From 873d6102b01fc3125f364f58e977254d89317898 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 20 Oct 2026 22:52:09 -0400 Subject: [PATCH] docs: point the RFC 9700 matrix at v0.19.2 and record the state binding The OAuth state is bound to the client that started the sign-in from 0.19.2 (#463), so the CSRF row moves from a gap to met, and every code link moves to the v0.19.2 tag. --- src/content/docs/understand/oauth-rfc9700.mdx | 54 +++++++++---------- src/content/docs/understand/security.mdx | 2 +- 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/src/content/docs/understand/oauth-rfc9700.mdx b/src/content/docs/understand/oauth-rfc9700.mdx index 3d7c1b5..9645166 100644 --- a/src/content/docs/understand/oauth-rfc9700.mdx +++ b/src/content/docs/understand/oauth-rfc9700.mdx @@ -1,6 +1,6 @@ --- 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.' +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.2.' sidebar: order: 6 --- @@ -20,49 +20,47 @@ authorization server recommendations are listed below as "applies from v0.21", n 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) +[v0.19.2](https://github.com/fells-code/seamless-auth-api/tree/v0.19.2) tag of the API. The flow +lives in [`oauthService.ts`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.2/src/services/oauthService.ts), +[`oauth.ts`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.2/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). +[`oauth.routes.ts`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.2/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 | +| RFC 9700 | Recommendation | Status | How, in v0.19.2 | | --- | --- | --- | --- | -| [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.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 | **Meets, configurable**, since 0.19.2 | `/start` returns a random `binding` to the client that asked, never to the provider, and signs only its SHA-256 into the state ([`createOAuthState`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.2/src/services/oauthService.ts#L263-L281)). The callback must present it, compared in constant time before the replay cache records the state ([`checkOAuthStateBinding`, `consumeOAuthState`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.2/src/services/oauthService.ts#L305-L346)), so a state and code taken from one sign-in cannot be finished in another browser. The state is also HMAC-SHA256 signed, expires after ten minutes, and is refused on reuse. The client SDKs keep and send the binding (`@seamless-auth/client` 0.7.1 and later). `OAUTH_ALLOW_UNBOUND_STATE=true` accepts a callback with no binding while older clients are upgraded; it reopens this gap while set, warns at boot, and is removed in v0.20 ([`unboundOAuthStateAllowed`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.2/src/services/oauthService.ts#L139-L157)). Version 0.19.1 and earlier did not bind the state ([#463](https://github.com/fells-code/seamless-auth-api/issues/463)). | +| [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.2/src/services/oauthService.ts#L283-L303)). 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.29.0/src/schemas/oauth/schema.ts#L48-L53)). 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.2/src/services/oauthService.ts#L227-L247)). 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.2/src/services/oauthService.ts#L159-L161)). 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.2/src/services/oauthService.ts#L550-L552)). 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.2/src/services/oauthService.ts#L409-L412)). | +| [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.2/src/services/oauthService.ts#L396)). 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.2/src/services/oauthService.ts#L443-L449)). 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.2/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.2/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.2/src/controllers/oauth.ts#L178-L184)). 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.2/src/services/oauthService.ts#L499-L555)). | +| [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.2/src/services/oauthService.ts#L637-L664)). 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.2/src/services/oauthService.ts#L468-L480)). 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. | +| [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.2/src/services/oauthService.ts#L401-L403)). 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.2/src/services/oauthService.ts#L437-L449)). 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. | - -