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
14 changes: 14 additions & 0 deletions .changeset/recovery-client-primitives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@seamless-auth/client': minor
'@seamless-auth/react-native': minor
---

Add the auth API v0.20 recovery primitives to the client and the session store.

- Recovery codes: `getRecoveryCodeStatus()`, `generateRecoveryCodes()` and `redeemRecoveryCode(code)`. Replacing an existing set needs a fresh step-up; the `403` it answers without one is recognized by `isStepUpRequired()`, and `generateRecoveryCodesWithStepUp()` runs the step-up and tries again.
- Self-service account recovery: `startAccountRecovery(channel)`, `verifyAccountRecovery(code)`, `getAccountRecoveryStatus()`, `completeAccountRecovery()`, `cancelAccountRecovery(cancelToken)`, and for a signed-in owner `listAccountRecoveryRequests()` and `cancelAccountRecoveryRequest(id)`.
- Recovery sessions: redeeming a code or completing a recovery issues a session that can only enroll a factor. `isRecoverySession()` on the client and `recoverySession` in the session state report it, and the session actions `redeemRecoveryCode` and `completeAccountRecovery` sign in with it. After a passkey registration or TOTP enrollment made from a recovery session the client refreshes the session (`POST /refresh`), so the new access token drops the recovery claim the adapters' guards refuse.
- Flows for every binding: `loadRecoveryOptions()`, `recoveryCancelTokenFromHash()`, `cancelRecoveryFromLink()`, `recoveryErrorMessage()`, `recoveryCodesText()`, `passkeyKind()` and `passkeyDisplayName()`, plus `getRecoveryErrorCode()` and `isEnrollmentRequired()`.
- Bearer transport routes each recovery route with the credential the auth API's adapter manifest gives it.

Needs an auth API with account recovery (v0.20) and a server adapter that proxies its manifest routes.
12 changes: 12 additions & 0 deletions .changeset/recovery-react-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@seamless-auth/react': minor
---

The prebuilt UI covers account recovery from auth API v0.20.

- `AuthRoutes` serves `/recovery-code` (sign in with a recovery code), `/recovery-codes` (shown after a passkey is enrolled, to generate and save a first set, or a new one once every code is spent), `/account-recovery` (self-service recovery through a verified email or phone, with its waiting period) and `/recovery/cancel`, where the link in every recovery notice lands. That page takes the token from the URL fragment, drops it from the address bar and cancels the request. Mount `AuthRoutes` at the root of the deployment's `FRONTEND_URL` so the link resolves.
- The sign-in screen offers "Use a recovery code" and "Recover your account" only where `GET /system-config/public` reports `recoveryCodes` or `accountRecovery`. A recovery session goes straight to passkey enrollment, which it cannot skip.
- New router-free settings sections: `PasskeyManagement` (list with name, kind and last use, rename, delete, add another, and a nudge towards a second passkey), `RecoveryCodeSettings` (codes left, generate or replace with a step-up), `RecoveryRequests` (open recovery requests with cancel), and `AccountSecurity`, which renders all three.
- `useAuth()` adds `recoverySession`, `redeemRecoveryCode` and `completeAccountRecovery`, and the package re-exports `useRecoveryOptions`, `isStepUpRequired`, `isEnrollmentRequired`, `getRecoveryErrorCode` and the recovery types.

Needs an auth API with account recovery (v0.20) and a server adapter that proxies its manifest routes, including `POST /auth/refresh`.
5 changes: 5 additions & 0 deletions .changeset/recovery-svelte.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@seamless-auth/svelte': minor
---

The session exposes `recoverySession`, `redeemRecoveryCode(code)` and `completeAccountRecovery()` from `@seamless-auth/client`, for a custom account recovery screen.
48 changes: 31 additions & 17 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ npm install @seamless-auth/client
## What is here

- `createSeamlessAuthClient({ apiHost })`: the request choreography for login,
registration, OTP, magic link, passkey, OAuth, step-up, TOTP, credential and
organization flows against a Seamless Auth server adapter mounted at `/auth`.
registration, OTP, magic link, passkey, OAuth, step-up, TOTP, credential,
recovery code, account recovery and organization flows against a Seamless Auth server adapter mounted at `/auth`.
Every method returns a `SeamlessAuthResult<T>`.
- `createAuthSession({ apiHost, storage?, detectPreviousSignIn? })`: the session
state machine (`getState`, `subscribe`, `actions`, `destroy`), designed to be
Expand All @@ -46,27 +46,41 @@ than redeclaring shapes.
Each bundled screen in a binding is a thin view over one of these. A custom UI in
any framework can call them directly.

| Flow | What it does |
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `beginSignIn(actions, { identifier, passkeySupported, configuredMethods })` | Starts a login and runs the passkey ceremony when it can. Resolves to `signed_in`, `choose_method` or `error` |
| `registerWithEmail(client, email)` | Starts registration. The next step is the email code |
| `requestOtp` / `verifyOtp` | Sends and checks a code for a channel (`email`, `phone`) and flow (`login`, `register`), and says where to go next |
| `watchMagicLink({ client, refreshSession, onSignedIn })` | In the tab that asked for a link, waits for it to be used (cross-tab message or poll). Returns a stop function |
| `finishMagicLinkSignIn({ client, refreshSession })` | On the page the link lands on, after `client.verifyMagicLink(token)` succeeds |
| `getMagicLinkConfirmation(error)`, `magicLinkConfirmationPrompt(held)` | When verifying fails because the link was opened on another device: who asked for it, and when |
| `confirmMagicLinkSignIn({ client, token })` | Approves that link once the reader agrees. Signs in the device that asked for it, not this one |
| `startOAuthSignIn` / `completeOAuthCallback` | Starts a provider sign-in and finishes it on the redirect URI |
| `enrollPasskey({ client, refreshSession }, attachment?)` | Enrols a passkey for the signed-in user, labelled with this device |
| `loadLoginMethods`, `fallbackSignInOptions`, `hasNonPasskeyLoginMethod` | What the instance allows, and what to offer a user |
| `detectPasskeySupport(port)` | Whether this device can use a passkey |
| `safeReturnPath`, `inAppPath` | Keep a destination inside the application |
| `isValidEmail`, `isValidPhoneNumber`, `parseUserAgent`, `formatCountdown` | Small shared helpers |
| Flow | What it does |
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `beginSignIn(actions, { identifier, passkeySupported, configuredMethods })` | Starts a login and runs the passkey ceremony when it can. Resolves to `signed_in`, `choose_method` or `error` |
| `registerWithEmail(client, email)` | Starts registration. The next step is the email code |
| `requestOtp` / `verifyOtp` | Sends and checks a code for a channel (`email`, `phone`) and flow (`login`, `register`), and says where to go next |
| `watchMagicLink({ client, refreshSession, onSignedIn })` | In the tab that asked for a link, waits for it to be used (cross-tab message or poll). Returns a stop function |
| `finishMagicLinkSignIn({ client, refreshSession })` | On the page the link lands on, after `client.verifyMagicLink(token)` succeeds |
| `getMagicLinkConfirmation(error)`, `magicLinkConfirmationPrompt(held)` | When verifying fails because the link was opened on another device: who asked for it, and when |
| `confirmMagicLinkSignIn({ client, token })` | Approves that link once the reader agrees. Signs in the device that asked for it, not this one |
| `startOAuthSignIn` / `completeOAuthCallback` | Starts a provider sign-in and finishes it on the redirect URI |
| `enrollPasskey({ client, refreshSession }, attachment?)` | Enrols a passkey for the signed-in user, labelled with this device |
| `loadLoginMethods`, `fallbackSignInOptions`, `hasNonPasskeyLoginMethod` | What the instance allows, and what to offer a user |
| `detectPasskeySupport(port)` | Whether this device can use a passkey |
| `safeReturnPath`, `inAppPath` | Keep a destination inside the application |
| `loadRecoveryOptions(client)` | Whether the instance offers recovery codes and self-service account recovery |
| `generateRecoveryCodesWithStepUp({ client, verifyStepUp })` | Generates a set of recovery codes, stepping up first when the API asks for it |
| `recoveryCancelTokenFromHash(hash)`, `cancelRecoveryFromLink(client, token)` | The cancel page a recovery notice links to |
| `recoveryErrorMessage(error)`, `recoveryCodesText(codes)` | Copy for recovery failures, and a set of codes as a text file |
| `passkeyKind`, `passkeyDisplayName` | How a passkey management screen labels a credential |
| `isValidEmail`, `isValidPhoneNumber`, `parseUserAgent`, `formatCountdown` | Small shared helpers |

None of them store a token. In a browser the session lives in HttpOnly cookies the
server adapter sets; the only things written to browser storage are the id of the
OAuth provider in flight and that sign-in's state binding (`sessionStorage`), and a
flag that this browser has signed in before (`localStorage`).

### Recovery sessions

`redeemRecoveryCode(code)` and `completeAccountRecovery()` issue a recovery session, which can only
enroll a passkey or authenticator app until one is enrolled (other routes answer `403`
`enrollment_required`, see `isEnrollmentRequired()`). `client.isRecoverySession()` and the session
state's `recoverySession` report it. After `registerPasskey()` or `verifyTotpEnrollment()` succeeds
from one, the client rotates the session through `POST /refresh` so the next access token no longer
carries the recovery claim. The flag is held in memory, so it does not survive a reload.

### OAuth state binding

Auth servers that bind the OAuth state to the client that started sign-in return a
Expand Down
Loading
Loading