Repository Public
1/**2 * WebAuthn (passkeys) on top of @simplewebauthn/server v14.3 * RP ID = hostname of AOS_PUBLIC_ORIGIN; expected origin = AOS_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-aos_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';24import { PRODUCT_NAME } from '../dto-auth.js';2526export const CHALLENGE_TTL_MS = 5 * 60_000;27const b64url = z.string().min(1).max(8192).regex(/^[A-Za-z0-9_-]+$/);2829export const RegistrationCredential = z.object({30 id: b64url,31 rawId: b64url,32 type: z.literal('public-key'),33 response: z.object({34 clientDataJSON: b64url,35 attestationObject: z.string().min(1).max(65536).regex(/^[A-Za-z0-9_-]+$/),36 transports: z.array(z.string().max(20)).max(10).optional(),37 }).passthrough(),38 clientExtensionResults: z.record(z.string(), z.unknown()).default({}),39 authenticatorAttachment: z.string().max(40).nullish(),40});41export const AuthenticationCredential = z.object({42 id: b64url,43 rawId: b64url,44 type: z.literal('public-key'),45 response: z.object({46 clientDataJSON: b64url,47 authenticatorData: b64url,48 signature: b64url,49 userHandle: z.string().max(512).nullish(),50 }),51 clientExtensionResults: z.record(z.string(), z.unknown()).default({}),52 authenticatorAttachment: z.string().max(40).nullish(),53});5455export interface StoredCredential {56 credentialId: string;57 publicKey: Buffer;58 counter: number;59 transports: string[];60}6162export function registrationOptions(63 config: ServerConfig,64 user: { id: string; username: string },65 existing: StoredCredential[],66): Promise<PublicKeyCredentialCreationOptionsJSON> {67 return generateRegistrationOptions({68 rpName: PRODUCT_NAME,69 rpID: config.rpId,70 userName: user.username,71 userID: new TextEncoder().encode(user.id) as Uint8Array<ArrayBuffer>,72 attestationType: 'none',73 excludeCredentials: existing.map((c) => ({ id: c.credentialId, transports: c.transports })),74 authenticatorSelection: { residentKey: 'required', userVerification: 'required' },75 timeout: 60_000,76 });77}7879export async function verifyRegistration(80 config: ServerConfig,81 credential: z.infer<typeof RegistrationCredential>,82 expectedChallenge: string,83): Promise<StoredCredential | null> {84 try {85 const r = await verifyRegistrationResponse({86 response: credential as unknown as RegistrationResponseJSON,87 expectedChallenge,88 expectedOrigin: config.publicOrigin,89 expectedRPID: config.rpId,90 requireUserVerification: true,91 });92 if (!r.verified) return null;93 const c = r.registrationInfo.credential;94 return {95 credentialId: c.id,96 publicKey: Buffer.from(c.publicKey),97 counter: c.counter,98 transports: (c.transports ?? []).map(String),99 };100 } catch {101 return null;102 }103}104105export function authenticationOptions(106 config: ServerConfig,107 allow: StoredCredential[] | null,108): Promise<PublicKeyCredentialRequestOptionsJSON> {109 return generateAuthenticationOptions({110 rpID: config.rpId,111 userVerification: 'required',112 timeout: 60_000,113 // null => discoverable credentials: credential ids are not disclosed to anonymous callers.114 allowCredentials: allow?.map((c) => ({ id: c.credentialId, transports: c.transports })),115 });116}117118/** Returns the new signature counter, or null when the assertion is not valid. */119export async function verifyAuthentication(120 config: ServerConfig,121 credential: z.infer<typeof AuthenticationCredential>,122 expectedChallenge: string,123 stored: StoredCredential,124): Promise<number | null> {125 try {126 const r = await verifyAuthenticationResponse({127 response: credential as unknown as AuthenticationResponseJSON,128 expectedChallenge,129 expectedOrigin: config.publicOrigin,130 expectedRPID: config.rpId,131 requireUserVerification: true,132 credential: {133 id: stored.credentialId,134 publicKey: new Uint8Array(stored.publicKey) as Uint8Array<ArrayBuffer>,135 counter: stored.counter,136 transports: stored.transports as NonNullable<WebAuthnCredential['transports']>,137 },138 });139 if (!r.verified || !r.authenticationInfo.userVerified) return null;140 // The library rejects a counter that did not increase when either side is non-zero.141 return r.authenticationInfo.newCounter;142 } catch {143 return null;144 }145}146