SPB Git

Repository Public

auth.ts 10.2 KB 154 lines typescript

1import { createHash, randomBytes, randomUUID } from 'node:crypto';2import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';3import '@fastify/cookie';4import {5  generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse,6  type AuthenticationResponseJSON, type RegistrationResponseJSON,7} from '@simplewebauthn/server';8import { z } from 'zod';9import { config, secureCookies, UserError } from './config.ts';10import { audit, sql } from './db.ts';11import { signer } from './signer-client.ts';1213export const COOKIE = secureCookies ? '__Host-spw' : 'spw';14const IDLE_MIN = 30, ABSOLUTE_H = 12;15const h = (s: string) => createHash('sha256').update(s).digest('hex');16const enrollChallenge = (id: string, pk: string) => createHash('sha256').update(`SPW-ENROLL-v1\n${id}\n${pk}`).digest();1718interface Cred { id: string; public_key: string; counter: string; transports: string[]; label: string; created_at: Date; last_used_at: Date | null; device_type: string; backed_up: boolean }19export const listCreds = () => sql<Cred[]>`select * from credentials order by created_at`;2021async function putChallenge(kind: string, challenge: string): Promise<string> {22  const id = randomUUID();23  await sql`delete from challenges where expires_at < now()`;24  await sql`insert into challenges (id, kind, challenge, expires_at) values (${id}, ${kind}, ${challenge}, now() + interval '3 minutes')`;25  return id;26}27async function takeChallenge(id: string, kind: string): Promise<string> {28  const [r] = await sql<{ challenge: string }[]>`delete from challenges where id = ${id} and kind = ${kind} and expires_at > now() returning challenge`;29  if (!r) throw new UserError('challenge_expired', 'challenge expired, try again', 401);30  return r.challenge;31}3233async function startSession(req: FastifyRequest, reply: FastifyReply): Promise<void> {34  const token = randomBytes(32).toString('base64url');35  await sql`insert into sessions (id_hash, expires_at, ua, ip) values (${h(token)}, now() + ${ABSOLUTE_H + ' hours'}::interval, ${String(req.headers['user-agent'] ?? '').slice(0, 200)}, ${req.ip})`;36  reply.setCookie(COOKIE, token, { httpOnly: true, secure: secureCookies, sameSite: 'strict', path: '/', maxAge: ABSOLUTE_H * 3600 });37}3839export async function isAuthed(req: FastifyRequest): Promise<boolean> {40  const token = req.cookies[COOKIE];41  if (!token || token.length > 100) return false;42  const [s] = await sql`update sessions set last_seen = now() where id_hash = ${h(token)} and not revoked and expires_at > now()43    and last_seen > now() - ${IDLE_MIN + ' minutes'}::interval returning id_hash`;44  return !!s;45}4647const RegBody = z.object({ challengeId: z.uuid(), response: z.any(), setupToken: z.string().min(8).max(100).optional(), label: z.string().max(40).optional() });48const AuthBody = z.object({ challengeId: z.uuid(), response: z.any() });49const regOptions = async (exclude: Cred[]) => generateRegistrationOptions({50  rpName: 'SP Wallet', rpID: config.rpId, userName: 'owner', userDisplayName: 'SP Wallet', userID: new TextEncoder().encode('sp-wallet-owner'),51  attestationType: 'none', excludeCredentials: exclude.map((c) => ({ id: c.id, transports: c.transports as never })),52  authenticatorSelection: { residentKey: 'required', userVerification: 'required' },53});54async function verifyReg(body: z.infer<typeof RegBody>, kind: string) {55  const expectedChallenge = await takeChallenge(body.challengeId, kind);56  const v = await verifyRegistrationResponse({ response: body.response as RegistrationResponseJSON, expectedChallenge, expectedOrigin: config.origin, expectedRPID: config.rpId, requireUserVerification: true })57    .catch((e: Error) => { throw new UserError('registration_failed', e.message, 401); });58  if (!v.verified) throw new UserError('registration_failed', 'passkey registration not verified', 401);59  const c = v.registrationInfo.credential;60  return { id: c.id, publicKey: Buffer.from(c.publicKey).toString('base64url'), counter: c.counter, transports: (c.transports ?? []) as string[],61    deviceType: v.registrationInfo.credentialDeviceType, backedUp: v.registrationInfo.credentialBackedUp };62}6364export function authRoutes(app: FastifyInstance): void {65  const strict = { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } };6667  app.get('/api/v1/auth/state', async (req) => {68    const n = (await listCreds()).length;69    return { setupRequired: n === 0, authenticated: await isAuthed(req) };70  });7172  // --- one-time owner setup: closed forever once a passkey exists -------------------------------------------73  app.post('/api/v1/auth/setup/options', strict, async () => {74    if ((await listCreds()).length > 0) throw new UserError('setup_closed', 'setup already completed', 403);75    const options = await regOptions([]);76    return { challengeId: await putChallenge('setup', options.challenge), options };77  });78  app.post('/api/v1/auth/setup/verify', strict, async (req, reply) => {79    const body = RegBody.parse(req.body);80    if ((await listCreds()).length > 0) throw new UserError('setup_closed', 'setup already completed', 403);81    if (!body.setupToken) throw new UserError('setup_token', 'setup token required', 403);82    const c = await verifyReg(body, 'setup');83    // the signer validates the one-time token and pins this passkey as the only approver84    await signer.enroll({ credential: { id: c.id, publicKey: c.publicKey, counter: c.counter, transports: c.transports }, setupToken: body.setupToken });85    await sql`insert into credentials (id, public_key, counter, transports, label, device_type, backed_up) values (${c.id}, ${c.publicKey}, ${c.counter}, ${sql.json(c.transports)}, ${body.label ?? 'Passkey 1'}, ${c.deviceType}, ${c.backedUp})`;86    await audit('owner.setup', 'owner', { credential: c.id.slice(0, 12) });87    await startSession(req, reply);88    return { ok: true };89  });9091  // --- login -------------------------------------------------------------------------------------------------92  app.post('/api/v1/auth/login/options', strict, async () => {93    const options = await generateAuthenticationOptions({ rpID: config.rpId, userVerification: 'required', allowCredentials: [] });94    return { challengeId: await putChallenge('login', options.challenge), options };95  });96  app.post('/api/v1/auth/login/verify', strict, async (req, reply) => {97    const body = AuthBody.parse(req.body);98    const response = body.response as AuthenticationResponseJSON;99    const expectedChallenge = await takeChallenge(body.challengeId, 'login');100    const [cred] = await sql<Cred[]>`select * from credentials where id = ${String(response?.id ?? '')}`;101    const fail = async (why: string) => { await audit('login.failed', 'unknown', { why, ip: req.ip }); throw new UserError('login_failed', 'authentication failed', 401); };102    if (!cred) return fail('unknown credential');103    const v = await verifyAuthenticationResponse({ response, expectedChallenge, expectedOrigin: config.origin, expectedRPID: config.rpId, requireUserVerification: true,104      credential: { id: cred.id, publicKey: new Uint8Array(Buffer.from(cred.public_key, 'base64url')), counter: Number(cred.counter), transports: cred.transports as never } }).catch(() => null);105    if (!v?.verified) return fail('bad assertion');106    await sql`update credentials set counter = ${v.authenticationInfo.newCounter}, last_used_at = now() where id = ${cred.id}`;107    await audit('login.ok', 'owner', { credential: cred.id.slice(0, 12), ip: req.ip });108    await startSession(req, reply);109    return { ok: true };110  });111  app.post('/api/v1/auth/logout', async (req, reply) => {112    const t = req.cookies[COOKIE];113    if (t) await sql`update sessions set revoked = true where id_hash = ${h(t)}`;114    reply.clearCookie(COOKIE, { path: '/' });115    return { ok: true };116  });117}118119/** Authenticated: add a second passkey. The signer only accepts it with an assertion from an existing passkey. */120export function deviceRoutes(app: FastifyInstance): void {121  app.post('/api/v1/security/devices/options', async () => {122    const options = await regOptions(await listCreds());123    return { challengeId: await putChallenge('add-device', options.challenge), options };124  });125  app.post('/api/v1/security/devices/verify', async (req) => {126    const body = RegBody.parse(req.body);127    const c = await verifyReg(body, 'add-device');128    await sql`delete from pending_credentials where expires_at < now()`;129    await sql`insert into pending_credentials (id, public_key, counter, transports, label, device_type, backed_up, expires_at)130      values (${c.id}, ${c.publicKey}, ${c.counter}, ${sql.json(c.transports)}, ${body.label ?? 'Passkey'}, ${c.deviceType}, ${c.backedUp}, now() + interval '5 minutes')131      on conflict (id) do update set expires_at = excluded.expires_at`;132    const creds = await listCreds();133    const options = await generateAuthenticationOptions({ rpID: config.rpId, userVerification: 'required', challenge: new Uint8Array(enrollChallenge(c.id, c.publicKey)),134      allowCredentials: creds.map((x) => ({ id: x.id, transports: x.transports as never })) });135    return { pendingId: c.id, options };136  });137  app.post('/api/v1/security/devices/confirm', async (req) => {138    const body = z.object({ pendingId: z.string().max(1024), assertion: z.any() }).parse(req.body);139    const [p] = await sql`select * from pending_credentials where id = ${body.pendingId} and expires_at > now()`;140    if (!p) throw new UserError('expired', 'pending passkey expired', 400);141    await signer.enroll({ credential: { id: p.id, publicKey: p.public_key, counter: Number(p.counter), transports: p.transports }, assertion: body.assertion });142    await sql`insert into credentials (id, public_key, counter, transports, label, device_type, backed_up) values (${p.id}, ${p.public_key}, ${p.counter}, ${sql.json(p.transports)}, ${p.label}, ${p.device_type}, ${p.backed_up})`;143    await sql`delete from pending_credentials where id = ${p.id}`;144    await audit('device.added', 'owner', { credential: String(p.id).slice(0, 12), label: p.label });145    return { ok: true };146  });147  app.post('/api/v1/security/sessions/revoke-all', async (req, reply) => {148    await sql`update sessions set revoked = true where not revoked`;149    reply.clearCookie(COOKIE, { path: '/' });150    await audit('sessions.revoked', 'owner', { ip: req.ip });151    return { ok: true };152  });153}154
Files main
Menu

Theme