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
101 changes: 101 additions & 0 deletions src/content/docs/understand/oauth-rfc9700.mdx
Original file line number Diff line number Diff line change
@@ -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 `<first origin>/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. |

<Aside type="caution">
The CSRF row is a gap in v0.19.1. [#463](https://github.com/fells-code/seamless-auth-api/issues/463)
binds the state to the client that started the sign-in. It needs the matching
`@seamless-auth/client`, `@seamless-auth/react` or `@seamless-auth/react-native` release, which
keeps the binding and sends it with the callback. This page will link the fixed code at its
release tag once it ships.
</Aside>

---

## 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)
Loading
Loading