Skip to content

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.

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 helpers

The 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 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 removed
const session = await getSessionAndUser(token, { sessions, db })
// ^ { user: { id, roles: string[] }, expires: number } | null

getSessionAndUser(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.

ExportKindRole
getSessionAndUser(token, bindings)fnthe resolver — cookie token → Session | null
SessionRecordtypethe Rust↔TS KV wire contract — { userId, expires }
SessionBindingstypethe { 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)

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):

src/auth.ts
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 HTTPS
export type { SessionRecord } from '@madenowhere/phaze-auth'
// bind the app's KV/D1 instances to the generic resolver
const 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].

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 back
const finish = await createPasskey(startOptions) // navigator.credentials.create(...)
// login: pass the server's start options → returns the assertion to post back
const assertion = await getPasskey(startOptions) // navigator.credentials.get(...)
ExportRole
createPasskey(opts)the register ceremony → { id, attestationObject, clientDataJSON, transports }
getPasskey(opts)the login ceremony → { id, authenticatorData, clientDataJSON, signature }
b64urlToBuf · bufToB64urlbase64url ⇄ ArrayBuffer — challenge/credential-ID encoding
PasskeyRegisterStart/Finish · PasskeyAuthStart/Finishceremony types (compatible with the Rust-gen action outputs)

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 hydrate
kv_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:

  1. actions.Passkey.registerStart / loginStartRust-proxied transport fences return typed PublicKeyCredential*Options; the page never mirrors the types.
  2. createPasskey / getPasskey — the browser ceremony turns those options into the finish/assertion payload.
  3. *Finish — mints the KV session (the Rust handler’s Set-Cookie is relayed by the fence); getSessionAndUser reads it from then on.
  4. session (s.async) — the reactive session, SSR-seeded and revalidated; session.reload() forces a re-read after an auth transition.
  5. live (s.bind(streams.Session())) — the push channel: revokeUserSession’s broadcast pushes null, the kill-switch watch fires, 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 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};
ModuleWhat it is
phaze::auth::sessionthe SessionRec / ChallengeRec schema + token / base64url helpers (the Rust side of the SessionRecord contract)
phaze::auth::webauthnpasskey parse_attestation_object (register) + verify_assertion (login) — ES256/CBOR-COSE over phaze::crypto::p256
phaze::auth::totpRFC-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.

  • Server primitives: @madenowhere/phaze-authgetSessionAndUser, SessionRecord, sessionKey, cookieName.
  • Browser ceremony: @madenowhere/phaze-auth/browsercreatePasskey, getPasskey, the base64url codecs.
  • App config: src/auth.tsAuth({ session }) (the guard resolver) + the session / revokeUserSession providers; imports Auth from @madenowhere/phaze-cloudflare/auth, broadcast from @madenowhere/phaze-cloudflare/transport.
  • Rust cores: the phaze-auth crate → phaze::auth::{session, totp, webauthn} (features = ["auth"]); handlers in rust-api/src/lib.rs.
  • Passkeys (WebAuthn) — the four endpoints, the assertion-verify checks, RP-ID scoping, none attestation.
  • Phaze Transport — the Session.* / Passkey.* / Totp.* fences, use:[] providers, rust: proxying, and s.bind streams.
  • Router → Authorization — the per-route restricted guard that reads the resolver pre-stream.
  • Signing & verification — the sign: field on Session.request and the live session stream.