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)