diff --git a/SPEC.md b/SPEC.md index b8588fda0..a9496cba1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -447,7 +447,9 @@ Starts a discoverable-credential registration. Empty body mints a new account id (no row until finish). Optional JSON `{ "viewKey": "<64 lowercase hex>" }` claims an existing provisioned account: `404` when the profile is missing, `409` when it already has a passkey, `400` when `viewKey` is present but not a -string. +string. Non-empty invalid JSON is `400` +`{ "error": "Begin body is not valid JSON" }` and does not open a challenge. +Empty or whitespace-only body still starts a new registration. When `WEBAUTHN_RP_ID` is unset, blank, not on the allowlist (`21.gifts` / `dev.21.gifts` / `localhost`), or no CORS origin matches that RP ID: @@ -491,7 +493,8 @@ ID). | Status | Body | When | | ------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | 500 | `{ "error": "Server auth is not configured" }` | RP ID missing, not on the allowlist, or no matching origin | -| 400 | `{ "error": "Expected a JSON body with challengeId and credential" }` | Body parse fail | +| 400 | `{ "error": "Finish body is not valid JSON" }` | Body is not JSON | +| 400 | `{ "error": "Expected a JSON body with challengeId and credential" }` | Missing body, or JSON that is not `{ challengeId, credential }` | | 400 | `{ "error": "Unknown or expired challenge" }` | Unknown `challengeId` | | 400 | `{ "error": "Challenge expired" }` | Past challenge TTL | | 400 | `{ "error": "Challenge already used" }` | Finish already attempted; challenge is consumed before verification | @@ -620,15 +623,17 @@ session resolution, before finish runs. Other ceremony failures stay **400** with the same strings as the old replace finish: Invalid origin, Unknown or expired challenge, Challenge expired, Challenge already used, Wrong challenge type, Invalid passkey, -and `{ "error": "Expected a JSON body with challengeId and credential" }`. +`{ "error": "Finish body is not valid JSON" }`, and +`{ "error": "Expected a JSON body with challengeId and credential" }`. A 400 or 409 after the session is known stores a failed renew row and does not change the account. Success stores `outcome: "succeeded"` with null error fields, then acknowledges open failed rows. 401 and 500 store no row. -**Response** `200`: `{ "account": { ... } }` — owner JSON via -`serializeOwnerAccountWithPosts`, no `token`. `passkeyCredentialId` is the -new credential id, `walletRequired` is true, `passkeyRenewClosed` is false, +**Response** `200`: the owner account itself, via +`serializeOwnerAccountWithPosts`, same shape as `GET /me`. No `token` and +no `account` wrapper. `passkeyCredentialId` is the new credential id, +`walletRequired` is true, `passkeyRenewClosed` is false, `walletBackupSeenAt` is unchanged. Logs `auth.passkey.seed.ok` only on success. Missing or invalid Bearer stays **401** `{ "error": "Unauthorized" }`. diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index fe2532db8..c9fec9849 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -261,56 +261,56 @@ ## Endpoint: POST /auth/passkey/authenticate/begin - **Purpose:** Issues WebAuthn request options for a discoverable credential. JSON: challengeId, options (`extensions.prf.eval.first` = base64url SHA-256 of `21gifts-nostr-v1`). -- **Errors:** HTTP 500 `{ error: 'Server auth is not configured' }` if `WEBAUTHN_RP_ID` is unset, blank, not on the allowlist, or no CORS origin matches it. +- **Errors:** HTTP 500 `{ error: 'Server auth is not configured' }` if `WEBAUTHN_RP_ID` is unset, blank, not on the allowlist, or no CORS origin matches it. That 500 is logged as `auth.passkey.login.fail` with the same error and no account id. - **Used by:** App passkey sign-in. - **Auth:** Public. ## Endpoint: POST /auth/passkey/authenticate/finish - **Purpose:** Verifies the assertion and issues `{ token, account }` immediately. Requires `Origin`. `{ token, account }` uses owner JSON including `hasPosted`, `aboutMe`, `aboutMeHasPhoto`, `notificationLevel`, `amountUnit`, `funding`, `walletRequired`, `walletBackupSeenAt`, and `passkeyCredentialId`. An account with `sessionRefused` is refused with no bearer. -- **Errors:** 400 invalid body/origin/challenge/credential; 403 `{ error: 'You signed in with the wrong account. Please try again with the correct account.' }` when `sessionRefused` is true; 500 if WebAuthn is unconfigured. +- **Errors:** 400 invalid body/origin/challenge/credential; 403 `{ error: 'You signed in with the wrong account. Please try again with the correct account.' }` when `sessionRefused` is true; 500 if WebAuthn is unconfigured. A missing body is 400 `Expected a JSON body with challengeId and credential`, logged as `auth.passkey.login.fail` with `json` `absent` and `bodyBytes`. Invalid JSON is 400 `Finish body is not valid JSON`, logged with `json` `invalid` and `bodyBytes`, and the text is not logged. A parsed body without `challengeId` and `credential` is the expected-body 400, logged with `json` `parsed`, `bodyKind`, and for an object `hasCredential` and `challengeIdKind`. The challenge id is logged only when it is 64 lowercase hex. The credential and the raw body are not logged. The 500 is logged as the same event with `Server auth is not configured`. - **Used by:** App passkey sign-in. - **Auth:** Public (proof is the assertion). ## Endpoint: POST /auth/passkey/register/begin - **Purpose:** Issues WebAuthn creation options. JSON: challengeId, options (`extensions.prf: {}`). Empty body / no `viewKey` mints a pending new account id (row created only on finish). Optional body `{ "viewKey": "<64-hex>" }` claims an operator-provisioned account (same id/name/lightningAddress/viewKey). -- **Errors:** HTTP 500 `{ error: 'Server auth is not configured' }` if `WEBAUTHN_RP_ID` is unset, blank, not on the allowlist, or no CORS origin matches it; 400 `{ error: 'Expected a JSON body with an optional "viewKey" string' }` when `viewKey` is present but not a string; 404 `{ error: 'This profile could not be found.' }` for a malformed/unknown view key; 409 `{ error: 'This profile already has a passkey' }` when the provisioned account already has a credential. +- **Errors:** HTTP 500 `{ error: 'Server auth is not configured' }` if `WEBAUTHN_RP_ID` is unset, blank, not on the allowlist, or no CORS origin matches it; 400 `{ error: 'Expected a JSON body with an optional "viewKey" string' }` when `viewKey` is present but not a string; 404 `{ error: 'This profile could not be found.' }` for a malformed/unknown view key; 409 `{ error: 'This profile already has a passkey' }` when the provisioned account already has a credential. Invalid JSON is 400 `{ error: 'Begin body is not valid JSON' }`, logs `auth.passkey.register.fail` with `json` `invalid` and `bodyBytes`, and does not open a challenge. Those failures log `auth.passkey.register.fail` with the error text and not the view key. - **Used by:** App passkey account creation and claim-by-viewKey. - **Auth:** Public. ## Endpoint: POST /auth/passkey/register/finish - **Purpose:** Verifies the attestation, creates a `linkingKey: null` account (or binds a passkey to a provisioned account without recreating it), issues `{ token, account }`. Requires `Origin`. `{ token, account }` uses owner JSON including `hasPosted`, `aboutMe`, `aboutMeHasPhoto`, `notificationLevel`, `amountUnit`, `funding`, `walletRequired`, `walletBackupSeenAt`, and `passkeyCredentialId`. An account with `sessionRefused` is refused with no bearer. -- **Errors:** 400 invalid body/origin/challenge/passkey; 403 `{ error: 'You signed in with the wrong account. Please try again with the correct account.' }` when `sessionRefused` is true; 500 if WebAuthn is unconfigured. +- **Errors:** 400 invalid body/origin/challenge/passkey; 403 `{ error: 'You signed in with the wrong account. Please try again with the correct account.' }` when `sessionRefused` is true; 500 if WebAuthn is unconfigured. A missing body is 400 `Expected a JSON body with challengeId and credential`, logged as `auth.passkey.register.fail` with `json` `absent` and `bodyBytes`. Invalid JSON is 400 `Finish body is not valid JSON`, logged with `json` `invalid` and `bodyBytes`, and the text is not logged. A parsed body without `challengeId` and `credential` is the expected-body 400, logged with `json` `parsed`, `bodyKind`, and for an object `hasCredential` and `challengeIdKind`. The challenge id is logged only when it is 64 lowercase hex. The credential and the raw body are not logged. The 500 is logged as the same event with `Server auth is not configured`. - **Used by:** App passkey account creation and claim-by-viewKey. - **Auth:** Public (proof is the attestation). ## Endpoint: POST /auth/passkey/replace/begin - **Purpose:** Bearer session. Refuses to replace a passkey. A recovery phrase is never replaced. Does not create a challenge and does not delete or insert a credential. `walletBackupSeenAt` is not consulted. -- **Errors:** 401 `{ error: 'Unauthorized' }` missing or invalid Bearer; 409 `{ error: 'A recovery phrase cannot be replaced' }` after a valid session; 500 `{ error: 'Server auth is not configured' }` when WebAuthn is unconfigured (checked before the bearer). +- **Errors:** 401 `{ error: 'Unauthorized' }` missing or invalid Bearer; 409 `{ error: 'A recovery phrase cannot be replaced' }` after a valid session; 500 `{ error: 'Server auth is not configured' }` when WebAuthn is unconfigured (checked before the bearer). That 500 is logged as `auth.passkey.replace.refused` with the same error and no account id. - **Used by:** App passkey replace, which the api now refuses. - **Auth:** `Authorization: Bearer` session. ## Endpoint: POST /auth/passkey/replace/finish - **Purpose:** Bearer session. Same refusal as begin. Does not parse a ceremony once the session is valid. Does not delete or insert a passkey. Does not mint a session. `walletBackupSeenAt` does not decide whether a seed exists. -- **Errors:** 401 without a session; 409 `{ error: 'A recovery phrase cannot be replaced' }` after a valid session; 500 if WebAuthn is unconfigured. +- **Errors:** 401 without a session; 409 `{ error: 'A recovery phrase cannot be replaced' }` after a valid session; 500 if WebAuthn is unconfigured. That 500 is logged as `auth.passkey.replace.refused` with `Server auth is not configured` and no account id. - **Used by:** App passkey replace, which the api now refuses. - **Auth:** `Authorization: Bearer` session. ## Endpoint: POST /auth/passkey/seed/begin -- **Purpose:** Bearer session. When `walletRequired` is not true, issues WebAuthn creation options for one extra seed passkey (`{ challengeId, options }`, no `excludeCredentials`, user id and name are the account id). Does not delete the login passkey. `walletRequired: true` means a seed passkey already exists. A 409 because a seed already exists stores a failed renew row (`stage` `begin`, HTTP 409) and does not change the account. A 200 stores no renew row. 401 and 500 store no row. +- **Purpose:** Bearer session. When `walletRequired` is not true, issues WebAuthn creation options for one extra seed passkey (`{ challengeId, options }`, no `excludeCredentials`, user id and name are the account id). Does not delete the login passkey. `walletRequired: true` means a seed passkey already exists. A 409 because a seed already exists stores a failed renew row (`stage` `begin`, HTTP 409), logs `auth.passkey.seed.fail` with the account id and error, and does not change the account. A 200 stores no renew row. 401 stores no row and no diagnostic row. 500 stores no renew row and logs `auth.passkey.seed.fail` with `Server auth is not configured`. - **Errors:** 401 missing or invalid Bearer; 409 `{ error: 'This account already has a recovery phrase' }` when `walletRequired` is true (no challenge); 500 if WebAuthn is unconfigured. - **Used by:** App add-recovery-phrase for an account that has no seed yet. - **Auth:** `Authorization: Bearer` session. ## Endpoint: POST /auth/passkey/seed/finish -- **Purpose:** Bearer session. Verifies a `seed` attestation and inserts an additional passkey, setting `walletRequired` true. Does not delete the login passkey, does not change `walletBackupSeenAt`, and does not mint a session. Success JSON is `{ account }` owner JSON. `passkeyCredentialId` is the new credential id. `walletBackupSeenAt` does not decide whether a seed exists. A 400 or 409 after the session is known stores a failed renew row and does not change the account. Success stores `outcome` `succeeded` with null error fields, then acknowledges failed rows that are still unacknowledged, so the owner JSON has `walletRequired` true and `passkeyRenewClosed` false. 401 and 500 store no row. -- **Errors:** 401 without a session, including a `sessionRefused` bearer (resolved before finish, not 409); 409 `{ error: 'This account already has a recovery phrase' }` when `walletRequired` is already true, the credential id is taken, the account is missing, or the insert does not land; 400 invalid body, origin, challenge, or attestation; 500 if WebAuthn is unconfigured. +- **Purpose:** Bearer session. Verifies a `seed` attestation and inserts an additional passkey, setting `walletRequired` true. Does not delete the login passkey, does not change `walletBackupSeenAt`, and does not mint a session. Success JSON is the owner account itself, same shape as `GET /me`, with no `token` and no `account` wrapper. `passkeyCredentialId` is the new credential id. `walletBackupSeenAt` does not decide whether a seed exists. A 400 or 409 after the session is known stores a failed renew row and does not change the account. Success stores `outcome` `succeeded` with null error fields, then acknowledges failed rows that are still unacknowledged, so the owner JSON has `walletRequired` true and `passkeyRenewClosed` false. 401 stores no renew row and no diagnostic row. 500 stores no renew row and logs `auth.passkey.seed.fail` with `Server auth is not configured`. A missing finish body is 400 `Expected a JSON body with challengeId and credential`, logged as `auth.passkey.seed.fail` with the account id, `json` `absent`, and `bodyBytes`. Invalid JSON is 400 `Finish body is not valid JSON`, logged with the account id, `json` `invalid`, and `bodyBytes`; the text is not logged. A parsed body without `challengeId` and `credential` is the expected-body 400, logged with the account id, `json` `parsed`, `bodyKind`, and for an object `hasCredential` and `challengeIdKind`. The challenge id is logged only when it is 64 lowercase hex. Each of those 400s still stores the failed renew row. The credential is not logged. A later ceremony 400 or 409 also logs `auth.passkey.seed.fail` with the account id. +- **Errors:** 401 without a session, including a `sessionRefused` bearer (resolved before finish, not 409); 409 `{ error: 'This account already has a recovery phrase' }` when `walletRequired` is already true, the credential id is taken, the account is missing, or the insert does not land; 400 invalid body, origin, challenge, or attestation; 500 if WebAuthn is unconfigured. Invalid JSON (`Finish body is not valid JSON`) and a body without `challengeId` and `credential` log `auth.passkey.seed.fail` with the account id as well as the renew row. The text, credential, and bearer are not logged. The 500 logs that fail event and stores no renew row. - **Used by:** App add-recovery-phrase finish. - **Auth:** `Authorization: Bearer` session. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 79db26528..29d62dd71 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -927,7 +927,7 @@ ## Function: authRoutes -- **Purpose:** Hono sub-app for passkey register, authenticate, seed add, and replace refusal. Register begin accepts an optional `{ viewKey }` to claim a provisioned account; empty begin still mints a pending new account. Replace begin/finish, after a valid Bearer, return 409 and do not create a challenge or delete a credential. Seed begin/finish add one extra passkey, set `walletRequired` true, and keep the login passkey and the existing session. Begin 409 stores a failed renew row and does not change the account. Begin 200, 401, and 500 store no row. Finish 400 or 409 after the session is known stores a failed renew row and does not change the account. Finish 401 and 500 store no row. Finish success stores `succeeded`, then acknowledges open failed rows, so owner JSON has `walletRequired` true and `passkeyRenewClosed` false. Passes optional `nostrKek` / `nostrKeygen` into register/authenticate finish so new logins get a custodial nsec. +- **Purpose:** Hono sub-app for passkey register, authenticate, seed add, and replace refusal. Register begin accepts an optional `{ viewKey }` to claim a provisioned account; empty begin still mints a pending new account. Replace begin/finish, after a valid Bearer, return 409 and do not create a challenge or delete a credential. Seed begin/finish add one extra passkey, set `walletRequired` true, and keep the login passkey and the existing session. Begin 409 stores a failed renew row, logs `auth.passkey.seed.fail` with the account id and error, and does not change the account. Begin 200 and 401 store no row. Begin 500 stores no renew row and logs `auth.passkey.seed.fail`. Finish 400 or 409 after the session is known stores a failed renew row, logs `auth.passkey.seed.fail` with the account id, and does not change the account. Finish 401 stores no renew row and no diagnostic row. Finish 500 stores no renew row and logs `auth.passkey.seed.fail`. Finish success stores `succeeded`, then acknowledges open failed rows, so owner JSON has `walletRequired` true and `passkeyRenewClosed` false. Passes optional `nostrKek` / `nostrKeygen` into register/authenticate finish so new logins get a custodial nsec. A missing finish body logs that same error with `json` `absent` and `bodyBytes`. Invalid JSON logs `Finish body is not valid JSON` with `json` `invalid` and `bodyBytes`, and does not log the text. A parsed body that is not `{ challengeId, credential }` logs the expected-body error plus `json` `parsed`, `bodyKind`, and, for an object, `hasCredential` and `challengeIdKind`. The challenge id is included only when it is 64 lowercase hex. The credential, token, view key, and raw body are never logged. Seed also keeps the failed renew row and adds the account id. Register begin with invalid JSON is 400 `Begin body is not valid JSON`, logs `auth.passkey.register.fail` with `json` `invalid` and `bodyBytes`, and does not open a challenge. Unconfigured WebAuthn logs the same fail event, or `auth.passkey.replace.refused` on replace, with `Server auth is not configured` and no account id. A register begin whose `viewKey` is not a string, and a failed claim, log `auth.passkey.register.fail` with the error and not the view key. - **Inputs:** `AuthRouteDeps`: store, `messages`, now, allowedOrigins, webAuthnRpId, webAuthnRpName, passkeyCeremony, optional `nostrKek` and `nostrKeygen`, optional `fundingStore` (default empty `InMemoryFundingStore`; owner JSON `funding` on finish). - **Returns / side effects:** Hono app mounted at `/auth`. Begin with viewKey maps claim errors to 404/409; unwraps `{ challengeId, options }` on success. - **Used by:** `createApp`. diff --git a/src/__tests__/routes/auth.test.ts b/src/__tests__/routes/auth.test.ts index 0ab132ecd..92ac07ead 100644 --- a/src/__tests__/routes/auth.test.ts +++ b/src/__tests__/routes/auth.test.ts @@ -178,6 +178,12 @@ describe('auth routes', () => { }); expect(res.status).toBe(409); expect(await res.json()).toEqual({ error: 'This profile already has a passkey' }); + const logged = parsedEvents(warn).find((e) => e['event'] === 'auth.passkey.register.fail'); + expect(logged).toMatchObject({ + event: 'auth.passkey.register.fail', + error: 'This profile already has a passkey', + }); + expect(JSON.stringify(logged)).not.toContain(viewKey); }); it('returns 400 when begin viewKey is not a string', async () => { @@ -190,6 +196,12 @@ describe('auth routes', () => { expect(await res.json()).toEqual({ error: 'Expected a JSON body with an optional "viewKey" string', }); + const logged = parsedEvents(warn).find((e) => e['event'] === 'auth.passkey.register.fail'); + expect(logged).toEqual({ + ts: expect.any(String), + event: 'auth.passkey.register.fail', + error: 'Expected a JSON body with an optional "viewKey" string', + }); }); it('rejects a missing finish body', async () => { @@ -201,6 +213,37 @@ describe('auth routes', () => { expect(await res.json()).toEqual({ error: 'Expected a JSON body with challengeId and credential', }); + const logged = parsedEvents(warn).find((e) => e['event'] === 'auth.passkey.register.fail'); + expect(logged).toMatchObject({ + event: 'auth.passkey.register.fail', + error: 'Expected a JSON body with challengeId and credential', + json: 'absent', + bodyBytes: 0, + }); + expect(logged).not.toHaveProperty('challengeId'); + expect(logged).not.toHaveProperty('accountId'); + }); + + it('rejects invalid JSON on register begin and does not open a challenge', async () => { + const store = new InMemoryAuthStore(); + const raw = '{"viewKey":"secret-view"'; + const res = await mount(store).request('/auth/passkey/register/begin', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: raw, + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Begin body is not valid JSON' }); + expect(await store.listPasskeyChallenges()).toEqual([]); + const logged = parsedEvents(warn).find((e) => e['event'] === 'auth.passkey.register.fail'); + expect(logged).toMatchObject({ + event: 'auth.passkey.register.fail', + error: 'Begin body is not valid JSON', + json: 'invalid', + bodyBytes: raw.length, + }); + expect(logged).not.toHaveProperty('viewKey'); + expect(JSON.stringify(logged)).not.toContain('secret-view'); }); it('does not log a registration challenge id that is not 64 lowercase hex', async () => { @@ -372,6 +415,12 @@ describe('auth routes', () => { { method: 'POST' }, ); expect(res.status).toBe(500); + const logged = parsedEvents(warn).find((e) => e['event'] === 'auth.passkey.login.fail'); + expect(logged).toMatchObject({ + event: 'auth.passkey.login.fail', + error: 'Server auth is not configured', + }); + expect(logged).not.toHaveProperty('accountId'); }); it('returns 500 on finish when unconfigured', async () => { @@ -380,6 +429,11 @@ describe('auth routes', () => { { method: 'POST' }, ); expect(res.status).toBe(500); + const logged = parsedEvents(warn).find((e) => e['event'] === 'auth.passkey.login.fail'); + expect(logged).toMatchObject({ + event: 'auth.passkey.login.fail', + error: 'Server auth is not configured', + }); }); it('rejects a missing finish body', async () => { @@ -391,6 +445,200 @@ describe('auth routes', () => { expect(await res.json()).toEqual({ error: 'Expected a JSON body with challengeId and credential', }); + const logged = parsedEvents(warn).find((e) => e['event'] === 'auth.passkey.login.fail'); + expect(logged).toMatchObject({ + event: 'auth.passkey.login.fail', + error: 'Expected a JSON body with challengeId and credential', + json: 'absent', + bodyBytes: 0, + }); + expect(logged).not.toHaveProperty('challengeId'); + expect(logged).not.toHaveProperty('accountId'); + }); + + it('logs invalid JSON on login finish and keeps no credential', async () => { + const raw = '{"credential":"cred-secret"'; + const res = await mount(new InMemoryAuthStore()).request( + '/auth/passkey/authenticate/finish', + { + method: 'POST', + headers: { origin: ORIGIN, 'content-type': 'application/json' }, + body: raw, + }, + ); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Finish body is not valid JSON' }); + const logged = parsedEvents(warn).find((e) => e['event'] === 'auth.passkey.login.fail'); + expect(logged).toMatchObject({ + event: 'auth.passkey.login.fail', + error: 'Finish body is not valid JSON', + json: 'invalid', + bodyBytes: raw.length, + }); + expect(logged).not.toHaveProperty('challengeId'); + expect(logged).not.toHaveProperty('hasCredential'); + expect(JSON.stringify(logged)).not.toContain('cred-secret'); + }); + + it('logs a challenge id when the login finish body has no credential', async () => { + const challengeId = 'ab'.repeat(32); + const payload = JSON.stringify({ challengeId, leftover: 'cred-secret' }); + const res = await mount(new InMemoryAuthStore()).request( + '/auth/passkey/authenticate/finish', + { + method: 'POST', + headers: { origin: ORIGIN, 'content-type': 'application/json' }, + body: payload, + }, + ); + expect(res.status).toBe(400); + const logged = parsedEvents(warn).find((e) => e['event'] === 'auth.passkey.login.fail'); + expect(logged).toMatchObject({ + event: 'auth.passkey.login.fail', + error: 'Expected a JSON body with challengeId and credential', + challengeId, + json: 'parsed', + bodyKind: 'object', + hasCredential: false, + challengeIdKind: 'string', + bodyBytes: payload.length, + }); + expect(JSON.stringify(logged)).not.toContain('cred-secret'); + expect(logged).not.toHaveProperty('accountId'); + }); + + it('drops a non-hex challenge id and never logs the credential', async () => { + const nonHex = await mount(new InMemoryAuthStore()).request( + '/auth/passkey/authenticate/finish', + { + method: 'POST', + headers: { origin: ORIGIN, 'content-type': 'application/json' }, + body: JSON.stringify({ challengeId: 'nope' }), + }, + ); + expect(nonHex.status).toBe(400); + const nonHexLog = parsedEvents(warn).find((e) => e['event'] === 'auth.passkey.login.fail'); + expect(nonHexLog).toMatchObject({ + event: 'auth.passkey.login.fail', + error: 'Expected a JSON body with challengeId and credential', + json: 'parsed', + bodyKind: 'object', + hasCredential: false, + challengeIdKind: 'string', + }); + expect(nonHexLog).not.toHaveProperty('challengeId'); + expect(JSON.stringify(nonHexLog)).not.toContain('nope'); + warn.mockClear(); + const credentialOnly = await mount(new InMemoryAuthStore()).request( + '/auth/passkey/authenticate/finish', + { + method: 'POST', + headers: { origin: ORIGIN, 'content-type': 'application/json' }, + body: JSON.stringify({ credential: { id: 'cred-secret', signature: 'sig-secret' } }), + }, + ); + expect(credentialOnly.status).toBe(400); + const logged = parsedEvents(warn).find((e) => e['event'] === 'auth.passkey.login.fail'); + expect(logged).toMatchObject({ + event: 'auth.passkey.login.fail', + error: 'Expected a JSON body with challengeId and credential', + json: 'parsed', + bodyKind: 'object', + hasCredential: true, + challengeIdKind: 'absent', + }); + expect(logged).not.toHaveProperty('challengeId'); + expect(JSON.stringify(logged)).not.toContain('cred-secret'); + expect(JSON.stringify(logged)).not.toContain('sig-secret'); + }); + + it('drops a numeric challenge id and an array body', async () => { + const numeric = await mount(new InMemoryAuthStore()).request( + '/auth/passkey/authenticate/finish', + { + method: 'POST', + headers: { origin: ORIGIN, 'content-type': 'application/json' }, + body: JSON.stringify({ challengeId: 4 }), + }, + ); + expect(numeric.status).toBe(400); + const numericLog = parsedEvents(warn).find((e) => e['event'] === 'auth.passkey.login.fail'); + expect(numericLog).toMatchObject({ + json: 'parsed', + bodyKind: 'object', + hasCredential: false, + challengeIdKind: 'number', + }); + expect(numericLog).not.toHaveProperty('challengeId'); + expect(Object.values(numericLog ?? {})).not.toContain(4); + warn.mockClear(); + const arrayBody = await mount(new InMemoryAuthStore()).request( + '/auth/passkey/authenticate/finish', + { + method: 'POST', + headers: { origin: ORIGIN, 'content-type': 'application/json' }, + body: '[]', + }, + ); + expect(arrayBody.status).toBe(400); + const arrayLog = parsedEvents(warn).find((e) => e['event'] === 'auth.passkey.login.fail'); + expect(arrayLog).toMatchObject({ + event: 'auth.passkey.login.fail', + error: 'Expected a JSON body with challengeId and credential', + json: 'parsed', + bodyKind: 'array', + bodyBytes: 2, + }); + expect(arrayLog).not.toHaveProperty('challengeId'); + expect(arrayLog).not.toHaveProperty('hasCredential'); + }); + + it('logs a parsed login finish body by kind and never its values', async () => { + const cases: Array<{ body: string; fields: Record; secret?: string }> = [ + { body: 'null', fields: { bodyKind: 'null', bodyBytes: 4 } }, + { + body: '"cred-secret"', + fields: { bodyKind: 'string', bodyBytes: '"cred-secret"'.length }, + secret: 'cred-secret', + }, + { + body: JSON.stringify({ challengeId: { id: 'cred-secret' } }), + fields: { bodyKind: 'object', hasCredential: false, challengeIdKind: 'object' }, + secret: 'cred-secret', + }, + { + body: JSON.stringify({ challengeId: ['cred-secret'] }), + fields: { bodyKind: 'object', hasCredential: false, challengeIdKind: 'array' }, + secret: 'cred-secret', + }, + { + body: JSON.stringify({ challengeId: null }), + fields: { bodyKind: 'object', hasCredential: false, challengeIdKind: 'null' }, + }, + ]; + for (const item of cases) { + warn.mockClear(); + const res = await mount(new InMemoryAuthStore()).request( + '/auth/passkey/authenticate/finish', + { + method: 'POST', + headers: { origin: ORIGIN, 'content-type': 'application/json' }, + body: item.body, + }, + ); + expect(res.status).toBe(400); + const logged = parsedEvents(warn).find((e) => e['event'] === 'auth.passkey.login.fail'); + expect(logged).toMatchObject({ + event: 'auth.passkey.login.fail', + error: 'Expected a JSON body with challengeId and credential', + json: 'parsed', + ...item.fields, + }); + expect(logged).not.toHaveProperty('challengeId'); + if (item.secret !== undefined) { + expect(JSON.stringify(logged)).not.toContain(item.secret); + } + } }); it('authenticates a registered credential', async () => { @@ -854,18 +1102,18 @@ describe('auth routes', () => { expect(finish.status).toBe(200); const finishBody = (await finish.json()) as { token?: unknown; - account: { - id: string; - walletRequired: boolean; - walletBackupSeenAt: number | null; - passkeyCredentialId: string | null; - }; + account?: unknown; + id: string; + walletRequired: boolean; + walletBackupSeenAt: number | null; + passkeyCredentialId: string | null; }; expect(finishBody).not.toHaveProperty('token'); - expect(finishBody.account.id).toBe(accountId); - expect(finishBody.account.walletRequired).toBe(true); - expect(finishBody.account.walletBackupSeenAt).toBe(9); - expect(finishBody.account.passkeyCredentialId).toBe('cred-2'); + expect(finishBody).not.toHaveProperty('account'); + expect(finishBody.id).toBe(accountId); + expect(finishBody.walletRequired).toBe(true); + expect(finishBody.walletBackupSeenAt).toBe(9); + expect(finishBody.passkeyCredentialId).toBe('cred-2'); expect((await store.getPasskeyCredential('cred-1'))?.accountId).toBe(accountId); expect((await store.getPasskeyCredential('cred-2'))?.accountId).toBe(accountId); expect((await store.getPasskeyCredentialForAccount(accountId))?.credentialId).toBe('cred-2'); @@ -933,7 +1181,7 @@ describe('auth routes', () => { it('returns 409 on seed begin for an account that already has a seed', async () => { const store = new InMemoryAuthStore(); - const { app, token } = await register(store); + const { app, token, accountId } = await register(store); const before = await store.listPasskeyChallenges(); const res = await app.request('/auth/passkey/seed/begin', { method: 'POST', @@ -944,6 +1192,14 @@ describe('auth routes', () => { error: 'This account already has a recovery phrase', }); expect(await store.listPasskeyChallenges()).toEqual(before); + expect( + parsedEvents(warn).filter( + (e) => + e['event'] === 'auth.passkey.seed.fail' && + e['error'] === 'This account already has a recovery phrase' && + e['accountId'] === accountId, + ), + ).toHaveLength(1); }); it('returns 409 on seed finish without parsing a body when walletRequired is true', async () => { @@ -969,7 +1225,7 @@ describe('auth routes', () => { it('returns 400 when seed finish rejects the attestation', async () => { const store = new InMemoryAuthStore(); - const { app, token } = await legacySignedIn(store); + const { app, token, accountId } = await legacySignedIn(store); const begin = (await ( await app.request('/auth/passkey/seed/begin', { method: 'POST', @@ -991,11 +1247,20 @@ describe('auth routes', () => { expect(res.status).toBe(400); expect(await res.json()).toEqual({ error: 'Invalid passkey' }); expect(await store.getPasskeyCredential('cred-2')).toBeUndefined(); + expect( + parsedEvents(warn).some( + (e) => + e['event'] === 'auth.passkey.seed.fail' && + e['error'] === 'Invalid passkey' && + e['accountId'] === accountId && + e['challengeId'] === begin.challengeId, + ), + ).toBe(true); }); it('does not log a seed challenge id that is not 64 lowercase hex', async () => { const store = new InMemoryAuthStore(); - const { app, token } = await legacySignedIn(store); + const { app, token, accountId } = await legacySignedIn(store); const res = await app.request('/auth/passkey/seed/finish', { method: 'POST', headers: { @@ -1010,8 +1275,10 @@ describe('auth routes', () => { expect(logged).toMatchObject({ event: 'auth.passkey.seed.fail', error: 'Unknown or expired challenge', + accountId, }); expect(logged).not.toHaveProperty('challengeId'); + expect(JSON.stringify(logged)).not.toContain(token); }); it('returns 409 when finish finds the credential id already stored', async () => { @@ -1044,7 +1311,7 @@ describe('auth routes', () => { it('rejects a missing finish body on a legacy account', async () => { const store = new InMemoryAuthStore(); - const { app, token } = await legacySignedIn(store); + const { app, token, accountId } = await legacySignedIn(store); const res = await app.request('/auth/passkey/seed/finish', { method: 'POST', headers: { origin: ORIGIN, authorization: `Bearer ${token}` }, @@ -1053,6 +1320,16 @@ describe('auth routes', () => { expect(await res.json()).toEqual({ error: 'Expected a JSON body with challengeId and credential', }); + const logged = parsedEvents(warn).find((e) => e['event'] === 'auth.passkey.seed.fail'); + expect(logged).toMatchObject({ + event: 'auth.passkey.seed.fail', + error: 'Expected a JSON body with challengeId and credential', + json: 'absent', + bodyBytes: 0, + accountId, + }); + expect(logged).not.toHaveProperty('challengeId'); + expect(JSON.stringify(logged)).not.toContain(token); }); class RecordingAuthStore extends InMemoryAuthStore { @@ -1063,6 +1340,45 @@ describe('auth routes', () => { } } + it('logs invalid JSON on seed finish and stores the failed renew row', async () => { + const store = new RecordingAuthStore(); + const { app, token, accountId } = await legacySignedIn(store); + warn.mockClear(); + const raw = '{"credential":"cred-secret"'; + const res = await app.request('/auth/passkey/seed/finish', { + method: 'POST', + headers: { + origin: ORIGIN, + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + 'user-agent': 'SeedAgent', + }, + body: raw, + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Finish body is not valid JSON' }); + const logged = parsedEvents(warn).find((e) => e['event'] === 'auth.passkey.seed.fail'); + expect(logged).toMatchObject({ + event: 'auth.passkey.seed.fail', + error: 'Finish body is not valid JSON', + json: 'invalid', + bodyBytes: raw.length, + accountId, + }); + expect(logged).not.toHaveProperty('challengeId'); + expect(JSON.stringify(logged)).not.toContain('cred-secret'); + expect(JSON.stringify(logged)).not.toContain(token); + expect(store.inserts).toHaveLength(1); + expect(store.inserts[0]).toMatchObject({ + accountId, + stage: 'finish', + outcome: 'failed', + httpStatus: 400, + message: 'Finish body is not valid JSON', + userAgent: 'SeedAgent', + }); + }); + it('inserts a failed begin row on seed begin 409 without changing walletRequired', async () => { const store = new RecordingAuthStore(); const { app, token, accountId } = await register(store); @@ -1183,15 +1499,15 @@ describe('auth routes', () => { }); expect(finish.status).toBe(200); const finishBody = (await finish.json()) as { - account: { - passkeyRenewFailed: boolean; - passkeyRenewClosed: boolean; - walletRequired: boolean; - }; + account?: unknown; + passkeyRenewFailed: boolean; + passkeyRenewClosed: boolean; + walletRequired: boolean; }; - expect(finishBody.account.passkeyRenewFailed).toBe(false); - expect(finishBody.account.passkeyRenewClosed).toBe(false); - expect(finishBody.account.walletRequired).toBe(true); + expect(finishBody).not.toHaveProperty('account'); + expect(finishBody.passkeyRenewFailed).toBe(false); + expect(finishBody.passkeyRenewClosed).toBe(false); + expect(finishBody.walletRequired).toBe(true); expect(await store.hasUnacknowledgedPasskeyRenewFailure(accountId)).toBe(false); const succeeded = store.inserts.filter((row) => row.outcome === 'succeeded'); expect(succeeded).toHaveLength(1); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 2163834a1..65febbaf9 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -17,7 +17,7 @@ import { WRONG_ACCOUNT_ERROR } from '@/lib/auth/wrong-account'; import { InMemoryFundingStore, type FundingStore } from '@/lib/funding-store'; import type { AuthStore } from '@/lib/auth/store'; import type { PasskeyCeremony } from '@/lib/auth/webauthn'; -import { logEvent } from '@/lib/log'; +import { logEvent, type LogFields } from '@/lib/log'; import type { MessageStore } from '@/lib/message-store'; import type { NostrKeygen } from '@/lib/nostr/keys'; import { bearerToken } from '@/routes/me'; @@ -71,6 +71,133 @@ function passkeyFailFields( return { error }; } +const PASSKEY_FINISH_BODY_ERROR = 'Expected a JSON body with challengeId and credential'; +const PASSKEY_FINISH_JSON_ERROR = 'Finish body is not valid JSON'; +const PASSKEY_BEGIN_JSON_ERROR = 'Begin body is not valid JSON'; +const SERVER_AUTH_UNCONFIGURED = 'Server auth is not configured'; + +/** + * Challenge id from an untrusted finish body. Missing, non-object, and + * non-string values are empty. The credential is never read. + */ +function challengeIdFromUnknown(body: unknown): string { + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + return ''; + } + const challengeId = (body as { challengeId?: unknown }).challengeId; + return typeof challengeId === 'string' ? challengeId : ''; +} + +/** `null`, `array`, or the JavaScript `typeof` name. No value is copied. */ +function valueKind(value: unknown): string { + if (value === null) { + return 'null'; + } + if (Array.isArray(value)) { + return 'array'; + } + return typeof value; +} + +/** + * Shape of a parsed finish body. Kinds and booleans only. Never the + * credential, the raw text, or a challenge id that is not 64 lowercase hex. + */ +function parsedBodyFields(body: unknown, bodyBytes: number): LogFields { + const fields: { [key: string]: string | number | boolean } = { + bodyBytes, + json: 'parsed', + bodyKind: valueKind(body), + }; + if (body !== null && typeof body === 'object' && !Array.isArray(body)) { + const record = body as { credential?: unknown; challengeId?: unknown }; + fields['hasCredential'] = 'credential' in record; + fields['challengeIdKind'] = 'challengeId' in record ? valueKind(record.challengeId) : 'absent'; + } + return fields; +} + +/** Empty text is absent. Invalid JSON is not parsed. The text is not returned. */ +async function readJsonBody(req: { + text: () => Promise; +}): Promise< + | { json: 'absent'; bodyBytes: number } + | { json: 'invalid'; bodyBytes: number } + | { json: 'parsed'; bodyBytes: number; value: unknown } +> { + const text = await req.text(); + if (text.trim() === '') { + return { json: 'absent', bodyBytes: text.length }; + } + try { + return { json: 'parsed', bodyBytes: text.length, value: JSON.parse(text) as unknown }; + } catch { + return { json: 'invalid', bodyBytes: text.length }; + } +} + +/** + * Finish body, or a 400 reason. Invalid JSON stays distinct from a parsed + * body that is not `{ challengeId, credential }`. + */ +async function readPasskeyFinish(req: { + text: () => Promise; +}): Promise< + | { ok: true; challengeId: string; credential: unknown } + | { ok: false; error: string; body?: unknown; extra: LogFields } +> { + const read = await readJsonBody(req); + if (read.json === 'absent') { + return { + ok: false, + error: PASSKEY_FINISH_BODY_ERROR, + body: null, + extra: { bodyBytes: read.bodyBytes, json: 'absent' }, + }; + } + if (read.json === 'invalid') { + return { + ok: false, + error: PASSKEY_FINISH_JSON_ERROR, + extra: { bodyBytes: read.bodyBytes, json: 'invalid' }, + }; + } + const parsed = passkeyFinishBody.safeParse(read.value); + // z.unknown() accepts a missing key, so a body with only challengeId still parses. + const shape = parsedBodyFields(read.value, read.bodyBytes); + if (!parsed.success || shape['hasCredential'] !== true) { + return { + ok: false, + error: PASSKEY_FINISH_BODY_ERROR, + body: read.value, + extra: shape, + }; + } + return { ok: true, challengeId: parsed.data.challengeId, credential: parsed.data.credential }; +} + +/** + * One diagnostic row for a passkey stop. A challenge id is kept only when + * it is 64 lowercase hex. No credential, token, or raw body. + */ +function logPasskeyStop( + event: + | 'auth.passkey.login.fail' + | 'auth.passkey.register.fail' + | 'auth.passkey.seed.fail' + | 'auth.passkey.replace.refused', + error: string, + body?: unknown, + accountId?: string, + extra?: LogFields, +): void { + logEvent(event, { + ...passkeyFailFields(body === undefined ? '' : challengeIdFromUnknown(body), error), + ...extra, + ...(accountId !== undefined ? { accountId } : {}), + }); +} + /** * Record a seed-path passkey renew row. Unexported so it does not need a * handbook heading. Does not change the account row. @@ -109,13 +236,27 @@ export function authRoutes(deps: AuthRouteDeps): Hono { .post('/passkey/register/begin', async (c) => { const config = webAuthnConfig(deps); if (config === null) { - return c.json({ error: 'Server auth is not configured' }, 500); + logPasskeyStop('auth.passkey.register.fail', SERVER_AUTH_UNCONFIGURED); + return c.json({ error: SERVER_AUTH_UNCONFIGURED }, 500); + } + const read = await readJsonBody(c.req); + if (read.json === 'invalid') { + logPasskeyStop( + 'auth.passkey.register.fail', + PASSKEY_BEGIN_JSON_ERROR, + undefined, + undefined, + { bodyBytes: read.bodyBytes, json: 'invalid' }, + ); + return c.json({ error: PASSKEY_BEGIN_JSON_ERROR }, 400); } - const body = await c.req.json().catch(() => null); + const body = read.json === 'parsed' ? read.value : null; if (body !== null && typeof body === 'object' && !Array.isArray(body) && 'viewKey' in body) { const viewKey = (body as { viewKey: unknown }).viewKey; if (typeof viewKey !== 'string') { - return c.json({ error: 'Expected a JSON body with an optional "viewKey" string' }, 400); + const badViewKey = 'Expected a JSON body with an optional "viewKey" string'; + logPasskeyStop('auth.passkey.register.fail', badViewKey); + return c.json({ error: badViewKey }, 400); } const claimed = await startPasskeyClaim( deps.store, @@ -125,6 +266,7 @@ export function authRoutes(deps: AuthRouteDeps): Hono { viewKey, ); if (!claimed.ok) { + logPasskeyStop('auth.passkey.register.fail', claimed.error); const status = claimed.error === 'This profile already has a passkey' ? 409 : 404; return c.json({ error: claimed.error }, status); } @@ -141,11 +283,13 @@ export function authRoutes(deps: AuthRouteDeps): Hono { .post('/passkey/register/finish', async (c) => { const config = webAuthnConfig(deps); if (config === null) { - return c.json({ error: 'Server auth is not configured' }, 500); + logPasskeyStop('auth.passkey.register.fail', SERVER_AUTH_UNCONFIGURED); + return c.json({ error: SERVER_AUTH_UNCONFIGURED }, 500); } - const parsed = passkeyFinishBody.safeParse(await c.req.json().catch(() => null)); - if (!parsed.success) { - return c.json({ error: 'Expected a JSON body with challengeId and credential' }, 400); + const read = await readPasskeyFinish(c.req); + if (!read.ok) { + logPasskeyStop('auth.passkey.register.fail', read.error, read.body, undefined, read.extra); + return c.json({ error: read.error }, 400); } const result = await finishPasskeyRegistration( deps.store, @@ -153,15 +297,14 @@ export function authRoutes(deps: AuthRouteDeps): Hono { config, deps.now(), c.req.header('origin'), - parsed.data.challengeId, - parsed.data.credential, + read.challengeId, + read.credential, nostrOpts(deps), ); if (!result.ok) { - logEvent( - 'auth.passkey.register.fail', - passkeyFailFields(parsed.data.challengeId, result.error), - ); + logPasskeyStop('auth.passkey.register.fail', result.error, { + challengeId: read.challengeId, + }); const status = result.error === WRONG_ACCOUNT_ERROR ? 403 : 400; return c.json({ error: result.error }, status); } @@ -181,7 +324,8 @@ export function authRoutes(deps: AuthRouteDeps): Hono { .post('/passkey/authenticate/begin', async (c) => { const config = webAuthnConfig(deps); if (config === null) { - return c.json({ error: 'Server auth is not configured' }, 500); + logPasskeyStop('auth.passkey.login.fail', SERVER_AUTH_UNCONFIGURED); + return c.json({ error: SERVER_AUTH_UNCONFIGURED }, 500); } const started = await startPasskeyAuthentication( deps.store, @@ -194,11 +338,13 @@ export function authRoutes(deps: AuthRouteDeps): Hono { .post('/passkey/authenticate/finish', async (c) => { const config = webAuthnConfig(deps); if (config === null) { - return c.json({ error: 'Server auth is not configured' }, 500); + logPasskeyStop('auth.passkey.login.fail', SERVER_AUTH_UNCONFIGURED); + return c.json({ error: SERVER_AUTH_UNCONFIGURED }, 500); } - const parsed = passkeyFinishBody.safeParse(await c.req.json().catch(() => null)); - if (!parsed.success) { - return c.json({ error: 'Expected a JSON body with challengeId and credential' }, 400); + const read = await readPasskeyFinish(c.req); + if (!read.ok) { + logPasskeyStop('auth.passkey.login.fail', read.error, read.body, undefined, read.extra); + return c.json({ error: read.error }, 400); } const result = await finishPasskeyAuthentication( deps.store, @@ -206,15 +352,14 @@ export function authRoutes(deps: AuthRouteDeps): Hono { config, deps.now(), c.req.header('origin'), - parsed.data.challengeId, - parsed.data.credential, + read.challengeId, + read.credential, nostrOpts(deps), ); if (!result.ok) { - logEvent( - 'auth.passkey.login.fail', - passkeyFailFields(parsed.data.challengeId, result.error), - ); + logPasskeyStop('auth.passkey.login.fail', result.error, { + challengeId: read.challengeId, + }); const status = result.error === WRONG_ACCOUNT_ERROR ? 403 : 400; return c.json({ error: result.error }, status); } @@ -234,7 +379,8 @@ export function authRoutes(deps: AuthRouteDeps): Hono { .post('/passkey/replace/begin', async (c) => { const config = webAuthnConfig(deps); if (config === null) { - return c.json({ error: 'Server auth is not configured' }, 500); + logPasskeyStop('auth.passkey.replace.refused', SERVER_AUTH_UNCONFIGURED); + return c.json({ error: SERVER_AUTH_UNCONFIGURED }, 500); } const token = bearerToken(c.req.header('authorization')); if (token === null) { @@ -250,7 +396,8 @@ export function authRoutes(deps: AuthRouteDeps): Hono { .post('/passkey/replace/finish', async (c) => { const config = webAuthnConfig(deps); if (config === null) { - return c.json({ error: 'Server auth is not configured' }, 500); + logPasskeyStop('auth.passkey.replace.refused', SERVER_AUTH_UNCONFIGURED); + return c.json({ error: SERVER_AUTH_UNCONFIGURED }, 500); } const token = bearerToken(c.req.header('authorization')); if (token === null) { @@ -266,7 +413,8 @@ export function authRoutes(deps: AuthRouteDeps): Hono { .post('/passkey/seed/begin', async (c) => { const config = webAuthnConfig(deps); if (config === null) { - return c.json({ error: 'Server auth is not configured' }, 500); + logPasskeyStop('auth.passkey.seed.fail', SERVER_AUTH_UNCONFIGURED); + return c.json({ error: SERVER_AUTH_UNCONFIGURED }, 500); } const token = bearerToken(c.req.header('authorization')); if (token === null) { @@ -300,7 +448,8 @@ export function authRoutes(deps: AuthRouteDeps): Hono { .post('/passkey/seed/finish', async (c) => { const config = webAuthnConfig(deps); if (config === null) { - return c.json({ error: 'Server auth is not configured' }, 500); + logPasskeyStop('auth.passkey.seed.fail', SERVER_AUTH_UNCONFIGURED); + return c.json({ error: SERVER_AUTH_UNCONFIGURED }, 500); } const token = bearerToken(c.req.header('authorization')); if (token === null) { @@ -328,9 +477,9 @@ export function authRoutes(deps: AuthRouteDeps): Hono { ); return c.json({ error: alreadyHasPhrase }, 409); } - const parsed = passkeyFinishBody.safeParse(await c.req.json().catch(() => null)); - if (!parsed.success) { - const badBody = 'Expected a JSON body with challengeId and credential'; + const read = await readPasskeyFinish(c.req); + if (!read.ok) { + logPasskeyStop('auth.passkey.seed.fail', read.error, read.body, account.id, read.extra); await recordPasskeySeedAttempt( deps, account.id, @@ -338,9 +487,9 @@ export function authRoutes(deps: AuthRouteDeps): Hono { 'finish', 'failed', 400, - badBody, + read.error, ); - return c.json({ error: badBody }, 400); + return c.json({ error: read.error }, 400); } const result = await finishPasskeySeed( deps.store, @@ -348,14 +497,16 @@ export function authRoutes(deps: AuthRouteDeps): Hono { config, deps.now(), c.req.header('origin'), - parsed.data.challengeId, - parsed.data.credential, + read.challengeId, + read.credential, account, ); if (!result.ok) { - logEvent( + logPasskeyStop( 'auth.passkey.seed.fail', - passkeyFailFields(parsed.data.challengeId, result.error), + result.error, + { challengeId: read.challengeId }, + account.id, ); const status = result.error === 'This account already has a recovery phrase' ? 409 : 400; await recordPasskeySeedAttempt( @@ -381,13 +532,11 @@ export function authRoutes(deps: AuthRouteDeps): Hono { await deps.store.acknowledgePasskeyRenewFailures(result.account.id, deps.now()); logEvent('auth.passkey.seed.ok', { accountId: result.account.id }); return c.json( - { - account: await serializeOwnerAccountWithPosts(result.account, deps.messages, { - store: deps.fundingStore ?? new InMemoryFundingStore(), - nowMs: deps.now(), - authStore: deps.store, - }), - }, + await serializeOwnerAccountWithPosts(result.account, deps.messages, { + store: deps.fundingStore ?? new InMemoryFundingStore(), + nowMs: deps.now(), + authStore: deps.store, + }), 200, ); });