Skip to content

Commit 4a32867

Browse files
authored
Merge pull request #129 from fells-code/docs/rfc9700-client-matrix
docs: add the RFC 9700 conformance matrix for the OAuth client
2 parents a831ede + ba1dbcf commit 4a32867

2 files changed

Lines changed: 103 additions & 1 deletion

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
---
2+
title: 'OAuth Client and RFC 9700'
3+
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.'
4+
sidebar:
5+
order: 6
6+
---
7+
8+
import { Aside } from '@astrojs/starlight/components';
9+
10+
[RFC 9700](https://www.rfc-editor.org/rfc/rfc9700) is the OAuth 2.0 Security Best Current
11+
Practice. It gives recommendations for clients, for authorization servers, and for resource
12+
servers. This page checks the Seamless Auth API against the ones that apply to it today.
13+
14+
Today the Seamless Auth API is an OAuth **client** only. When a provider is configured (Google,
15+
GitHub, Microsoft Entra ID, or any OAuth 2.0 or OpenID Connect provider, see
16+
[OAuth Login](/build/oauth-login)), the API sends the user to that provider, exchanges the
17+
authorization code, reads the profile, and then issues its own Seamless Auth session. It is not an
18+
authorization server for other applications until the OpenID Provider planned for v0.21, so the
19+
authorization server recommendations are listed below as "applies from v0.21", not as passes or
20+
gaps.
21+
22+
Every code link points at the
23+
[v0.19.1](https://github.com/fells-code/seamless-auth-api/tree/v0.19.1) tag of the API. The flow
24+
lives in [`oauthService.ts`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/services/oauthService.ts),
25+
[`oauth.ts`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/controllers/oauth.ts)
26+
(the controller), and
27+
[`oauth.routes.ts`](https://github.com/fells-code/seamless-auth-api/blob/v0.19.1/src/routes/oauth.routes.ts).
28+
29+
Status values:
30+
31+
- **Meets**: the recommendation holds by default.
32+
- **Meets, configurable**: it holds by default, and an operator setting can turn it off.
33+
- **Partial**: some of the recommendation holds, and the row says which part does not.
34+
- **Gap**: it does not hold in v0.19.1. The row links the issue that tracks the fix.
35+
- **Not used**: the API does not use the feature the recommendation is about.
36+
37+
---
38+
39+
## Client Recommendations
40+
41+
| RFC 9700 | Recommendation | Status | How, in v0.19.1 |
42+
| --- | --- | --- | --- |
43+
| [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. |
44+
| [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. |
45+
| [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. |
46+
| [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)). |
47+
| [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. |
48+
| [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. |
49+
| [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. |
50+
| [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)). |
51+
| [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. |
52+
| [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. |
53+
| [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. |
54+
| [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. |
55+
| [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. |
56+
| [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. |
57+
| [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. |
58+
| [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. |
59+
60+
<Aside type="caution">
61+
The CSRF row is a gap in v0.19.1. [#463](https://github.com/fells-code/seamless-auth-api/issues/463)
62+
binds the state to the client that started the sign-in. It needs the matching
63+
`@seamless-auth/client`, `@seamless-auth/react` or `@seamless-auth/react-native` release, which
64+
keeps the binding and sends it with the callback. This page will link the fixed code at its
65+
release tag once it ships.
66+
</Aside>
67+
68+
---
69+
70+
## Authorization Server Recommendations
71+
72+
These apply to an authorization server. The Seamless Auth API does not act as one until the
73+
OpenID Provider planned for v0.21, so none of them is scored yet. They are listed so the v0.21
74+
work is checked against them.
75+
76+
| RFC 9700 | Recommendation | Status |
77+
| --- | --- | --- |
78+
| [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 |
79+
| [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 |
80+
| [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 |
81+
| [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 |
82+
| [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 |
83+
| [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 |
84+
| [2.5](https://www.rfc-editor.org/rfc/rfc9700#section-2.5) | Authenticate confidential clients, preferably with asymmetric methods | Applies from v0.21 |
85+
| [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 |
86+
| [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 |
87+
| [4.15](https://www.rfc-editor.org/rfc/rfc9700#section-4.15) | Prevent clickjacking of the authorization page | Applies from v0.21 |
88+
| [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 |
89+
| [2.6](https://www.rfc-editor.org/rfc/rfc9700#section-2.6) | Do not support CORS on the authorization endpoint | Applies from v0.21 |
90+
91+
The Seamless Auth session itself (its access token, single-use refresh token rotation, and reuse
92+
detection) is not an OAuth grant and is described in
93+
[Security Posture](/understand/security#session-management).
94+
95+
---
96+
97+
## Related Docs
98+
99+
- [Security Posture and Threat Model](/understand/security)
100+
- [OAuth Login](/build/oauth-login)
101+
- [RFC 9700: Best Current Practice for OAuth 2.0 Security](https://www.rfc-editor.org/rfc/rfc9700)

0 commit comments

Comments
 (0)