// Global index of enrolled security keys: credentialId → ad_user. // // Login is "tap only, no username": login-options sends the credential IDs of ALL enrolled keys // as allowCredentials, the YubiKey answers for the one it owns, and login-verify maps that // credential ID back to the user here. Non-discoverable credentials return no userHandle, so // this index is the only way to know who tapped. // // Source of truth stays AD_User.WebAuthnCredentials (adUserCredentials.ts); this is a 60 s // per-worker read cache with single-flight, invalidated by the enrol / delete routes. PM2 workers // cache independently — a key enrolled on worker A is visible on worker B after ≤ 60 s, or at once // when B gets a lookup miss (a miss always triggers a refresh, see lookup()). import fetchHelper from './fetchHelper' import type { WebAuthnKeyEntry } from './adUserCredentials' export interface IndexedCredential { userId: number userName: string // ad_user.Name (the login id auth/tokens accepts) userValue: string // ad_user.Value (search key, alternative login id) key: WebAuthnKeyEntry } interface IndexState { byCredential: Map byUser: Map loadedAt: number } const TTL_MS = 60_000 const PAGE = 500 const MAX_USERS = 2000 let state: IndexState | null = null let inflight: Promise | null = null function serviceToken(): string { const config = useRuntimeConfig() const token: string | undefined = (config.api as any)?.idempieretoken if (!token) throw new Error('IDEMPIERETOKEN env var is not set — cannot build the security-key index') return token } function parseKeys(raw: any): WebAuthnKeyEntry[] { if (!raw) return [] try { const obj = typeof raw === 'string' ? JSON.parse(raw) : raw return Array.isArray(obj?.keys) ? obj.keys.filter((k: any) => k && typeof k.credentialId === 'string') : [] } catch { return [] } } async function load(event: any): Promise { const token = serviceToken() const byCredential = new Map() const byUser = new Map() // `neq null` is the only supported null test in idempiere-rest OData (see // .claude/rules/idempiere/write-casing-and-flags.md). Paged with $top/$skip, hard-capped. for (let skip = 0; skip < MAX_USERS; skip += PAGE) { const url = 'models/ad_user?$filter=WebAuthnCredentials neq null and IsActive eq true' + '&$select=Name,Value,WebAuthnCredentials&$orderby=AD_User_ID asc' + '&$top=' + PAGE + '&$skip=' + skip const res: any = await fetchHelper(event, url, 'GET', token, null) const records: any[] = Array.isArray(res?.records) ? res.records : [] for (const rec of records) { const userId = Number(rec?.id ?? rec?.AD_User_ID?.id ?? rec?.AD_User_ID) if (!userId) continue const keys = parseKeys(rec?.WebAuthnCredentials ?? rec?.webAuthnCredentials) if (!keys.length) continue const userName = String(rec?.Name ?? rec?.name ?? '') const userValue = String(rec?.Value ?? rec?.value ?? '') const list: IndexedCredential[] = [] for (const key of keys) { const entry: IndexedCredential = { userId, userName, userValue, key } byCredential.set(key.credentialId, entry) list.push(entry) } byUser.set(userId, list) } if (records.length < PAGE) break } return { byCredential, byUser, loadedAt: Date.now() } } async function ensure(event: any, force = false): Promise { if (!force && state && Date.now() - state.loadedAt < TTL_MS) return state if (!inflight) { inflight = load(event) .then(s => { state = s; return s }) .finally(() => { inflight = null }) } return inflight } export function invalidateWebAuthnIndex(): void { state = null } /** All enrolled credentials (allow-list for the username-less login). `fresh` bypasses the cache. */ export async function listAllCredentials(event: any, fresh = false): Promise { const s = await ensure(event, fresh) return Array.from(s.byCredential.values()) } /** Credentials of one user, matched by Name or Value (case-insensitive). */ export async function listCredentialsForUserName(event: any, userName: string): Promise { const needle = String(userName ?? '').trim().toLowerCase() if (!needle) return [] const s = await ensure(event) const out: IndexedCredential[] = [] for (const list of s.byUser.values()) { const first = list[0] if (!first) continue if (first.userName.toLowerCase() === needle || first.userValue.toLowerCase() === needle) out.push(...list) } return out } /** Resolve a credential ID to its user. A miss forces one refresh (key enrolled on another worker). */ export async function lookupCredential(event: any, credentialId: string): Promise { if (!credentialId) return null let s = await ensure(event) let hit = s.byCredential.get(credentialId) if (hit) return hit s = await ensure(event, true) hit = s.byCredential.get(credentialId) return hit ?? null }