SPB Git

Repository Public

webauthn.ts 4.9 KB 145 lines typescript

1/**2 * WebAuthn (passkeys) on top of @simplewebauthn/server v14.3 * RP ID = hostname of CR_PUBLIC_ORIGIN; expected origin = CR_PUBLIC_ORIGIN; user verification4 * is REQUIRED everywhere, which is why a passkey alone counts as full authentication.5 *6 * Challenges are always stored server-side and are single-use with a 5 min lifetime:7 *  - with a session (registration, passkey as 2nd step): in `auth_sessions.pending`;8 *  - before any session (passkey-first login): a row in `preauth_challenges` keyed by the9 *    sha256 of an opaque random token held in the short-lived cookie `__Host-cr_pre`.10 */11import {12  generateAuthenticationOptions,13  generateRegistrationOptions,14  verifyAuthenticationResponse,15  verifyRegistrationResponse,16  type AuthenticationResponseJSON,17  type PublicKeyCredentialCreationOptionsJSON,18  type PublicKeyCredentialRequestOptionsJSON,19  type RegistrationResponseJSON,20  type WebAuthnCredential,21} from '@simplewebauthn/server';22import { z } from 'zod';23import type { ServerConfig } from '../config.js';2425export const CHALLENGE_TTL_MS = 5 * 60_000;26const b64url = z.string().min(1).max(8192).regex(/^[A-Za-z0-9_-]+$/);2728export const RegistrationCredential = z.object({29  id: b64url,30  rawId: b64url,31  type: z.literal('public-key'),32  response: z.object({33    clientDataJSON: b64url,34    attestationObject: z.string().min(1).max(65536).regex(/^[A-Za-z0-9_-]+$/),35    transports: z.array(z.string().max(20)).max(10).optional(),36  }).passthrough(),37  clientExtensionResults: z.record(z.string(), z.unknown()).default({}),38  authenticatorAttachment: z.string().max(40).nullish(),39});40export const AuthenticationCredential = z.object({41  id: b64url,42  rawId: b64url,43  type: z.literal('public-key'),44  response: z.object({45    clientDataJSON: b64url,46    authenticatorData: b64url,47    signature: b64url,48    userHandle: z.string().max(512).nullish(),49  }),50  clientExtensionResults: z.record(z.string(), z.unknown()).default({}),51  authenticatorAttachment: z.string().max(40).nullish(),52});5354export interface StoredCredential {55  credentialId: string;56  publicKey: Buffer;57  counter: number;58  transports: string[];59}6061export function registrationOptions(62  config: ServerConfig,63  user: { id: string; username: string },64  existing: StoredCredential[],65): Promise<PublicKeyCredentialCreationOptionsJSON> {66  return generateRegistrationOptions({67    rpName: config.agent.productName,68    rpID: config.rpId,69    userName: user.username,70    userID: new TextEncoder().encode(user.id) as Uint8Array<ArrayBuffer>,71    attestationType: 'none',72    excludeCredentials: existing.map((c) => ({ id: c.credentialId, transports: c.transports })),73    authenticatorSelection: { residentKey: 'required', userVerification: 'required' },74    timeout: 60_000,75  });76}7778export async function verifyRegistration(79  config: ServerConfig,80  credential: z.infer<typeof RegistrationCredential>,81  expectedChallenge: string,82): Promise<StoredCredential | null> {83  try {84    const r = await verifyRegistrationResponse({85      response: credential as unknown as RegistrationResponseJSON,86      expectedChallenge,87      expectedOrigin: config.publicOrigin,88      expectedRPID: config.rpId,89      requireUserVerification: true,90    });91    if (!r.verified) return null;92    const c = r.registrationInfo.credential;93    return {94      credentialId: c.id,95      publicKey: Buffer.from(c.publicKey),96      counter: c.counter,97      transports: (c.transports ?? []).map(String),98    };99  } catch {100    return null;101  }102}103104export function authenticationOptions(105  config: ServerConfig,106  allow: StoredCredential[] | null,107): Promise<PublicKeyCredentialRequestOptionsJSON> {108  return generateAuthenticationOptions({109    rpID: config.rpId,110    userVerification: 'required',111    timeout: 60_000,112    // null => discoverable credentials: credential ids are not disclosed to anonymous callers.113    allowCredentials: allow?.map((c) => ({ id: c.credentialId, transports: c.transports })),114  });115}116117/** Returns the new signature counter, or null when the assertion is not valid. */118export async function verifyAuthentication(119  config: ServerConfig,120  credential: z.infer<typeof AuthenticationCredential>,121  expectedChallenge: string,122  stored: StoredCredential,123): Promise<number | null> {124  try {125    const r = await verifyAuthenticationResponse({126      response: credential as unknown as AuthenticationResponseJSON,127      expectedChallenge,128      expectedOrigin: config.publicOrigin,129      expectedRPID: config.rpId,130      requireUserVerification: true,131      credential: {132        id: stored.credentialId,133        publicKey: new Uint8Array(stored.publicKey) as Uint8Array<ArrayBuffer>,134        counter: stored.counter,135        transports: stored.transports as NonNullable<WebAuthnCredential['transports']>,136      },137    });138    if (!r.verified || !r.authenticationInfo.userVerified) return null;139    // The library rejects a counter that did not increase when either side is non-zero.140    return r.authenticationInfo.newCounter;141  } catch {142    return null;143  }144}145
Files main
Menu

Theme