SPB Git

Repository Public

webauthn.ts 5.0 KB 146 lines typescript

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
Files main
Menu

Theme