Repository Public
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