import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, } from '@simplewebauthn/server' import type { RegistrationResponseJSON, AuthenticationResponseJSON, } from '@simplewebauthn/server' // PIN-free security-key login (see .claude/rules/auth/webauthn-security-keys.md): // * credentials are NON-discoverable (residentKey 'discouraged') — a YubiKey can only hold a // discoverable credential when a PIN is set, and Chrome/Windows then prompt for that PIN on // every login (credProtect escalation). Non-discoverable + allowCredentials needs no PIN. // * userVerification 'discouraged' on both ceremonies — presence (touch / NFC tap) only. // * the login therefore identifies the user by CREDENTIAL ID (webauthnIndex.ts), never by the // userHandle, which non-discoverable credentials do not return. export interface RpConfig { rpID: string rpName: string origin: string origins: string[] globalAllowlist: boolean globalAllowlistMax: number } export function getRpConfig(): RpConfig { const config = useRuntimeConfig() const wa: any = (config as any).webauthn ?? {} const rpID: string | undefined = wa.rpId const rpName: string = wa.rpName ?? 'LogShip ERP' const origin: string | undefined = wa.origin if (!rpID || !origin) { throw new Error('WEBAUTHN_RP_ID and WEBAUTHN_ORIGIN env vars must be set') } // Extra accepted origins: the Android app signs with its APK key hash // (`android:apk-key-hash:`), optionally more hosts. const extra = String(wa.extraOrigins ?? '') .split(',') .map(s => s.trim()) .filter(Boolean) const origins = Array.from(new Set([origin, ...extra])) const globalAllowlist = String(wa.globalAllowlist ?? '1') !== '0' const maxRaw = Number(wa.globalAllowlistMax) const globalAllowlistMax = Number.isFinite(maxRaw) && maxRaw > 0 ? maxRaw : 150 return { rpID, rpName, origin, origins, globalAllowlist, globalAllowlistMax } } // numeric ad_user.id encoded as Uint8Array for the WebAuthn userHandle function encodeUserHandle(userId: number): Uint8Array { // Buffer.from(...) returns a Buffer (extends Uint8Array); the // TextEncoder path produces Uint8Array which TS rejects // for the simplewebauthn typing on userID. return Buffer.from(String(userId), 'utf8') } export function decodeUserHandle(handle: string | undefined | null): number | null { if (!handle) return null try { const decoded = Buffer.from(handle, 'base64url').toString('utf8') const n = Number(decoded) return Number.isFinite(n) && n > 0 ? n : null } catch { return null } } export async function buildRegistrationOptions(opts: { userId: number userName: string userDisplayName?: string excludeCredentialIds?: string[] }) { const { rpID, rpName } = getRpConfig() return generateRegistrationOptions({ rpName, rpID, userName: opts.userName, userID: encodeUserHandle(opts.userId) as any, userDisplayName: opts.userDisplayName ?? opts.userName, attestationType: 'none', authenticatorSelection: { // Non-discoverable: no PIN needed on a YubiKey, no credProtect PIN escalation. residentKey: 'discouraged', requireResidentKey: false, userVerification: 'discouraged', // Roaming keys only — keeps Windows Hello / Touch ID out of the enrolment prompt. authenticatorAttachment: 'cross-platform', }, excludeCredentials: (opts.excludeCredentialIds ?? []).map(id => ({ id })), }) } export async function verifyRegistration(opts: { response: RegistrationResponseJSON expectedChallenge: string }) { const { rpID, origins } = getRpConfig() return verifyRegistrationResponse({ response: opts.response, expectedChallenge: opts.expectedChallenge, expectedOrigin: origins, expectedRPID: rpID, requireUserVerification: false, }) } export interface AllowCredential { id: string transports?: any[] } const ROAMING = ['usb', 'nfc'] function roamingTransports(recorded?: any[]): string[] { const t = Array.isArray(recorded) ? recorded.map(String) : [] const isRoaming = !t.length || t.some(x => x === 'usb' || x === 'nfc' || x === 'ble') if (!isRoaming) return t return Array.from(new Set([...ROAMING, ...t])) } export async function buildAuthenticationOptions(opts: { allowCredentials: AllowCredential[] }) { const { rpID } = getRpConfig() return generateAuthenticationOptions({ rpID, userVerification: 'discouraged', // Populated allow-list → non-discoverable credentials work, no PIN. Transport hints steer the // platform UI: Android Credential Manager only arms NFC when 'nfc' is listed, and a key enrolled // over USB on a desktop records ['usb'] only. Roaming keys therefore always advertise usb + nfc // (a USB-only key simply never answers on NFC); platform/hybrid hints are kept as recorded. allowCredentials: opts.allowCredentials.map(c => ({ id: c.id, transports: roamingTransports(c.transports) as any, })), }) } export async function verifyAuthentication(opts: { response: AuthenticationResponseJSON expectedChallenge: string credential: { id: string; publicKey: Uint8Array; counter: number; transports?: any[] } }) { const { rpID, origins } = getRpConfig() return verifyAuthenticationResponse({ response: opts.response, expectedChallenge: opts.expectedChallenge, expectedOrigin: origins, expectedRPID: rpID, credential: opts.credential as any, requireUserVerification: false, }) }