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

OAuth sign-in respects the per-account passkey requirement (#469).

- With `passkey_login_fallback_enabled` off, `POST /oauth/:providerId/callback` for an account that holds a passkey now answers `403 login_method_disabled` and issues no session, as the email code, phone code, magic link and TOTP continuations already did. The refusal is recorded as `oauth_login_failed` with reason `passkey_required`.
- Session issuance now applies the same rule as its backstop, not only phishing-resistant-only mode, so a sign-in path that misses the check fails closed instead of issuing a session.
10 changes: 10 additions & 0 deletions .changeset/revoke-all-sessions-cutoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'seamless-auth-api': patch
---

Log out everywhere can no longer miss a session a concurrent refresh was creating (#471).

- `DELETE /logout/all`, `DELETE /sessions`, `DELETE /admin/sessions/:userId/revoke-all`, device-replacement recovery and OAuth provider retirement revoke with one bulk update instead of reading the sessions and revoking them one at a time.
- Each also records a per-user cutoff. A session created at or before it is refused on every authenticated request and on `POST /refresh`, which re-reads the cutoff after claiming the rotation, so a rotation that started before the revoke cannot produce a live session. The refusal is `401 invalid_refresh_token`, with `refusal: 'sessions_revoked'` on the `refresh_token_failed` auth event.
- Validating an access token now reads the cutoff from the session owner's row, one indexed lookup per authenticated request.
- Migration: `users` gains `sessions_revoked_before`.
2 changes: 1 addition & 1 deletion docs/admin-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ It does not return credential private material, TOTP secrets, recovery codes, re

## Session Hygiene

Administrative session endpoints can list sessions and revoke individual or all sessions for a user. Use these endpoints when responding to suspicious account activity or user-requested device cleanup.
Administrative session endpoints can list sessions and revoke individual or all sessions for a user. Use these endpoints when responding to suspicious account activity or user-requested device cleanup. Revoking all of a user's sessions also records a cutoff on the account, so a session a concurrent refresh was creating at that moment is refused as well (see [Log out everywhere](./security-posture.md#log-out-everywhere)).

## Lockout Policy

Expand Down
6 changes: 4 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,10 @@ Supported login methods are controlled by `login_methods` system config:
Accounts holding a passkey can be restricted to passkey-only continuation by disabling
`passkey_login_fallback_enabled`. `phishing_resistant_only` goes further and restricts every
account to passkeys, apart from the one session that verifies a new account's address. Both
are enforced on each continuation endpoint as well as in the `/login` method list, and session
issuance refuses a non-passkey factor in phishing-resistant-only mode as a backstop. The client sends a `passkeyAvailable` capability hint on
are enforced on each continuation endpoint, including the OAuth callback, as well as in the
`/login` method list. Session issuance applies both rules again as a backstop, refusing a
non-passkey factor whenever either one requires a passkey, so a sign-in path that forgets the
check fails closed instead of issuing a session. The client sends a `passkeyAvailable` capability hint on
`POST /login`, but it is advisory: it can remove passkey from a set the policy already permits,
never add a weaker method to a passkey-only one.

Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ first boot.
| `RATE_LIMIT` | Yes | - | `rate_limit` | Global limit, positive integer. |
| `DELAY_AFTER` | Yes | - | `delay_after` | Slow-down threshold, non-negative integer. |
| `LOGIN_METHODS` | No | `passkey,magic_link` | `login_methods` | Any of `passkey,magic_link,email_otp,phone_otp,oauth`. `.env.example` ships `passkey,magic_link,email_otp` so a stock instance is CLI/headless-loginable; `email_otp` needs a configured messaging transport or the external-delivery path. |
| `PASSKEY_LOGIN_FALLBACK_ENABLED` | No | `true` | `passkey_login_fallback_enabled` | When `false`, an account that holds a passkey continues with passkey only. The client's `passkeyAvailable` hint on `POST /login` cannot widen that: a caller reporting no passkey support is still offered passkey only, because otherwise any caller could ask for a weaker method by claiming not to support passkeys. A browser that genuinely cannot run the ceremony cannot sign in, which is what this setting means. Accounts with no passkey are unaffected and keep the configured methods. The rule binds on every continuation endpoint, not only on the list `/login` returns, so a passkey holder calling an email code, phone code, magic link, TOTP or email verification endpoint directly gets `403 login_method_disabled`. |
| `PASSKEY_LOGIN_FALLBACK_ENABLED` | No | `true` | `passkey_login_fallback_enabled` | When `false`, an account that holds a passkey continues with passkey only. The client's `passkeyAvailable` hint on `POST /login` cannot widen that: a caller reporting no passkey support is still offered passkey only, because otherwise any caller could ask for a weaker method by claiming not to support passkeys. A browser that genuinely cannot run the ceremony cannot sign in, which is what this setting means. Accounts with no passkey are unaffected and keep the configured methods. The rule binds on every continuation endpoint, not only on the list `/login` returns, so a passkey holder calling an email code, phone code, magic link, TOTP or email verification endpoint directly, or finishing an OAuth sign-in, gets `403 login_method_disabled`. |
| `PHISHING_RESISTANT_ONLY` | No | `false` | `phishing_resistant_only` | When `true`, a session starts only from a passkey. Email and phone codes, magic links, TOTP and OAuth are refused (`403 login_method_disabled`, and OAuth providers are hidden) whatever `LOGIN_METHODS` and `PASSKEY_LOGIN_FALLBACK_ENABLED` say, and `GET /system-config/public` reports `loginMethods: ["passkey"]`. The one exception is a new account: the email code that verifies its address starts one session so its first passkey can be enrolled. An account that never enrolls one cannot sign in again on its own and needs admin recovery. Off by default, so existing deployments are unaffected. |
| `LOCKOUT_POLICY` | No | `{"enabled":true,"maxFailures":10,"windowSeconds":900,"lockoutSeconds":900}` | `lockout_policy` | JSON. Set `enabled:false` only when an upstream policy handles lockout. |
| `SESSION_IDLE_TTL` | No | `8h` | `session_idle_ttl` | Format `\d+[smhd]`. How long a session may go unrefreshed. The absolute session lifetime is `REFRESH_TOKEN_TTL`, measured from sign-in and not extended by refresh; this only binds while it is the shorter of the two. |
Expand Down
6 changes: 6 additions & 0 deletions docs/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ Example provider:
5. `POST /oauth/:providerId/callback` with `code`, `state` and the `binding` from step 2.
6. Seamless Auth validates state and its binding, exchanges the code, fetches userinfo, resolves the local user, and issues a Seamless Auth session.

OAuth is a non-passkey factor, so the passkey rules bind here as they do on every other
continuation. When the resolved account has to sign in with a passkey (phishing-resistant-only
mode, or the account holds a passkey and `passkey_login_fallback_enabled` is off), the callback
answers 403 `login_method_disabled`, records `oauth_login_failed` with reason `passkey_required`,
and issues no session.

The `binding` ties the `state` to the client that started the sign-in. The state travels through
the provider and the address bar, so without it anyone holding a state and its code could finish
the sign-in in another browser and sign that browser in to their own account (login CSRF). A
Expand Down
22 changes: 22 additions & 0 deletions docs/security-posture.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,28 @@ of one token signs its user out, which is the direction this has to fail: the al
is that whoever wins the race keeps a session, and the winner is not always the client that
should have it.

### Log out everywhere

"Log out everywhere" (`DELETE /logout/all`, `DELETE /sessions`) and the admin equivalents
(`DELETE /admin/sessions/:userId/revoke-all`, device-replacement recovery, and retiring an
OAuth provider for an organization) used to read a user's sessions and then revoke them one
row at a time. A refresh landing between the read and the writes created a replacement that
nothing revoked, so the user stayed signed in on the device they were trying to remove.

All of them now go through
[`revokeAllSessionsForUsers`](../src/services/sessionRevocation.ts), which revokes with one
`UPDATE ... WHERE user_id = ? AND revoked_at IS NULL` and first records
`users.sessions_revoked_before`. A session created at or before that time is refused by
`validateSessionRecord` and by `POST /refresh` even if its own row was never revoked, and a
refresh re-reads the cutoff after it claims the rotation, so a rotation that started before
the revoke cannot hand out a working session. It answers `401 invalid_refresh_token`, revokes
the session with `revokedReason: 'sessions_revoked'`, and records `refresh_token_failed` with
`refusal: 'sessions_revoked'`.

The comparison uses the API's clock for both sides. Instances with skewed clocks can refuse a
session created within the skew after a revoke-all, which signs that user in again rather than
leaving a session alive.

### Absolute and idle lifetime

Rotation does not extend a session. Every session in a chain carries `chainStartedAt`, the time of
Expand Down
Loading
Loading