Phaze Auth
Phaze Auth is the reusable library behind a Phaze app’s sessions and passwordless login. It is not a framework you configure — it’s a small set of pure primitives you wire: a session resolver (cookie → KV → D1 → Session | null), the WebAuthn browser ceremony, and the WebAuthn / TOTP / session cores on the Rust runtime. The app’s auth.ts binds them; Phaze Transport carries them; the router’s restricted guard reads them.
It is isomorphic like the rest of Phaze: the session read is always server-side (KV/D1 are server-only bindings), whether it runs during SSR or behind a client-dispatched actions.Session.request(). There is no client-side session store, no hook, no middleware — just one resolver, shared.
The two halves
Section titled “The two halves”Phaze Auth ships as a TypeScript package and a Rust crate that share one session wire contract ({ userId, expires }):
@madenowhere/phaze-auth (npm — TS) phaze-auth (crates.io — Rust)├── . server primitives phaze::auth::session schema + token/b64url helpers│ getSessionAndUser · SessionRecord phaze::auth::webauthn passkey attestation + assertion verify│ sessionKey · cookieName · SessionBindings phaze::auth::totp RFC-6238 enroll / verify└── /browser WebAuthn ceremony (re-exported via `phaze`, features = ["auth"]) createPasskey · getPasskey · b64url helpersThe two never share code — they share a contract: Rust’s SessionRec serialises to the same { userId, expires } JSON the TS SessionRecord interface describes, so a session minted by a Rust login/finish handler is read by the TS resolver without translation.
The session resolver
Section titled “The session resolver”The server entry (@madenowhere/phaze-auth) is built around one function — the canonical session read:
import { getSessionAndUser, type SessionRecord } from '@madenowhere/phaze-auth'
// token → KV `session:<token>` → D1 `users` → Session | null// null = anon / revoked / expired / user removedconst session = await getSessionAndUser(token, { sessions, db })// ^ { user: { id, roles: string[] }, expires: number } | nullgetSessionAndUser(token, bindings) is the whole read in one call: it reads the KV record (SessionRecord = { userId, expires }), rejects it if expired, looks up the D1 users row, splits roles, and returns the resolved Session — or null for any failure along the chain. Because the KV key is server-revocable, deleting session:<token> makes the very next read null — that’s how a revoke (and the live kill switch) works.
| Export | Kind | Role |
|---|---|---|
getSessionAndUser(token, bindings) | fn | the resolver — cookie token → Session | null |
SessionRecord | type | the Rust↔TS KV wire contract — { userId, expires } |
SessionBindings | type | the { sessions, db } the resolver needs (duck-typed KV/D1) |
sessionKey(token) | fn | `session:${token}` — the KV key convention |
cookieName(prod) | fn | __Secure-session (prod) / session (dev http) |
App auth config
Section titled “App auth config”A consumer’s src/auth.ts binds the generic resolver to its own KV/D1 and exports the app’s auth config via Auth({ session }) — one of the three once-per-app config helpers (Env / Transport / Auth):
import { getSessionAndUser } from '@madenowhere/phaze-auth'import { Auth } from '@madenowhere/phaze-cloudflare/auth'import { broadcast } from '@madenowhere/phaze-cloudflare/transport'import { env } from 'phaze:env'
export const COOKIE = env.prod ? '__Secure-session' : 'session' // __Secure- needs HTTPSexport type { SessionRecord } from '@madenowhere/phaze-auth'
// bind the app's KV/D1 instances to the generic resolverconst bindings = (transport: Env) => ({ sessions: transport.SESSIONS, db: transport.DB })
// (1) the guard resolver — core() calls this PRE-STREAM, in-process (no wire)export default Auth<Env>({ session: ({ cookies, transport }) => getSessionAndUser(cookies.get(COOKIE), bindings(transport)),})
// (2) the `use:[session]` provider — a plain fn; being in a fence's use:[] IS what// makes it a provider. Returns { session }, folded onto the handler ctx.export const session = async (_input: unknown, { cookies, transport }: ActionContext) => ({ session: await getSessionAndUser(cookies.get(COOKIE), bindings(transport)),})
// (3) the WRITE counterpart — revoke: delete the KV record AND push its live streams,// then clear the cookie. broadcast() kicks any open SSE connection (a bare delete// revokes server-side but wouldn't close a held stream). Idempotent.export const revokeUserSession = async (_input: unknown, { cookies, transport }: ActionContext) => { const token = cookies.get(COOKIE) if (token) { await transport.SESSIONS.delete(`session:${token}`) broadcast(`session:${token}`, 'null') } cookies.delete(COOKIE, { path: '/' })}The read lives once (getSessionAndUser) and is shared two ways with no duplication: the restricted guard calls it directly (pre-stream, in-process — no fetch/sign/envelope), and Session.request pulls it in over the signed wire via use:[session]. revokeUserSession is the write side, wired into Session.delete via use:[revokeUserSession].
Browser ceremony
Section titled “Browser ceremony”The client entry (@madenowhere/phaze-auth/browser) wraps navigator.credentials into two ceremony calls plus the base64url codecs WebAuthn needs:
import { createPasskey, getPasskey } from '@madenowhere/phaze-auth/browser'
// register: pass the server's start options → returns the finish payload to post backconst finish = await createPasskey(startOptions) // navigator.credentials.create(...)
// login: pass the server's start options → returns the assertion to post backconst assertion = await getPasskey(startOptions) // navigator.credentials.get(...)| Export | Role |
|---|---|
createPasskey(opts) | the register ceremony → { id, attestationObject, clientDataJSON, transports } |
getPasskey(opts) | the login ceremony → { id, authenticatorData, clientDataJSON, signature } |
b64urlToBuf · bufToB64url | base64url ⇄ ArrayBuffer — challenge/credential-ID encoding |
PasskeyRegisterStart/Finish · PasskeyAuthStart/Finish | ceremony types (compatible with the Rust-gen action outputs) |
End-to-end usage
Section titled “End-to-end usage”The (experimental)/passkey page is the full surface — register, login, a reactive session, a push-driven live session with a revoke kill switch, and a TOTP wizard. The auth-relevant skeleton:
import { s, watch } from '@madenowhere/phaze/dsl'import { minutes, timeout, ms } from '@madenowhere/phaze/time'import { actions, streams } from 'phaze:transport'import { useAction } from '@madenowhere/phaze-cloudflare/actions'import { createPasskey, getPasskey } from '@madenowhere/phaze-auth/browser'import { COOKIE } from '@/auth'
---data// server loader (pageCtx → cookies + transport): the direct KV read, already-there on hydratekv_session: transport.SESSIONS.get(`session:${cookies.get(COOKIE)}`, 'json')---state// the action-backed reactive session: SSR-seeded + revalidated (re-reads KV→D1 = auto revoke/expiry)session: s.async(actions.Session.request(), { revalidate: minutes(10) })status: s('idle')---
const pkRegisterStart = useAction(actions.Passkey.registerStart)const pkRegisterFinish = useAction(actions.Passkey.registerFinish)
const register = async () => { const start = await pkRegisterStart.execute() // Rust start → typed options if (start.error || !start.data) return status.set('register failed') const result = await pkRegisterFinish.execute(await createPasskey(start.data)) // ceremony → finish if (result.error || !result.data) return status.set('register failed') status.set(`signed in as ${result.data.userId}`) session.reload() // re-read the reactive session live.reconnect() // re-open the live stream with the fresh cookie}
// THE LIVE BIND — push-driven session over SSE (s.bind compiles to a 0-byte EventSource recipe).// Type comes from the ---Session fence's out: → Signal<Session | null>.const live = s.bind(streams.Session())const wasAuthed = s(false)
// THE KILL SWITCH — latch on auth, fire on loss. SSR-safe: live() is undefined server-side, so// neither watch fires there. revokeUserSession's broadcast() pushes null here → redirect.watch(live()?.user && wasAuthed.set(true))watch(wasAuthed() && live() === null && timeout(ms(300), location.assign('/login')))The flow, end to end:
actions.Passkey.registerStart/loginStart— Rust-proxied transport fences return typedPublicKeyCredential*Options; the page never mirrors the types.createPasskey/getPasskey— the browser ceremony turns those options into the finish/assertion payload.*Finish— mints the KV session (the Rust handler’sSet-Cookieis relayed by the fence);getSessionAndUserreads it from then on.session(s.async) — the reactive session, SSR-seeded and revalidated;session.reload()forces a re-read after an auth transition.live(s.bind(streams.Session())) — the push channel:revokeUserSession’sbroadcastpushesnull, the kill-switchwatchfires, the page redirects — no polling, no re-read loop.
TOTP (the authenticator-app wizard on the same page) follows the identical consume shapes against the Totp.* fences — s.async(actions.Totp.enrollStart()) for the QR + secret, useAction(actions.Totp.enrollFinish/verify) for the 6-digit confirm.
The Rust crate
Section titled “The Rust crate”The heavy verification lives in the phaze-auth crate, re-exported at phaze::auth::* when the phaze crate is built with features = ["auth"]:
use phaze::auth::session::{SessionRec, ChallengeRec, random_b64url, b64url_encode};use phaze::auth::{totp, webauthn};| Module | What it is |
|---|---|
phaze::auth::session | the SessionRec / ChallengeRec schema + token / base64url helpers (the Rust side of the SessionRecord contract) |
phaze::auth::webauthn | passkey parse_attestation_object (register) + verify_assertion (login) — ES256/CBOR-COSE over phaze::crypto::p256 |
phaze::auth::totp | RFC-6238 enroll / verify — HMAC-SHA1 (authenticator-app standard) |
All three are pure-Rust with no Worker dependency, so they compile and unit-test on the native target (cargo test) without a wrangler environment — the verification cores travel with their own tests. The handler code that wires them to KV/D1 (the four passkey endpoints + the three TOTP endpoints) lives in the consumer’s rust-api/src/lib.rs; the Passkeys page covers the assertion-verify checks in depth.
Where it lives
Section titled “Where it lives”- Server primitives:
@madenowhere/phaze-auth—getSessionAndUser,SessionRecord,sessionKey,cookieName. - Browser ceremony:
@madenowhere/phaze-auth/browser—createPasskey,getPasskey, the base64url codecs. - App config:
src/auth.ts—Auth({ session })(the guard resolver) + thesession/revokeUserSessionproviders; importsAuthfrom@madenowhere/phaze-cloudflare/auth,broadcastfrom@madenowhere/phaze-cloudflare/transport. - Rust cores: the
phaze-authcrate →phaze::auth::{session, totp, webauthn}(features = ["auth"]); handlers inrust-api/src/lib.rs.
See also
Section titled “See also”- Passkeys (WebAuthn) — the four endpoints, the assertion-verify checks, RP-ID scoping,
noneattestation. - Phaze Transport — the
Session.*/Passkey.*/Totp.*fences,use:[]providers,rust:proxying, ands.bindstreams. - Router → Authorization — the per-route
restrictedguard that reads the resolver pre-stream. - Signing & verification — the
sign:field onSession.requestand the live session stream.