// POST /api/idempiere-auth/webauthn/login-options // body: { userName?: string } // // Tap-only login: without a userName the allow-list holds the credential IDs of ALL enrolled // employee keys (capped by WEBAUTHN_GLOBAL_ALLOWLIST_MAX, default 150). The YubiKey answers for // the credential it owns and login-verify maps it back to the user. With a userName only that // user's keys are sent (fallback when the global list is disabled or too large → the response // carries `needsUserName: true` and the sign-in page asks for the username first). // // `challengeToken` duplicates the signed challenge cookie for cookie-less clients (native app). import { getRequestIP } from 'h3' import { buildAuthenticationOptions, getRpConfig } from '../../../utils/webauthnHelper' import { issueChallenge } from '../../../utils/webauthnChallengeStore' import { listAllCredentials, listCredentialsForUserName } from '../../../utils/webauthnIndex' // The public allow-list reveals opaque credential IDs (useless without the key). Still throttle // scraping: per IP, per process (PM2 doubles the effective rate — accepted, as in authCooldown.ts). const WINDOW_MS = 60_000 const MAX_PER_WINDOW = 40 const hits = new Map() function throttle(ip: string) { const now = Date.now() if (hits.size > 2000) for (const [k, v] of hits) if (v.resetAt < now) hits.delete(k) const e = hits.get(ip) if (!e || e.resetAt < now) { hits.set(ip, { n: 1, resetAt: now + WINDOW_MS }); return } e.n += 1 if (e.n > MAX_PER_WINDOW) { throw createError({ statusCode: 429, statusMessage: 'Too many security-key requests, retry in a minute' }) } } export default defineEventHandler(async (event) => { throttle('ip:' + (getRequestIP(event, { xForwardedFor: true }) ?? 'unknown')) const body = await readBody(event).catch(() => ({} as any)) const userName = String(body?.userName ?? '').trim() const { globalAllowlist, globalAllowlistMax } = getRpConfig() let creds: Awaited> = [] let needsUserName = false try { if (userName) { creds = await listCredentialsForUserName(event, userName) } else if (globalAllowlist) { creds = await listAllCredentials(event) if (creds.length > globalAllowlistMax) { creds = []; needsUserName = true } } else { needsUserName = true } } catch (e: any) { console.warn('[webauthn] credential index unavailable:', e?.message) throw createError({ statusCode: 503, statusMessage: 'Security-key login temporarily unavailable' }) } if (needsUserName) return { needsUserName: true } // Unknown username / nobody enrolled: still return a syntactically valid ceremony (empty // allow-list → the browser reports "no matching key") instead of revealing which names exist. const options = await buildAuthenticationOptions({ allowCredentials: creds.map(c => ({ id: c.key.credentialId, transports: c.key.transports })), }) const challengeToken = issueChallenge(event, options.challenge, { kind: 'login' }) return { ...options, challengeToken, credentialCount: creds.length } })