From ba1dbcfe7d514c8561cab95a26fba031d7a7782e Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 20 Oct 2026 21:19:59 -0400 Subject: [PATCH] docs: add the RFC 9700 conformance matrix for the OAuth client Checks every client recommendation against the API at v0.19.1 and lists the authorization server rows as applying from v0.21. Links it from the security posture page. --- src/content/docs/understand/oauth-rfc9700.mdx | 101 ++++++++++++++++++ src/content/docs/understand/security.mdx | 3 +- 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 src/content/docs/understand/oauth-rfc9700.mdx diff --git a/src/content/docs/understand/oauth-rfc9700.mdx b/src/content/docs/understand/oauth-rfc9700.mdx new file mode 100644 index 0000000..3d7c1b5 --- /dev/null +++ b/src/content/docs/understand/oauth-rfc9700.mdx @@ -0,0 +1,101 @@ +--- +title: 'OAuth Client and RFC 9700' +description: 'How the Seamless Auth API, as an OAuth 2.0 and OpenID Connect client for social and enterprise sign-in, measures against the RFC 9700 OAuth 2.0 Security Best Current Practice, row by row, each linked to the code at v0.19.1.' +sidebar: + order: 6 +--- + +import { Aside } from '@astrojs/starlight/components'; + +[RFC 9700](https://www.rfc-editor.org/rfc/rfc9700) is the OAuth 2.0 Security Best Current +Practice. It gives recommendations for clients, for authorization servers, and for resource +servers. This page checks the Seamless Auth API against the ones that apply to it today. + +Today the Seamless Auth API is an OAuth **client** only. When a provider is configured (Google, +GitHub, Microsoft Entra ID, or any OAuth 2.0 or OpenID Connect provider, see +[OAuth Login](/build/oauth-login)), the API sends the user to that provider, exchanges the +authorization code, reads the profile, and then issues its own Seamless Auth session. It is not an +authorization server for other applications until the OpenID Provider planned for v0.21, so the +authorization server recommendations are listed below as "applies from v0.21", not as passes or +gaps. + +Every code link points at the +[v0.19.1](https://github.com/fells-code/seamless-auth-api/tree/v0.19.1) tag of the API. The flow +lives in [`oauthService.ts`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/services/oauthService.ts), +[`oauth.ts`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/controllers/oauth.ts) +(the controller), and +[`oauth.routes.ts`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/routes/oauth.routes.ts). + +Status values: + +- **Meets**: the recommendation holds by default. +- **Meets, configurable**: it holds by default, and an operator setting can turn it off. +- **Partial**: some of the recommendation holds, and the row says which part does not. +- **Gap**: it does not hold in v0.19.1. The row links the issue that tracks the fix. +- **Not used**: the API does not use the feature the recommendation is about. + +--- + +## Client Recommendations + +| RFC 9700 | Recommendation | Status | How, in v0.19.1 | +| --- | --- | --- | --- | +| [2.1](https://www.rfc-editor.org/rfc/rfc9700#section-2.1), [4.7](https://www.rfc-editor.org/rfc/rfc9700#section-4.7) | Prevent CSRF: the `state` (or PKCE verifier, or OpenID Connect nonce) must be bound to the user agent that started the flow | **Gap**, fix in [#463](https://github.com/fells-code/seamless-auth-api/issues/463) | `state` is HMAC-SHA256 signed, expires after ten minutes, and is refused on reuse ([`createOAuthState`, `verifyOAuthState`, `consumeOAuthState`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/services/oauthService.ts#L228-L281)). It is not bound to the browser or app that started the sign-in, and the PKCE verifier is derived from the state payload ([`createOAuthPkceCodeVerifier`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/services/oauthService.ts#L287-L305)), so it does not bind the flow either. Someone holding an unused state and its code could finish the sign-in in another browser and sign that browser in to their own account. The fix returns a binding from `/start` that the callback must present. | +| [2.1](https://www.rfc-editor.org/rfc/rfc9700#section-2.1), [4.4](https://www.rfc-editor.org/rfc/rfc9700#section-4.4) | A client that uses more than one authorization server must defend against mix-up attacks, with the `iss` response parameter ([RFC 9207](https://www.rfc-editor.org/rfc/rfc9207)) or a distinct redirect URI per authorization server | **Partial** | The signed state names the provider, and a callback for another provider is refused ([`verifyOAuthState`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/services/oauthService.ts#L243-L263)). That stops a state from one provider being used with another, but it does not stop the mix-up attack itself. The callback body has no `iss` and none is checked ([`FinishOAuthLoginRequestSchema`](https://github.com/fells-code/seamless-auth-types/blob/v0.28.0/src/schemas/oauth/schema.ts#L42-L45)). A provider can have its own `redirectUri`, but without one every provider shares `/oauth/callback` ([`resolveOAuthRedirectUri`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/services/oauthService.ts#L199-L219)). Every provider is configured by the operator, so the attack needs a provider the operator added to be malicious or compromised. Give each provider its own redirect URI. | +| [2.1.1](https://www.rfc-editor.org/rfc/rfc9700#section-2.1.1), [4.5](https://www.rfc-editor.org/rfc/rfc9700#section-4.5) | Prevent authorization code injection, with PKCE or the OpenID Connect nonce | **Meets, configurable** | PKCE is on unless a provider sets `pkce: false` ([`pkceEnabled`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/services/oauthService.ts#L131-L133)). The verifier is derived per sign-in on the server and never leaves it, so a code stolen from one sign-in fails the exchange in another. An OpenID Connect provider also gets a nonce, signed into the state and checked against the verified ID token ([`verifyOAuthIdToken`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/services/oauthService.ts#L485-L487)). A provider with `pkce: false` and no `issuer` or `jwksUri` has neither defence. Set `pkce: false` only for a provider that refuses PKCE. | +| [2.1.1](https://www.rfc-editor.org/rfc/rfc9700#section-2.1.1) | Use the `S256` PKCE challenge method | **Meets** | Only `S256` is sent ([`buildOAuthAuthorizationUrl`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/services/oauthService.ts#L344-L347)). | +| [2.1.2](https://www.rfc-editor.org/rfc/rfc9700#section-2.1.2), [4.6](https://www.rfc-editor.org/rfc/rfc9700#section-4.6) | Do not use the implicit grant, or any response type that returns an access token in the authorization response | **Meets** | The authorization request always uses `response_type=code` ([`buildOAuthAuthorizationUrl`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/services/oauthService.ts#L331)). Tokens come only from the token endpoint, over a back-channel request from the API. | +| [2.4](https://www.rfc-editor.org/rfc/rfc9700#section-2.4) | Do not use the resource owner password credentials grant | **Not used** | The only grant sent to a provider is `authorization_code` ([`exchangeOAuthTokens`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/services/oauthService.ts#L378-L384)). Seamless Auth has no password flow. | +| [4.11](https://www.rfc-editor.org/rfc/rfc9700#section-4.11) | Do not expose an open redirector | **Meets** | A requested `redirectUri` must exactly match the provider's `redirectUris`, or, when a provider lists none, share an origin with a configured origin ([`allowedRedirect`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/lib/redirectAllowlist.ts#L40-L48)). `returnTo` must share an origin with a configured origin ([`allowedReturnTo`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/controllers/oauth.ts#L37-L46)), and is read back from the signed state at the callback rather than from the callback request ([`finishOAuthLogin`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/controllers/oauth.ts#L171-L177)). Configure exact `redirectUris` in production. | +| [4.6](https://www.rfc-editor.org/rfc/rfc9700#section-4.6) | Validate ID tokens, so a token issued for another client or sign-in is not accepted | **Meets** | An OpenID Connect provider's ID token is checked for signature against its JWKS, issuer, audience (this client), expiry, issued-at, subject, `azp` when there are several audiences, and the nonce. Only asymmetric algorithms are accepted, so `none` and shared-key algorithms are refused ([`verifyOAuthIdToken`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/services/oauthService.ts#L434-L490)). | +| [4.9](https://www.rfc-editor.org/rfc/rfc9700#section-4.9) | Send an access token only to the resource it is meant for, in a header rather than a URL | **Meets** | The provider access token is sent only to the provider's configured `userInfoUrl` (and, for GitHub, the email list on the same origin), in an `Authorization: Bearer` header ([`fetchOAuthProfile`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/services/oauthService.ts#L572-L599)). An OpenID Connect provider's profile comes from the verified ID token and the access token is not used at all. | +| [4.13](https://www.rfc-editor.org/rfc/rfc9700#section-4.13) | Protect refresh tokens | **Not used** | The API reads only `access_token` and `id_token` from the provider's token response ([`exchangeOAuthTokens`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/services/oauthService.ts#L403-L415)). A provider refresh token is never kept, and the provider access token is used once during the callback, then dropped. Neither is stored, logged, or returned to the client. | +| [2.2](https://www.rfc-editor.org/rfc/rfc9700#section-2.2) | Use sender-constrained access tokens (mTLS or DPoP) | **Not used** | Provider access tokens are plain bearer tokens. Each is used once, server to server, within the callback, which leaves little window for replay. DPoP and mTLS are not supported. | +| [2.3](https://www.rfc-editor.org/rfc/rfc9700#section-2.3) | Request only the privileges the client needs | **Operator setting** | The scopes sent are the provider's configured `scopes` ([`buildOAuthAuthorizationUrl`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/services/oauthService.ts#L336-L338)). The API needs a subject and a verified email, so `openid email` (or the provider's equivalent, such as `user:email` on GitHub) is enough. | +| [2.5](https://www.rfc-editor.org/rfc/rfc9700#section-2.5) | Authenticate the client, preferably with an asymmetric method (mTLS or `private_key_jwt`) | **Partial** | The client authenticates with `client_secret_post` ([`exchangeOAuthTokens`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/services/oauthService.ts#L372-L384)). The secret is read from the environment variable the provider's `clientSecretEnv` names, never stored in `system_config`, and never returned by the API. Asymmetric client authentication is not supported. | +| [4.2](https://www.rfc-editor.org/rfc/rfc9700#section-4.2), [4.3](https://www.rfc-editor.org/rfc/rfc9700#section-4.3) | Keep the authorization code and state out of `Referer` headers and browser history | **Application** | The provider redirects to the application's own callback page, which then posts `code` and `state` to its backend. That page is not served by the API, so this is up to the application: load no third-party content on it and leave the URL once the sign-in is finished. The code is single use at the provider and, with PKCE, useless without the verifier only the API holds. | +| [4.1](https://www.rfc-editor.org/rfc/rfc9700#section-4.1) | Use TLS for every endpoint of the flow | **Operator setting** | Provider `authorizationUrl`, `tokenUrl`, `userInfoUrl`, `issuer` and `jwksUri` must be URLs, but `http` is not refused ([provider schema](https://github.com/fells-code/seamless-auth-types/blob/v0.28.0/src/schemas/systemConfig/schema.ts#L55-L76)). Configure `https` endpoints, which every public provider uses. | +| [4.7](https://www.rfc-editor.org/rfc/rfc9700#section-4.7) | Treat a `state` as single use | **Partial**, tracked in [#414](https://github.com/fells-code/seamless-auth-api/issues/414) | A consumed state is remembered for ten minutes, but only by the API instance that consumed it ([`consumeOAuthState`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/services/oauthService.ts#L265-L281)). With several instances, another instance could accept a replayed callback inside that window. The state is still signed and expiring, and the provider's code is single use. Until #414 moves the cache to shared storage, keep OAuth callbacks on one instance. | + + + +--- + +## Authorization Server Recommendations + +These apply to an authorization server. The Seamless Auth API does not act as one until the +OpenID Provider planned for v0.21, so none of them is scored yet. They are listed so the v0.21 +work is checked against them. + +| RFC 9700 | Recommendation | Status | +| --- | --- | --- | +| [2.1](https://www.rfc-editor.org/rfc/rfc9700#section-2.1), [4.1](https://www.rfc-editor.org/rfc/rfc9700#section-4.1) | Match redirect URIs exactly against the registered value | Applies from v0.21 | +| [2.1.1](https://www.rfc-editor.org/rfc/rfc9700#section-2.1.1), [4.8](https://www.rfc-editor.org/rfc/rfc9700#section-4.8) | Support PKCE, enforce it, and prevent PKCE downgrade | Applies from v0.21 | +| [2.1](https://www.rfc-editor.org/rfc/rfc9700#section-2.1), [4.4](https://www.rfc-editor.org/rfc/rfc9700#section-4.4) | Return `iss` in the authorization response ([RFC 9207](https://www.rfc-editor.org/rfc/rfc9207)) | Applies from v0.21 | +| [2.1.2](https://www.rfc-editor.org/rfc/rfc9700#section-2.1.2), [2.4](https://www.rfc-editor.org/rfc/rfc9700#section-2.4) | Do not offer the implicit or resource owner password credentials grants | Applies from v0.21 | +| [2.2](https://www.rfc-editor.org/rfc/rfc9700#section-2.2), [4.13](https://www.rfc-editor.org/rfc/rfc9700#section-4.13) | Sender-constrain or rotate refresh tokens for public clients | Applies from v0.21 | +| [2.3](https://www.rfc-editor.org/rfc/rfc9700#section-2.3), [4.10](https://www.rfc-editor.org/rfc/rfc9700#section-4.10) | Restrict access tokens to an audience and to the privileges granted | Applies from v0.21 | +| [2.5](https://www.rfc-editor.org/rfc/rfc9700#section-2.5) | Authenticate confidential clients, preferably with asymmetric methods | Applies from v0.21 | +| [4.11](https://www.rfc-editor.org/rfc/rfc9700#section-4.11), [4.12](https://www.rfc-editor.org/rfc/rfc9700#section-4.12) | Do not act as an open redirector, and do not redirect with HTTP 307 | Applies from v0.21 | +| [4.14](https://www.rfc-editor.org/rfc/rfc9700#section-4.14) | Keep clients from choosing a `client_id` that impersonates a resource owner | Applies from v0.21 | +| [4.15](https://www.rfc-editor.org/rfc/rfc9700#section-4.15) | Prevent clickjacking of the authorization page | Applies from v0.21 | +| [4.16](https://www.rfc-editor.org/rfc/rfc9700#section-4.16) | Do not redirect to a client after a failed authentication without the user's interaction | Applies from v0.21 | +| [2.6](https://www.rfc-editor.org/rfc/rfc9700#section-2.6) | Do not support CORS on the authorization endpoint | Applies from v0.21 | + +The Seamless Auth session itself (its access token, single-use refresh token rotation, and reuse +detection) is not an OAuth grant and is described in +[Security Posture](/understand/security#session-management). + +--- + +## Related Docs + +- [Security Posture and Threat Model](/understand/security) +- [OAuth Login](/build/oauth-login) +- [RFC 9700: Best Current Practice for OAuth 2.0 Security](https://www.rfc-editor.org/rfc/rfc9700) diff --git a/src/content/docs/understand/security.mdx b/src/content/docs/understand/security.mdx index 2e811cb..8872136 100644 --- a/src/content/docs/understand/security.mdx +++ b/src/content/docs/understand/security.mdx @@ -61,7 +61,7 @@ no `exp` or an `exp` more than an hour and a minute away | Account enumeration on `/login` | An unknown identifier gets a signed decoy pre-auth token, and every endpoint that accepts a pre-auth token answers a decoy the way it answers a real account. `defineRoute` refuses to register an `auth: 'ephemeral'` route without a decoy responder ([`defineRoute.ts`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/lib/defineRoute.ts)), and `/login` holds every pre-auth answer, real or decoy, to a minimum time, `LOGIN_RESPONSE_FLOOR_MS` (default 250 ms) ([`authentication.ts`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/controllers/authentication.ts)). | | Stolen refresh tokens | Single-use rotation with reuse detection that revokes the session chain (see [Session Management](#session-management)). | | Token confusion | Every JWT is verified for `RS256`, issuer, audience and `typ`, so an access token is not accepted where a pre-auth token is expected, or the reverse ([`sessionService.ts`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/services/sessionService.ts)). | -| OAuth account takeover by unverified email | Linking to or creating an account from an OAuth profile requires the provider to assert the email is verified ([`oauthService.ts`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/services/oauthService.ts)). OAuth `state` is HMAC signed, expires after ten minutes and is refused on reuse, and PKCE is on unless a provider sets `pkce: false`. | +| OAuth account takeover by unverified email | Linking to or creating an account from an OAuth profile requires the provider to assert the email is verified ([`oauthService.ts`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/services/oauthService.ts)). OAuth `state` is HMAC signed, expires after ten minutes and is refused on reuse, and PKCE is on unless a provider sets `pkce: false`. The state is not yet bound to the client that started the sign-in ([#463](https://github.com/fells-code/seamless-auth-api/issues/463)); see [OAuth Client and RFC 9700](/understand/oauth-rfc9700) for every client recommendation. | | Requests from disallowed browser origins | A request whose `Origin` is not allowlisted is refused with `403` before the route runs, and recorded as `request_suspicious` ([`app.ts`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/app.ts)). | | Tampering with the audit trail | Hash-chained, append-only `auth_events` enforced by database triggers (see [Audit Logging](#audit-logging)). | | Running a development build in public | The server refuses to start with `NODE_ENV` other than `production` when `ISSUER` or `APP_ORIGINS` names a non-local host, unless `SEAMLESS_ALLOW_INSECURE_DEV=true` is set on purpose ([`devModeGuard.ts`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/config/devModeGuard.ts)). Development mode is what enables on-disk signing keys, guessable fallback secrets, `/docs`, and the switch that disables rate limits. | @@ -396,6 +396,7 @@ Source code checks run in CI: ## Related Docs +- [OAuth Client and RFC 9700](/understand/oauth-rfc9700) - [Session Model](/understand/sessions) - [Auth Events and Auditing](/understand/authevents) - [Cookies, Sessions, and Tokens](/reference/cookies-sessions-tokens)