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
5 changes: 5 additions & 0 deletions .changeset/harden-secret-responses-and-provider-fetches.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'seamless-auth-api': patch
---

Two hardening fixes from #556. Every API response now carries `Cache-Control: no-store` unless its route sets its own caching (the JWKS and the admin dashboard's hashed assets do), so tokens, client secrets from create and rotate, recovery codes and delivery payloads are never stored by a cache. Provider requests (OAuth token, userinfo and discovery, and back-channel logout) now go through the address guard in every mode: outside production, `http` and private or loopback addresses are still allowed, but link-local `169.254.0.0/16` and unspecified `0.0.0.0/8` addresses are refused whether named directly or reached through DNS.
5 changes: 5 additions & 0 deletions .changeset/notice-limit-canonical-user.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'seamless-auth-api': patch
---

The per-user limit on `POST /applications/account-notices` now folds the `userId` to lower case before counting (#553). Upper-case variants of one UUID no longer each get their own allowance of 10 notices an hour.
5 changes: 5 additions & 0 deletions .changeset/notice-text-without-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'seamless-auth-api': minor
---

Application notice text can no longer carry its own links (#554). `POST /applications/account-notices` refuses with `400` a `text` that contains a URL, a `www.` address, a domain name or an email address, since mail and SMS clients turn all of them into links; `actionUrl`, checked against the application's origins, is the only link a notice carries. The `Sent by <application> through <instance>.` attribution is now the first line of the message, ahead of the text, rather than a footer the text could imitate. Adapters that render notices from external delivery receive the new layout in `text`.
5 changes: 5 additions & 0 deletions .changeset/passkey-assurance-from-registration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'seamless-auth-api': minor
---

Passkey hardware assurance now comes from the registered credential rather than the assertion (#552). Sign-in and step-up refuse an assertion whose backup eligibility flag differs from the one stored at registration (`401`, audited as `webauthn_login_failed` or `step_up_failed` with reason `Backup eligibility differs from registration`). A passkey counts as device-bound, and earns `acr=phrh` and `amr=hwk`, only when it was registered device-bound with attestation verified against the FIDO Metadata Service. Deployments that use the default `none` attestation, and credentials registered before attestation was recorded, now get `phr` and `swk` instead of `phrh` and `hwk`.
5 changes: 5 additions & 0 deletions .changeset/send-unadvertised-notice-kinds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'seamless-auth-api': minor
---

Owner notices that a delivery-owning adapter does not advertise are now sent directly instead of dropped (#557). Under external delivery with a service token, a notice whose kind is missing from `x-seamless-auth-delivery-kinds` (for example the SMS notice on `/recovery/verify` when the adapter lists only `account_notice_email`) goes out through the API's own email or SMS transport when one is configured, and is recorded as `sent` or `failed` rather than `not_sent_external_delivery`. The same applies to the recovery completed and recovery code used notices and to `POST /applications/account-notices`. A recovery that previously answered `503 recovery_notice_failed` for an adapter listing no notice kinds now proceeds when the API can notify the owner itself. Admin identity-change notices are unchanged.
5 changes: 5 additions & 0 deletions .changeset/third-party-clients-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'seamless-auth-api': minor
---

`third_party_clients_enabled` now gates existing third-party OpenID Provider clients, not only creating and updating them (#555). While it is off, a client that is not first-party is treated like a disabled one: `/oidc/clients/:id` answers `404`, its shared secret does not verify, `/oidc/sign` refuses with `client_inactive`, its notice tokens are refused and back-channel logout skips it. An instance that turned the flag off after registering third-party clients stops serving them on upgrade; turn the flag back on, or mark the applications first-party, to keep them working.
7 changes: 4 additions & 3 deletions docs/account-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,10 @@ off, and a request id from another account answers `404`.
`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
kind the caller does not list goes out directly through the API's own transport for
that channel, because an adapter that predates the kinds would drop it. Only when the
API has no transport for it either is it recorded as `not_sent_external_delivery`, and
if no notice reached the owner the caller gets the `503`. 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
Expand Down
24 changes: 13 additions & 11 deletions docs/adr/0001-openid-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,21 +215,23 @@ The API derives both from the session's `auth_methods`. The edge only passes the

`amr` uses the RFC 8176 registry:

| Sign-in method | `amr` |
| --------------------------------------- | -------------------------------------------------- |
| Passkey, device-bound (BE flag not set) | `hwk`, plus `user` and `mfa` when UV was performed |
| Passkey, synced or unknown | `swk`, plus `user` and `mfa` when UV was performed |
| Email code or magic link | `otp` |
| SMS code | `sms`, `otp` |
| TOTP | `otp` |
| Two distinct factors in one session | `mfa` added |
| Sign-in method | `amr` |
| ----------------------------------- | -------------------------------------------------- |
| Passkey, device-bound (see below) | `hwk`, plus `user` and `mfa` when UV was performed |
| Passkey, synced or unknown | `swk`, plus `user` and `mfa` when UV was performed |
| Email code or magic link | `otp` |
| SMS code | `sms`, `otp` |
| TOTP | `otp` |
| Two distinct factors in one session | `mfa` added |

`acr` uses the OpenID Enhanced Authentication Profile ACR values:

- `phrh` for a device-bound passkey with user verification.
- `phr` for any passkey with user verification.
- Omitted otherwise.

**Amendment, 2026-11-02: device binding comes from registration (#552).** A passkey counts as device-bound only when its registration stored the BE flag as not set and its attestation was verified against the FIDO Metadata Service. The flag in each assertion is no longer trusted on its own: WebAuthn Level 3 fixes it at registration, so an assertion whose flag differs from the stored value is refused, and under `none` attestation the flag is self-reported. A deployment that does not request attestation therefore never issues `phrh` or `hwk`.

`acr_values_supported` is `["phr", "phrh"]`. Seamless-specific names were rejected, because generic RPs would not recognise them. #510's `webauthn` example is read with this mapping.

## D10. Consent phishing limits
Expand All @@ -247,7 +249,7 @@ A passkey resists phishing because it is bound to an origin. Two flows can still

- A client is first-party only if an admin marks it so.
- Third-party clients always show the consent page, with their redirect domain.
- An instance-wide `third_party_clients_enabled` flag, off by default, gates them entirely.
- An instance-wide `third_party_clients_enabled` flag, off by default, gates them entirely. Turning it off makes existing third-party clients inactive in the API, as a disabled client is (#555).

## D11. Compatibility and token separation

Expand Down Expand Up @@ -313,8 +315,8 @@ The notices endpoint, `POST /applications/account-notices`, stays on the private

- It goes only to a user who already holds a grant with that application, or with an application it is linked to (amended 2026-11-02, below).
- The application must have notices enabled by an admin (`oidc_clients`).
- It is plain text only, inside a fixed template the application cannot change: "Sent by <application> through <instance>". It cannot pose as a Seamless security notice.
- An optional action link must be on one of the application's `https` redirect origins or its `noticeActionOrigins`.
- It is plain text only, inside a fixed template the application cannot change: "Sent by <application> through <instance>" as the first line, ahead of the text, so the text cannot imitate it (amended 2026-11-02, #554). It cannot pose as a Seamless security notice.
- An optional action link must be on one of the application's `https` redirect origins or its `noticeActionOrigins`. It is the only link: text that contains a URL, a domain name or an email address is refused with `400`, since mail and SMS clients link those too (#554).
- Rate limits apply per client and per user, and every notice writes an auth event.
- Delivery uses `src/services/accountNotices.ts`, including external delivery (`account_notice_email`, `account_notice_sms`).
- types#120 defines the request and response. The response lists channels, never addresses.
Expand Down
20 changes: 10 additions & 10 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,16 +364,16 @@ See [docs/production-operations.md](./production-operations.md) for key rotation
The API's side of the OpenID Provider service ([ADR 0001](./adr/0001-openid-provider.md)). All of
it is off until a deployment runs the provider service and turns it on.

| Variable | Required | Default | Notes |
| ---------------------------------- | ------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `OIDC_PROVIDER_ENABLED` | No | `false` | Seeds `oidc_provider_enabled`. While off, every `/oidc/*` route answers 404. The server refuses to start with it on unless the checks below pass. |
| `OIDC_ISSUER` | When enabled | - | Seeds `oidc_issuer`, the provider service's public URL. Must be https, must differ from `ISSUER`, and its host must be `rpid` or a subdomain of it. |
| `OIDC_ALLOW_CLIENT_SECRETS` | No | `false` | Seeds `allow_client_secrets`. Lets an admin opt individual provider clients in to shared secrets. `private_key_jwt` is the default without it. |
| `OIDC_THIRD_PARTY_CLIENTS_ENABLED` | No | `false` | Seeds `third_party_clients_enabled`. Third-party clients always show a consent page. |
| `SEAMLESS_OIDC_SERVICE_TOKEN` | When enabled | - | The provider service's own service token secret. Must differ from `SEAMLESS_SERVICE_TOKEN`, or the server refuses to start. `/oidc/*` accepts only this, and external delivery refuses it. |
| `OIDC_JWKS_ACTIVE_KID` | When enabled | - | Key ID of the provider signing key. Only this key signs. |
| `OIDC_JWKS_KEYS` | When enabled | - | JSON JWK set of private RS256 keys, each with a unique `kid`. Separate from the `SEAMLESS_JWKS_*` keys. Every key in it is published at `/oidc/jwks`. |
| `OIDC_HOSTED_UI` | No | - | Seeds `hosted_ui` as JSON: branding for the hosted pages (`appName`, an https `logoUrl`, hex `colors`, `fontFamily`). Admins change it through `PATCH /system-config/admin`. |
| Variable | Required | Default | Notes |
| ---------------------------------- | ------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `OIDC_PROVIDER_ENABLED` | No | `false` | Seeds `oidc_provider_enabled`. While off, every `/oidc/*` route answers 404. The server refuses to start with it on unless the checks below pass. |
| `OIDC_ISSUER` | When enabled | - | Seeds `oidc_issuer`, the provider service's public URL. Must be https, must differ from `ISSUER`, and its host must be `rpid` or a subdomain of it. |
| `OIDC_ALLOW_CLIENT_SECRETS` | No | `false` | Seeds `allow_client_secrets`. Lets an admin opt individual provider clients in to shared secrets. `private_key_jwt` is the default without it. |
| `OIDC_THIRD_PARTY_CLIENTS_ENABLED` | No | `false` | Seeds `third_party_clients_enabled`. Third-party clients always show a consent page. While it is off, existing third-party clients are inactive: the API does not serve them to the edge, verify their secrets, sign for them or accept their notice tokens. |
| `SEAMLESS_OIDC_SERVICE_TOKEN` | When enabled | - | The provider service's own service token secret. Must differ from `SEAMLESS_SERVICE_TOKEN`, or the server refuses to start. `/oidc/*` accepts only this, and external delivery refuses it. |
| `OIDC_JWKS_ACTIVE_KID` | When enabled | - | Key ID of the provider signing key. Only this key signs. |
| `OIDC_JWKS_KEYS` | When enabled | - | JSON JWK set of private RS256 keys, each with a unique `kid`. Separate from the `SEAMLESS_JWKS_*` keys. Every key in it is published at `/oidc/jwks`. |
| `OIDC_HOSTED_UI` | No | - | Seeds `hosted_ui` as JSON: branding for the hosted pages (`appName`, an https `logoUrl`, hex `colors`, `fontFamily`). Admins change it through `PATCH /system-config/admin`. |

The issuer has to sit under `rpid` because the hosted sign-in pages run on it and passkeys are
bound to `rpid`. Changing `rpid` to fit a new hostname orphans every existing passkey, so pick
Expand Down
5 changes: 3 additions & 2 deletions docs/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ Kinds that older adapters do not know are only returned to a caller that names t
`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.
`notices: true` in the adapter manifest. A kind the caller does not name is sent directly
through the API's own transport for its channel; when the API has none, the notice is not sent, and a
route that requires it refuses.

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
Expand Down
5 changes: 4 additions & 1 deletion docs/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ Provider access tokens are used only during callback handling to fetch profile d
other secret the server holds.
4. In production, provider URLs must use `https` and resolve to public addresses, and the server does not
follow redirects from them. For an on-premises IdP, list its host in `OAUTH_PROVIDER_PRIVATE_HOSTS`.
Link-local addresses are refused even when listed.
Link-local addresses are refused even when listed. Outside production, `http` and private or loopback
addresses are allowed so a local stack can reach its IdP, but link-local (`169.254.0.0/16`, which carries
cloud instance metadata) and unspecified (`0.0.0.0/8`) addresses are refused in every mode, whether
named directly or reached through DNS.
5. Set `OAUTH_STATE_SECRET` in production. Adding a provider answers `409 secret_required` until it is
set, and the server refuses to start with a provider configured and no value.

Expand Down
Loading
Loading