UNPKG

accounts

Version:

Tempo Accounts SDK

281 lines 12.7 kB
import { Base64, Bytes, Hex } from 'ox'; import { Credential } from 'ox/webauthn'; import { Authentication, Registration, } from 'webauthx/server'; import { from } from '../../Handler.js'; import * as Kv from '../../Kv.js'; import * as Session from './session.js'; const defaults = { cookieName: 'accounts_webauthn', ttl: { challenge: 5 * 60, // 5 minutes session: 24 * 60 * 60, // 24 hours }, }; const sessionKey = (token) => `session:${token}`; /** * Instantiates a WebAuthn ceremony handler that manages registration and * authentication flows server-side. * * Mounts five POST endpoints under `path`: * - `POST {path}/register/options` — generate credential creation options * - `POST {path}/register` — verify registration and store credential * - `POST {path}/login/options` — generate credential request options * - `POST {path}/login` — verify authentication and issue a session * (cookie via `Set-Cookie`, or `{ token }` body when `cookie: false` * or the request opts in via `returnToken: true`) * - `POST {path}/logout` — revoke the session and clear the cookie * * The returned handler also exposes `getSession(req)` for resolving the * current session from a follow-up request's cookie or `Authorization: * Bearer` header. * * @example * ```ts * import { Handler, Kv } from 'accounts/server' * * const handler = Handler.webAuthn({ * kv: Kv.memory(), * origin: 'https://example.com', * rpId: 'example.com', * }) * * export default handler * ``` * * @param options - Options. * @returns Request handler. */ export function webAuthn(options) { const { cookie = true, cookieName = defaults.cookieName, kv, onAuthenticate, onRegister, path = '', rpId, session = true, ttl: { challenge: challengeTtl = defaults.ttl.challenge, session: sessionTtl = defaults.ttl.session, } = {}, ...rest } = options; const origin = options.origin; const router = from(rest); router.post(`${path}/register/options`, async (c) => { try { const body = await c.req.raw.json(); const { excludeCredentialIds, name, userId } = body; const { challenge, options } = Registration.getOptions({ excludeCredentialIds, name, rp: { id: rpId, name: rpId }, ...(userId ? { user: { id: new TextEncoder().encode(userId), name } } : undefined), }); await kv.set(`challenge:${challenge}`, { created: Date.now(), name, ...(userId ? { userId } : {}) }, { ttl: challengeTtl }); return Response.json({ options }); } catch (error) { return Response.json({ error: error.message }, { status: 400 }); } }); router.post(`${path}/register`, async (c) => { try { const credential = (await c.req.raw.json()); const deserialized = Credential.deserialize(credential); const clientData = JSON.parse(Bytes.toString(new Uint8Array(deserialized.clientDataJSON))); const challenge = Hex.fromBytes(Base64.toBytes(clientData.challenge)); const stored = await kv.get(`challenge:${challenge}`); if (!stored || Date.now() - stored.created > challengeTtl * 1_000) throw new Error('Missing or expired challenge'); const result = Registration.verify(credential, { challenge, origin, rpId, }); const { publicKey } = result.credential; const credentialId = credential.id; // Base64url-encode the userId we registered with so it matches // the `userHandle` shape the authenticator emits on `/login`. // Callers see a consistent identifier across both flows. const userId = stored.userId ? Base64.fromBytes(Bytes.fromString(stored.userId), { pad: false, url: true }) : undefined; const [, hook] = await Promise.all([ kv.set(`credential:${credentialId}`, { publicKey, ...(userId ? { userId } : {}) }), onRegister?.({ credentialId, name: stored.name, publicKey, request: c.req.raw, ...(userId ? { userId } : {}), }), kv.delete(`challenge:${challenge}`), ]); // Successful registration is also a successful authentication for // the freshly-minted credential. Issue a session here so the // common "register → automatically signed in" flow doesn't require // an extra `/login` round-trip. if (!session) return Session.mergeResponse({ credentialId, publicKey, ...(userId ? { userId } : {}) }, hook || undefined); const issuedAt = Math.floor(Date.now() / 1000); const payload = { credentialId, publicKey, ...(userId ? { userId } : {}), issuedAt, expiresAt: issuedAt + sessionTtl, }; const token = Session.generateToken(); await kv.set(sessionKey(token), payload, { ttl: sessionTtl }); const json = { credentialId, publicKey, ...(userId ? { userId } : {}), ...(!cookie ? { token } : {}), }; const cookieHeader = cookie ? Session.serializeCookie({ name: cookieName, protocol: new URL(c.req.url).protocol, ttl: sessionTtl, value: token, }) : undefined; return Session.mergeResponse(json, hook || undefined, cookieHeader); } catch (error) { return Response.json({ error: error.message }, { status: 400 }); } }); router.post(`${path}/login/options`, async (c) => { try { const body = await c.req.raw.json(); const { allowCredentialIds, challenge: requestChallenge, credentialId, mediation, } = body; const { challenge, options: authOptions } = Authentication.getOptions({ challenge: requestChallenge, credentialId: allowCredentialIds ?? credentialId, rpId, }); const options = mediation ? { ...authOptions, mediation } : authOptions; await kv.set(`challenge:${challenge}`, Date.now(), { ttl: challengeTtl }); return Response.json({ options }); } catch (error) { return Response.json({ error: error.message }, { status: 400 }); } }); router.post(`${path}/login`, async (c) => { try { const body = (await c.req.raw.json()); const { returnToken, ...response } = body; const clientData = JSON.parse(response.metadata.clientDataJSON); const challenge = Hex.fromBytes(Base64.toBytes(clientData.challenge)); const [stored, credentialData] = await Promise.all([ kv.get(`challenge:${challenge}`), kv.get(`credential:${response.id}`), ]); if (!stored || Date.now() - stored > challengeTtl * 1_000) throw new Error('Missing or expired challenge'); if (!credentialData) throw new Error('Unknown credential'); const valid = Authentication.verify(response, { challenge, origin, publicKey: credentialData.publicKey, rpId, }); if (!valid) throw new Error('Authentication failed'); const rawResponse = response.raw?.response; const userHandle = rawResponse?.userHandle; const credentialId = response.id; const publicKey = credentialData.publicKey; // Surface the authenticator-emitted `userHandle` verbatim // (base64url-encoded user id). Fall back to the base64-encoded // userId we stashed during register, so callers see the same // identifier shape across register and login. const userId = userHandle && userHandle.length > 0 ? userHandle : (credentialData.userId ?? undefined); // Hook for side effects (user provisioning, analytics, allow/deny). // The legacy contract — return a `Response` to merge fields onto // the JSON body — is preserved. Throwing now rejects the request // with `401` (vs the outer `400`) so callers can tell hook errors // apart from protocol errors. let hookResponse; if (onAuthenticate) { try { const result = await onAuthenticate({ credentialId, publicKey, request: c.req.raw, ...(userId ? { userId } : {}), }); if (result) hookResponse = result; } catch (error) { await kv.delete(`challenge:${challenge}`); return Response.json({ error: error instanceof Error ? error.message : 'authentication rejected' }, { status: 401 }); } } // `session: false` short-circuits — login acts as a stateless // verification. No token, no cookie, no kv write. Useful for // hosts that mint their own session in `onAuthenticate` (e.g. JWTs). if (!session) { await kv.delete(`challenge:${challenge}`); return Session.mergeResponse({ credentialId, publicKey, ...(userId ? { userId } : {}), }, hookResponse); } const issuedAt = Math.floor(Date.now() / 1000); const payload = { credentialId, publicKey, ...(userId ? { userId } : {}), issuedAt, expiresAt: issuedAt + sessionTtl, }; const token = Session.generateToken(); await Promise.all([ kv.set(sessionKey(token), payload, { ttl: sessionTtl }), kv.delete(`challenge:${challenge}`), ]); const json = { credentialId, publicKey, ...(userId ? { userId } : {}), // Token mode: forced when `cookie: false`, opt-in via // `returnToken: true` otherwise. Cookie mode (default) carries // the token in `Set-Cookie` and omits it from the body. ...(!cookie || returnToken ? { token } : {}), }; // Cookie is appended on the merged response below — the route // builds its own `Response`, so Hono's context-stashed headers // wouldn't carry through. const cookieHeader = cookie && !returnToken ? Session.serializeCookie({ name: cookieName, protocol: new URL(c.req.url).protocol, ttl: sessionTtl, value: token, }) : undefined; return Session.mergeResponse(json, hookResponse, cookieHeader); } catch (error) { return Response.json({ error: error.message }, { status: 400 }); } }); // Logout has no meaning when sessions are disabled — skip mounting the // route entirely so callers get a clean `404` instead of a misleading // `204` no-op. if (session) router.post(`${path}/logout`, async (c) => { const token = Session.tokenFromRequest(c.req.raw, { cookie, cookieName }); if (token) await kv.delete(sessionKey(token)); const headers = new Headers(); if (cookie) headers.append('set-cookie', Session.clearCookieHeader(cookieName)); return new Response(null, { status: 204, headers }); }); const getSession = async (req) => { if (!session) return undefined; const token = Session.tokenFromRequest(req, { cookie, cookieName }); if (!token) return undefined; return await kv.get(sessionKey(token)); }; return Object.assign(router, { getSession }); } //# sourceMappingURL=webAuthn.js.map