Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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" }`.
Expand Down
18 changes: 9 additions & 9 deletions docs/handbook/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/handbook/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Loading
Loading