// POST /api/idempiere-auth/webauthn/login-verify // body: { response: AuthenticationResponseJSON, challengeToken?, pwa?, mobileWorker? } // // 1. consume the challenge (cookie or body token) + replay ledger // 2. resolve the user by CREDENTIAL ID (webauthnIndex) — userHandle only as legacy fallback // 3. verify the assertion, bump counter + lastUsedAt // 4. decrypt the vaulted iDempiere password and run the EXISTING login pipeline: // web → /api/idempiere-auth/login (Set-Cookie forwarded to the browser) // app → /api/app-auth/login (X-Logship-App: 1; token + refresh_token in the body) import { verifyAuthentication, decodeUserHandle } from '../../../utils/webauthnHelper' import { consumeChallenge } from '../../../utils/webauthnChallengeStore' import { readCredentials, writeCredentials, fetchUserName } from '../../../utils/adUserCredentials' import { lookupCredential } from '../../../utils/webauthnIndex' import { markChallengeUsed } from '../../../utils/webauthnReplayDb' import { decryptPassword } from '../../../utils/passwordCipher' export default defineEventHandler(async (event) => { const body = await readBody(event) const response = body?.response if (!response?.response?.clientDataJSON || typeof response?.id !== 'string') { throw createError({ statusCode: 400, statusMessage: 'Missing authentication response' }) } // 1. Decode + consume challenge (signed cookie or body token issued by login-options) let clientData: any try { clientData = JSON.parse(Buffer.from(response.response.clientDataJSON, 'base64url').toString('utf8')) } catch { throw createError({ statusCode: 400, statusMessage: 'Malformed clientDataJSON' }) } const challenge: string = String(clientData?.challenge ?? '') const entry = consumeChallenge(event, 'login', challenge, body?.challengeToken) if (!entry) { throw createError({ statusCode: 400, statusMessage: 'Challenge expired or invalid' }) } if (!markChallengeUsed(challenge, 'login', entry.expiresAt)) { throw createError({ statusCode: 400, statusMessage: 'Challenge already used' }) } // 2. Resolve user by credential id; legacy discoverable keys may still carry a userHandle. let userId: number | null = null const indexed = await lookupCredential(event, response.id).catch(() => null) if (indexed) userId = indexed.userId else userId = decodeUserHandle(response.response?.userHandle) if (!userId) { throw createError({ statusCode: 400, statusMessage: 'Credential not registered' }) } // 3. Load credentials for this user (uses the iDempiere service token) const stored = await readCredentials(event, userId).catch(() => ({ keys: [], password: null })) const matching = stored.keys.find(k => k.credentialId === response.id) if (!matching) { throw createError({ statusCode: 400, statusMessage: 'Credential not registered' }) } if (!stored.password) { throw createError({ statusCode: 400, statusMessage: 'No stored credentials — please re-register your security key' }) } // 4. Verify the assertion const verification = await verifyAuthentication({ response, expectedChallenge: challenge, credential: { id: matching.credentialId, publicKey: new Uint8Array(Buffer.from(matching.publicKey, 'base64')), counter: matching.counter, transports: matching.transports as any, }, }).catch((e: any) => { throw createError({ statusCode: 400, statusMessage: 'Verification failed: ' + (e?.message ?? 'unknown error') }) }) if (!verification.verified) { throw createError({ statusCode: 400, statusMessage: 'Verification failed' }) } // 5. Update counter + last-used (best effort — never block the login on this write) matching.counter = verification.authenticationInfo.newCounter matching.lastUsedAt = Date.now() await writeCredentials(event, userId, stored).catch(() => null) // 6. Decrypt the iDempiere password let password: string try { password = decryptPassword(stored.password) } catch (e: any) { throw createError({ statusCode: 500, statusMessage: 'Could not decrypt stored credentials: ' + (e?.message ?? 'unknown') }) } // 7. Resolve username (the login id iDempiere expects) const userName = indexed?.userName || await fetchUserName(event, userId) if (!userName) { throw createError({ statusCode: 500, statusMessage: 'Could not resolve username for user ' + userId }) } // 8. Delegate to the EXISTING login pipeline. login.post.ts is left untouched — it does the // auth call, sets all cookies, runs auto-finalize. The native app (X-Logship-App: 1) goes // through app-auth/login, which surfaces refresh_token in the body (no cookies cross-origin). const isPwa = !!body?.pwa const isMobileWorker = !!body?.mobileWorker || getCookie(event, 'logship_mw') === '1' const isApp = getHeader(event, 'x-logship-app') === '1' const loginBody = { userName, password, remember: false, selectRole: false, mobileWorker: isMobileWorker, pwa: isPwa, } if (isApp) { const data: any = await $fetch('/api/app-auth/login', { method: 'POST', headers: { cookie: getHeader(event, 'cookie') ?? '' }, body: loginBody, }).catch((e: any) => { throw createError({ statusCode: e?.statusCode ?? e?.status ?? 500, statusMessage: e?.statusMessage ?? e?.message ?? 'Login failed', }) }) return data } const loginRes = await $fetch.raw('/api/idempiere-auth/login', { method: 'POST', headers: { // forward existing cookies so client_id etc. may be available cookie: getHeader(event, 'cookie') ?? '', }, body: loginBody, }).catch((e: any) => { throw createError({ statusCode: e?.statusCode ?? e?.status ?? 500, statusMessage: e?.statusMessage ?? e?.message ?? 'Login failed', }) }) // Forward Set-Cookie headers from the internal login response to our caller. // Without this, the browser never sees logship_it / logship_user / etc. const setCookies: string[] = (loginRes.headers as any).getSetCookie?.() ?? (loginRes.headers as any).raw?.()['set-cookie'] ?? [] for (const c of setCookies) { appendResponseHeader(event, 'set-cookie', c) } return loginRes._data })