accounts
Version:
Tempo Accounts SDK
281 lines • 12.7 kB
JavaScript
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