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
10 changes: 10 additions & 0 deletions .changeset/recovery-notice-delivery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'seamless-auth-api': patch
---

Account recovery works with adapter-owned delivery, and a signed-in owner can cancel it.

- `POST /recovery/verify` returns the "recovery opened" notices as `deliveries` (`account_notice_email` / `account_notice_sms`, with the `cancelUrl`) to a credentialed caller that owns delivery and lists those kinds in the new `x-seamless-auth-delivery-kinds` header. A caller that owns delivery but does not list them still gets `503 recovery_notice_failed`, since an older adapter would drop a kind it does not know. The adapter manifest marks the route `notices: true`.
- The recovery code from `POST /recovery/start` carries `purpose: "account_recovery"` under external delivery.
- `GET /recovery/requests` and `DELETE /recovery/requests/:id` (access session) list and cancel the account's open recovery requests without the token from the notice.
- Updates `@seamless-auth/types` to 0.31.0.
4 changes: 3 additions & 1 deletion adapter-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,11 @@
"credential": "preAuth",
"issues": "session"
},
{ "method": "GET", "path": "/recovery/requests", "credential": "access" },
{ "method": "DELETE", "path": "/recovery/requests/{id}", "credential": "access" },
{ "method": "POST", "path": "/recovery/start", "credential": "preAuth", "delivery": true },
{ "method": "GET", "path": "/recovery/status", "credential": "preAuth" },
{ "method": "POST", "path": "/recovery/verify", "credential": "preAuth" },
{ "method": "POST", "path": "/recovery/verify", "credential": "preAuth", "notices": true },
{ "method": "POST", "path": "/refresh", "credential": "refresh", "issues": "session" },
{ "method": "POST", "path": "/registration/phone", "credential": "access", "delivery": true },
{ "method": "POST", "path": "/registration/phone/verify", "credential": "access" },
Expand Down
44 changes: 27 additions & 17 deletions docs/account-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,19 @@ opens, with a link that cancels it.

Off by default. Turn it on with `ACCOUNT_RECOVERY` (`account_recovery`), whose `delay` is
`24h` unless set (`1h` to `30d`). While it is off, every route below answers
`403 { "error": "account_recovery_disabled" }`, and `GET /system-config/public` reports
`403 { "error": "account_recovery_disabled" }` (except cancelling, and the session routes for
open requests), and `GET /system-config/public` reports
`accountRecovery` so sign-in screens offer it only where it works.

| Route | Auth | What it does |
| ------------------------- | ---------------------------- | --------------------------------------------------------- |
| `POST /recovery/start` | pre-auth token from `/login` | `{ channel: "email" \| "phone" }`, sends a six-digit code |
| `POST /recovery/verify` | the same attempt | `{ code }`, opens the request and starts the delay |
| `GET /recovery/status` | the same attempt | `{ state, requestedAt, completesAt }` |
| `POST /recovery/cancel` | none | `{ cancelToken }` from the notice |
| `POST /recovery/complete` | the same attempt, once ready | Signs out every session and issues a recovery session |
| Route | Auth | What it does |
| ------------------------------- | ---------------------------- | --------------------------------------------------------------------------------- |
| `POST /recovery/start` | pre-auth token from `/login` | `{ channel: "email" \| "phone" }`, sends a six-digit code |
| `POST /recovery/verify` | the same attempt | `{ code }`, opens the request and starts the delay |
| `GET /recovery/status` | the same attempt | `{ state, requestedAt, completesAt }` |
| `POST /recovery/cancel` | none | `{ cancelToken }` from the notice |
| `POST /recovery/complete` | the same attempt, once ready | Signs out every session and issues a recovery session |
| `GET /recovery/requests` | access session | `{ requests: [{ id, channel, state, requestedAt, completesAt }] }`, the open ones |
| `DELETE /recovery/requests/:id` | access session | Cancels one of them without the token |

### The flow

Expand Down Expand Up @@ -120,7 +123,9 @@ that code and leave the open request as it was.
`POST /recovery/cancel` with the token from the notice cancels the request whether it is
`pending` or `ready`. It needs no session, because the owner may be signed out everywhere,
and it answers the same `200` whether or not the token matched, so it is not an oracle.
Cancelling from a signed-in session without the token is a follow-up.
A signed-in owner can also see open requests with `GET /recovery/requests` and cancel one
with `DELETE /recovery/requests/:id`, without the token. Both work while recovery is turned
off, and a request id from another account answers `404`.

### Notes

Expand All @@ -129,14 +134,19 @@ Cancelling from a signed-in session without the token is a follow-up.
the OTP flow rate limits.
- A pre-auth token for an identifier with no account gets the answers a real account with
no verified address gets, and nothing is stored or sent for it.
- The opening notice needs direct delivery: the API's own email or SMS transport. The
delivery contract has no kind for account notices yet, so a credentialed caller that
owns delivery (external delivery with a service token) on `/recovery/verify` gets the
`503`, with each notice recorded as `not_sent_external_delivery`. A deployment that
delivers codes through its adapter can still offer recovery, as long as the API has a
working transport for the notice and the adapter does not ask for external delivery on
verify. The delivery header alone does not suppress notices, so a stranger cannot
recover an account silently.
- Under external delivery the code arrives with `purpose: "account_recovery"`, so an
adapter can word it as a recovery warning rather than a sign-in code.
- The opening notice goes out directly, through the API's own email or SMS transport,
unless the caller owns delivery. A credentialed caller that owns delivery (external
delivery with a service token) gets the notices back only if it lists their kinds in
`x-seamless-auth-delivery-kinds` (`account_notice_email`, `account_notice_sms`): they
arrive as `deliveries` on the `/recovery/verify` response, each with `notice:
"account_recovery_opened"` and the `cancelUrl`, and count as notifying the owner. A
caller that owns delivery but lists neither kind gets the `503`, with each notice
recorded as `not_sent_external_delivery`, because an adapter that predates the kinds
would drop them. The adapter manifest marks the route `notices: true`. The delivery
header alone does not suppress notices, so a stranger cannot recover an account
silently, and the cancel link is never written to the audit event.
- Audit events: `account_recovery_started`, `account_recovery_verified` (with each notice's
outcome), `account_recovery_cancelled`, `account_recovery_completed` (with the number of
sessions revoked) and `account_recovery_failed` (a wrong code, a failed delivery, or a request voided because the
Expand Down
7 changes: 7 additions & 0 deletions docs/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ its own channel. See [`src/lib/externalDelivery.ts`](../src/lib/externalDelivery
- For local development without a service token, set `ALLOW_UNCREDENTIALED_DELIVERY_SECRETS=true`.
The flag is ignored when `NODE_ENV=production`.

Kinds that older adapters do not know are only returned to a caller that names them in
`x-seamless-auth-delivery-kinds` (a comma separated list of `kind` values). Today that covers
the owner notices for account recovery (`account_notice_email`, `account_notice_sms`), which
`POST /recovery/verify` returns as a `deliveries` array; routes that can do this are marked
`notices: true` in the adapter manifest. A caller that does not name them gets direct
delivery, or a refusal where a notice is required.

This is how a server adapter integrates its own email/SMS stack without the API baking in a
provider. See the delivery payload shapes in
[`src/schemas/generic.responses.ts`](../src/schemas/generic.responses.ts) (`AuthDeliverySchema`)
Expand Down
Loading
Loading