@cloudflare/workers-oauth-provider 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -484
- package/dist/oauth-provider.d.ts +296 -179
- package/dist/oauth-provider.js +748 -501
- package/docs/advanced-configuration.md +14 -7
- package/docs/authorization-server.md +232 -0
- package/docs/consent-page.md +14 -19
- package/docs/mcp-discovery.md +78 -0
- package/docs/migration-1.0.md +248 -43
- package/docs/resource-servers.md +27 -4
- package/docs/upstream-sign-in.md +2 -7
- package/package.json +3 -1
- package/skills/migrate-to-1.0/SKILL.md +38 -24
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# Advanced configuration
|
|
2
2
|
|
|
3
|
-
This guide covers features that are useful for proxying another authorization system, changing token data during exchange, or operating the provider over time. Start with the
|
|
3
|
+
This guide covers features that are useful for proxying another authorization system, changing token data during exchange, or operating the provider over time. Start with the [README](../README.md) for a minimal deployment, [MCP authorization discovery](mcp-discovery.md), and the [authorization server reference](authorization-server.md) for client registration.
|
|
4
4
|
|
|
5
5
|
## Token exchange callback
|
|
6
6
|
|
|
7
7
|
`tokenExchangeCallback` runs during authorization code and refresh token exchanges. It is useful when the Worker also acts as an OAuth client to an upstream service.
|
|
8
8
|
|
|
9
|
+
The callback receives the request's `env`, so it can reach secrets and bindings (an upstream client secret, say) while the provider itself is built once at module scope.
|
|
10
|
+
|
|
9
11
|
```ts
|
|
10
12
|
new OAuthProvider({
|
|
11
13
|
// Other options...
|
|
@@ -26,7 +28,7 @@ new OAuthProvider({
|
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
if (options.grantType === 'refresh_token') {
|
|
29
|
-
const upstream = await refreshUpstream(options.props.upstreamRefreshToken);
|
|
31
|
+
const upstream = await refreshUpstream(options.props.upstreamRefreshToken, options.env.UPSTREAM_CLIENT_SECRET);
|
|
30
32
|
return {
|
|
31
33
|
accessTokenProps: {
|
|
32
34
|
...options.props,
|
|
@@ -97,6 +99,8 @@ tokenExchangeCallback: (options) => {
|
|
|
97
99
|
},
|
|
98
100
|
```
|
|
99
101
|
|
|
102
|
+
The exchanged token is issued to the requesting client (RFC 8693): `ctx.auth.clientId` names it, and it can revoke the token. The token still belongs to the subject's grant, so revoking that grant revokes it too.
|
|
103
|
+
|
|
100
104
|
## Enterprise-managed authorization
|
|
101
105
|
|
|
102
106
|
**Experimental.** The [MCP Enterprise-Managed Authorization extension](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization) is itself young, so the `enterpriseManagedAuthorization` option and its exported types (`EmaValidationError`, trust-policy shapes) are exempt from 1.x semver: they may change in a minor release, with the change documented in the changelog. Everything else on this page is stable API.
|
|
@@ -233,7 +237,7 @@ Both classes accept a public `description`, `statusCode`, and response `headers`
|
|
|
233
237
|
|
|
234
238
|
A refresh rotates the token. The newly issued token and the immediately previous token can both recover a refresh whose response was lost. Once the new token is used, the previous token is invalidated and another token is issued.
|
|
235
239
|
|
|
236
|
-
Per-token `accessTokenTTL` and `refreshTokenTTL` overrides are available through `tokenExchangeCallback`.
|
|
240
|
+
Per-token `accessTokenTTL` and `refreshTokenTTL` overrides are available through `tokenExchangeCallback`. Each lifetime a callback returns applies where it belongs and is ignored elsewhere, so one callback can return all of them for every grant type: `refreshTokenTTL` at code exchange (`0` for no refresh token, otherwise at least 60 seconds), `refreshTokenIdleTTL` on refresh. In a callback result `undefined` means not set, never no expiry.
|
|
237
241
|
|
|
238
242
|
### Sliding expiry
|
|
239
243
|
|
|
@@ -261,13 +265,16 @@ export default {
|
|
|
261
265
|
return provider.fetch(request, env, ctx);
|
|
262
266
|
},
|
|
263
267
|
async scheduled(_event, env) {
|
|
264
|
-
|
|
265
|
-
|
|
268
|
+
// Each run checks up to batchSize grants, then tokens, and returns where it stopped.
|
|
269
|
+
const cursor = (await env.OAUTH_KV.get('purge-cursor')) ?? undefined;
|
|
270
|
+
const result = await provider.purgeExpiredData(env, { batchSize: 100, cursor });
|
|
271
|
+
if (result.cursor) await env.OAUTH_KV.put('purge-cursor', result.cursor);
|
|
272
|
+
else await env.OAUTH_KV.delete('purge-cursor'); // done: the next run starts a new sweep
|
|
266
273
|
},
|
|
267
274
|
};
|
|
268
275
|
```
|
|
269
276
|
|
|
270
|
-
The default batch size is 50. `result.done`
|
|
277
|
+
The default batch size is 50. Pass each result's `cursor` to the next invocation: without it, every run starts again from the first `batchSize` grants and never reaches the rest. `result.done` is true, with no `cursor`, once the sweep has covered both key spaces.
|
|
271
278
|
|
|
272
279
|
Deleting a client through `OAuthHelpers.deleteClient()` also revokes its grants and associated tokens across users.
|
|
273
280
|
|
|
@@ -294,7 +301,7 @@ Use either `apiHandlers` or `apiRoute` plus `apiHandler`, not both. Routes can b
|
|
|
294
301
|
|
|
295
302
|
## External token resolution
|
|
296
303
|
|
|
297
|
-
`resolveExternalToken` accepts a bearer credential that was not issued or stored by this provider. It runs only after the internal token lookup fails. The credential can be an external OAuth access token, opaque API key, or personal access token (PAT).
|
|
304
|
+
`resolveExternalToken` accepts a bearer credential that was not issued or stored by this provider. It runs only after the internal token lookup fails, and never for a token in this provider's own `userId:grantId:secret` format: an expired or revoked token we issued is answered `invalid_token` directly, so it is never forwarded to an external validator. The credential can be an external OAuth access token, opaque API key, or personal access token (PAT).
|
|
298
305
|
|
|
299
306
|
### MCP compatibility warning
|
|
300
307
|
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# Authorization server reference
|
|
2
|
+
|
|
3
|
+
The authorization endpoint, client registration, tokens, resources, scopes, storage, and every option. For a runnable starting point see [`examples/split-workers`](../examples/split-workers).
|
|
4
|
+
|
|
5
|
+
## Authorization endpoint
|
|
6
|
+
|
|
7
|
+
Your `authorizeEndpoint` is application code, because user authentication and consent are application-specific. With split roles it is the `/authorize` route in your authorization server's `fetch`, using `authorizationServer.getOAuthApi(env)`; with `OAuthProvider` it lives in `defaultHandler`, using `env.OAUTH_PROVIDER`. The helpers are the same. The provider is not an identity provider.
|
|
8
|
+
|
|
9
|
+
`OAuthAuthorizationServer` advertises the endpoint at `${issuer}/authorize` and serves its token endpoint at `${issuer}/oauth/token`, under the issuer's path if it has one; set `authorizeEndpoint` or `tokenEndpoint` to use other paths. Route the authorization endpoint before calling `authorizationServer.fetch()`. `parseAuthRequest()` rejects a request that arrives anywhere but the advertised endpoint, so a route on the wrong path fails on its first request. `OAuthProvider` requires both options.
|
|
10
|
+
|
|
11
|
+
A typical flow has three steps:
|
|
12
|
+
|
|
13
|
+
1. Call `parseAuthRequest(request)` to validate the client, redirect URI, response type, resource, and PKCE restrictions.
|
|
14
|
+
2. Authenticate the user, show consent, and decide which scopes to grant.
|
|
15
|
+
3. Call `completeAuthorization()` and redirect to its returned `redirectTo` URL.
|
|
16
|
+
|
|
17
|
+
[docs/consent-page.md](consent-page.md) shows a safe consent page (what it must display, escaping client metadata, Allow and Deny) and which errors to redirect back to the client and which to render.
|
|
18
|
+
|
|
19
|
+
`parseAuthRequest()` throws an exported `AuthorizationError` for expected request validation failures. Its optional `redirectUri` is present only after the client and exact registered redirect URI have been validated. Without it, render the error locally and never redirect. With it, the application can safely construct an OAuth error redirect using the error's `code`, `description`, original `state`, and RFC 9207 `issuer`, as shown in [`examples/split-workers`](../examples/split-workers/auth-server/index.ts).
|
|
20
|
+
|
|
21
|
+
`completeAuthorization()` repeats response-type validation before writing a grant or revoking existing grants. Validation errors from reconstructed requests are also typed as `AuthorizationError`, but applications should not construct redirects from untrusted reconstructed values; the redirect context is attached only by `parseAuthRequest()`.
|
|
22
|
+
|
|
23
|
+
`completeAuthorization()` stores a new grant and, by default, revokes existing grants for the same user, client, and resource after the new grant is safely stored. A grant for another registered resource is a separate authorization and is not revoked. Set `revokeExistingGrants: false` only when the application intentionally allows concurrent grants within the same resource.
|
|
24
|
+
|
|
25
|
+
For Client ID Metadata Document clients, whose client_id is the metadata URL shared by every installation, default revocation is additionally scoped to grants created from the same redirect URI, so one installation's re-authorization does not revoke another's. Grants created before the redirect URI was recorded are never auto-revoked by CIMD clients.
|
|
26
|
+
|
|
27
|
+
### Authorization response issuer
|
|
28
|
+
|
|
29
|
+
RFC 9207 issuer identification is always enabled. Authorization server metadata advertises `authorization_response_iss_parameter_supported: true`, and successful authorization responses include `iss` automatically.
|
|
30
|
+
|
|
31
|
+
Error redirects carry it too. `AuthorizationError.redirectTo` is ready-made for `parseAuthRequest()` failures, and `authorizationErrorRedirect()` builds one for an error your application decides on, from a request the library validated:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
const oauthRequest = await env.OAUTH_PROVIDER.parseAuthRequest(request);
|
|
35
|
+
// …the user declined:
|
|
36
|
+
return Response.redirect(authorizationErrorRedirect(oauthRequest, 'access_denied'), 302);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Intermediate identity-provider redirects and local HTML error pages do not need the OAuth `iss` parameter.
|
|
40
|
+
|
|
41
|
+
## Client registration
|
|
42
|
+
|
|
43
|
+
[MCP client registration](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration) defines three ways for a client to obtain a client ID. Clients that support all three prefer pre-registration, then CIMD, then DCR.
|
|
44
|
+
|
|
45
|
+
### Pre-registered clients
|
|
46
|
+
|
|
47
|
+
Use `OAuthHelpers.createClient()` to create clients through application or administrative code. These clients are stored in KV and are not subject to `clientRegistrationTTL`.
|
|
48
|
+
|
|
49
|
+
### Client ID Metadata Documents
|
|
50
|
+
|
|
51
|
+
CIMD lets a client use an HTTPS URL with a non-root path as its `client_id`. That URL serves a JSON metadata document describing the client and its redirect URIs.
|
|
52
|
+
|
|
53
|
+
Enable it in both places:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
new OAuthProvider({
|
|
57
|
+
// Other options...
|
|
58
|
+
clientIdMetadataDocumentEnabled: true,
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```jsonc
|
|
63
|
+
{
|
|
64
|
+
"compatibility_flags": ["global_fetch_strictly_public"],
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The compatibility flag prevents outbound CIMD fetches from using legacy same-zone origin routing, which is necessary for SSRF protection. The provider advertises `client_id_metadata_document_supported: true` only when both settings are present. CIMD fetches also use the `cache` option of `fetch`, which requires a compatibility date of `2024-11-11` or later (or the `cache_option_enabled` compatibility flag).
|
|
69
|
+
|
|
70
|
+
CIMD validation follows [draft-ietf-oauth-client-id-metadata-document-00](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00) — the revision pinned by the MCP 2026-07-28 authorization spec — and includes:
|
|
71
|
+
|
|
72
|
+
- An HTTPS Client Identifier URL with a path component and no userinfo, fragment, or dot path segments.
|
|
73
|
+
- A document `client_id` exactly matching its URL.
|
|
74
|
+
- Non-empty `client_name` and `redirect_uris` fields, as MCP requires. Redirect URIs must use `https`, or `http` on a loopback host (`localhost`, `127.0.0.0/8`, `::1`), with no userinfo or fragment. Every authorization request is held to it, so older clients are too. A client may register, or list in its CIMD document, other redirect URIs next to a compliant one, because it signs in from several places: Cursor registers `cursor://anysphere.cursor-mcp/oauth/callback` beside its https and loopback callbacks. Those are stored, and refused whenever a request uses one. Dynamic registration, `createClient()` and `updateClient()` need at least one compliant redirect URI, and refuse any with a dangerous scheme such as `javascript:`, a fragment or userinfo; a dangerous scheme also rejects a whole CIMD document. Native apps using RFC 8252 private-use schemes (`com.example.app:/cb`) need `allowPrivateUseRedirectUris: true`; remote `http` is never accepted.
|
|
75
|
+
- Exact authorization-request redirect URI validation, with RFC 8252 loopback port handling.
|
|
76
|
+
- A 5 KB response size limit and a 10 second timeout covering both headers and body.
|
|
77
|
+
- Valid UTF-8 JSON object syntax and safe URI schemes for client metadata fields.
|
|
78
|
+
- No embedded client secrets or private JWK material.
|
|
79
|
+
|
|
80
|
+
Validated documents are cached according to their `Cache-Control` headers, capped at 7 days. Error responses and invalid documents are never cached, and a cached document that stops validating is evicted and re-resolved from origin within the same request.
|
|
81
|
+
|
|
82
|
+
CIMD token endpoint authentication is negotiated from `token_endpoint_auth_method` and the OpenID RP Metadata Choices field `token_endpoint_auth_methods_supported`. The provider currently implements only `none`: a client may prefer `private_key_jwt` while also offering `none`, in which case the provider selects `none` and applies public-client PKCE requirements. A client that offers only `private_key_jwt` is rejected until assertion validation is implemented.
|
|
83
|
+
|
|
84
|
+
When a CIMD document cannot be fetched or validated, the token endpoint returns a generic `invalid_client` response and reports diagnostics through `onError.internal`. `OAuthHelpers` methods that resolve a CIMD client throw the exported `CimdFetchError`, allowing applications to distinguish an upstream metadata failure from a client that does not exist. See [Advanced configuration](advanced-configuration.md#cimd-fetch-errors) for an example.
|
|
85
|
+
|
|
86
|
+
### Dynamic Client Registration
|
|
87
|
+
|
|
88
|
+
Set `clientRegistrationEndpoint` to enable RFC 7591 Dynamic Client Registration:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
clientRegistrationEndpoint: '/oauth/register';
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
MCP 2026-07-28 deprecates DCR for new implementations in favor of CIMD. The endpoint remains useful for compatibility with clients that do not support CIMD.
|
|
95
|
+
|
|
96
|
+
Registration accepts only authentication methods, grants, and response types implemented by the configured provider, and rejects inconsistent grant/response combinations before storage. Choice-valued `token_endpoint_auth_methods_supported` input is negotiated to one effective `token_endpoint_auth_method`; grant and response registrations remain strict. Omitted metadata uses the RFC 7591 defaults: `client_secret_basic`, `grant_types: ["authorization_code"]`, and `response_types: ["code"]`. The token endpoint enforces each client's registered grant types with `unauthorized_client`; `refresh_token` is implied by `authorization_code`, and a client must register `urn:ietf:params:oauth:grant-type:token-exchange` to use token exchange.
|
|
97
|
+
|
|
98
|
+
The effective `token_endpoint_auth_method` returned by registration is enforced exactly. When both authentication metadata fields are omitted, no explicit-method marker is stored and the client may use either `client_secret_basic` or `client_secret_post`, provided the same stored secret validates. Client records written by earlier releases have no marker and receive the same compatibility. This never crosses between `none` and a secret method and does not apply to CIMD clients.
|
|
99
|
+
|
|
100
|
+
Calling `OAuthHelpers.updateClient()` with `tokenEndpointAuthMethod` adds the marker; unrelated updates leave it unchanged.
|
|
101
|
+
|
|
102
|
+
Related options:
|
|
103
|
+
|
|
104
|
+
- `clientRegistrationTTL` controls the lifetime of dynamically registered clients. The default is 90 days. A registration still in use does not expire: once it has passed half its lifetime, the next successful token request renews it for the full TTL, so a client that keeps refreshing keeps its `client_id` while an abandoned one is cleaned up. The `client_secret_expires_at` returned at registration describes the initial lifetime; there is no channel to report a renewal, so a client that honours it re-registers on that schedule as before.
|
|
105
|
+
- `disallowPublicClientRegistration` takes `none` out of DCR negotiation: a client that also supports a secret method is registered with it, and a client that supports only `none` is refused (`onError` reason `public_client_forbidden`).
|
|
106
|
+
- `clientRegistrationCallback` can allow or reject registration based on application policy.
|
|
107
|
+
|
|
108
|
+
Clients created by `OAuthHelpers.createClient()` are not affected by the DCR TTL or public-registration restriction.
|
|
109
|
+
|
|
110
|
+
## PKCE and token lifecycle
|
|
111
|
+
|
|
112
|
+
Public clients must use PKCE with the authorization code flow, and only the S256 method is accepted, as MCP requires. Confidential clients may still omit PKCE.
|
|
113
|
+
|
|
114
|
+
The implicit grant (`response_type=token`) is not supported: OAuth 2.1 removed it, and MCP clients use the authorization code flow with PKCE.
|
|
115
|
+
|
|
116
|
+
The provider owns `tokenEndpoint`. It exchanges authorization codes for tokens, refreshes access tokens, and handles RFC 7009 revocation. Refresh tokens rotate on use. The immediately previous token remains valid until its replacement is first used, allowing a client to retry after losing a refresh response.
|
|
117
|
+
|
|
118
|
+
A grant expires `refreshTokenTTL` seconds after the code exchange (30 days by default) however often it is refreshed. Set `refreshTokenIdleTTL` to make that lifetime slide instead: each successful refresh moves the expiry to that many seconds later, so a grant lives while the client keeps using it and expires once idle. `tokenExchangeCallback` can return `refreshTokenIdleTTL` to set the lifetime for one refresh, which lets a Worker that proxies an upstream OAuth service match the lifetime of the upstream refresh token it just rotated. See [Advanced configuration](advanced-configuration.md#token-and-client-lifetimes).
|
|
119
|
+
|
|
120
|
+
## Resources and token audiences
|
|
121
|
+
|
|
122
|
+
An authorization server may register one or more protected resources. Each resource has one canonical `resourceMetadata.resource`: an absolute HTTPS URI without a fragment, with lowercase `https` and a lowercase host, and an RFC 3986-safe producer serialization. Userinfo, default ports, dot-segment paths, and an empty path before a query are rejected because `Request` would rewrite them before RFC 9728 comparison. A bare origin is the only empty-path exception; use `/` before a query. Query components are supported but discouraged by RFC 9728.
|
|
123
|
+
|
|
124
|
+
For local development, `http` is accepted for resources, `authorization_servers`, the explicit `OAuthAuthorizationServer` issuer, and absolute endpoint URLs only when the host is a loopback address (`localhost`, `127.0.0.0/8`, `::1`), so `wrangler dev` works at `http://localhost:8787`. Any other host must use `https`: Workers are always served over `https`, and OAuth 2.1 requires it. A local MCP client's loopback redirect URI is unaffected by this rule; it is governed by the RFC 8252 loopback handling described under client registration.
|
|
125
|
+
|
|
126
|
+
Every authorization grant and access token is bound to exactly one registered resource. A central authorization server can therefore issue separate Calendar and Drive tokens from one KV namespace, but it never turns those into one multi-audience bearer token. Completing a new authorization for Drive does not replace the same user and client's Calendar grant.
|
|
127
|
+
|
|
128
|
+
Conforming MCP clients are required to send `resource` in authorization and token requests. Resource selection and compatibility work as follows:
|
|
129
|
+
|
|
130
|
+
- When the authorization server has one registered resource, that sole resource is selected if an authorization request omits `resource`. This preserves existing `OAuthProvider` behavior.
|
|
131
|
+
- When it has multiple registered resources, an authorization request must identify exactly one of them. Set `defaultResource` on `OAuthAuthorizationServer` only when older clients that omit `resource` should be routed to a deliberate compatibility default.
|
|
132
|
+
- An authorization-code or refresh-token request may omit `resource`; the server inherits the resource already stored on the grant. If present, it must match that grant and cannot retarget it.
|
|
133
|
+
- Malformed, unknown, or multi-valued resource input returns `invalid_target` before code consumption, callbacks, refresh rotation, or storage writes.
|
|
134
|
+
|
|
135
|
+
ASCII case differences in the URI scheme and host are accepted, but port, path, query, trailing slash, and array cardinality remain strict. The authorization server always stores and returns the configured lowercase scheme-and-host spelling. The token response includes the selected resource, and the access-token audience contains that resource alone.
|
|
136
|
+
|
|
137
|
+
Token exchange cannot change the resource. Both the subject-token audience and any explicit requested resource must resolve to the same registered canonical value. A token is exchanged by the client its grant was issued to unless `tokenExchangeCallback` returns `allowCrossClientExchange: true`. Internally and externally validated tokens are accepted at a protected route only when their audience matches that route's resource.
|
|
138
|
+
|
|
139
|
+
Path-aware API validation uses path-boundary prefix matching. A canonical audience for `https://example.com/mcp` covers `/mcp` and `/mcp/tools`, but not `/mcp-other`. A canonical trailing slash remains significant.
|
|
140
|
+
|
|
141
|
+
## Scopes and step-up authorization
|
|
142
|
+
|
|
143
|
+
Three places carry scopes on the wire. Two share the name `scopes_supported`, because RFC 8414 and RFC 9728 each define one, but they mean different things:
|
|
144
|
+
|
|
145
|
+
| On the wire | Standard | Means | Configure with |
|
|
146
|
+
| ------------------------------------------------ | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- |
|
|
147
|
+
| Authorization server metadata `scopes_supported` | RFC 8414 | everything this authorization server can grant, across its resources | `OAuthAuthorizationServer` (or `OAuthProvider`) `scopesSupported` |
|
|
148
|
+
| Protected resource metadata `scopes_supported` | RFC 9728; MCP: "the minimal set of scopes necessary for basic functionality" | the scopes any access needs, so clients request them first | `OAuthResourceServer` (or `OAuthProvider`) `requiredScopes` |
|
|
149
|
+
| `WWW-Authenticate: Bearer … scope="…"` | RFC 6750 | what's needed right now: the resource's baseline on a `401`, the missing scopes on a `403` | automatic on `401`; `insufficientScope(ctx.auth, scopes)` for a `403` |
|
|
150
|
+
|
|
151
|
+
With split roles the two lists live in different Workers, and the resource server never sees the authorization server's. MCP clients choose scopes from the `401` challenge or the resource's list, so keep the resource's list to the baseline and let the authorization server's list be the whole catalogue. Copying the catalogue into the resource would make every client request everything on first sign-in. `offline_access` is removed from a resource's list and challenges automatically: refresh tokens are not a resource requirement.
|
|
152
|
+
|
|
153
|
+
A request flows through them like this:
|
|
154
|
+
|
|
155
|
+
1. An unauthenticated call gets `401` with `scope="mcp:read"`, the resource's baseline.
|
|
156
|
+
2. The client authorizes with `scope=mcp:read`. Your `/authorize` decides what to grant: `completeAuthorization({ scope })` stores it, and `approveConsent()` accepts only scopes in `scopesSupported`. Requests are not otherwise filtered against the catalogue, so the application stays in charge. Token and refresh requests can only narrow the grant.
|
|
157
|
+
3. The resource sees the token's scopes as `ctx.auth.scope`. An operation that needs more answers with every scope it needs, baseline included: `insufficientScope(ctx.auth, ['mcp:read', 'mcp:write'])` gives a `403` with `error="insufficient_scope"`, `scope="mcp:read mcp:write"` and the resource's metadata URL. The client re-authorizes for those scopes. See [docs/resource-servers.md](resource-servers.md#what-the-handler-sees).
|
|
158
|
+
|
|
159
|
+
[`examples/split-workers`](../examples/split-workers) walks this whole flow, from the first `401` through a read to a write after step-up, in its end-to-end test.
|
|
160
|
+
|
|
161
|
+
Leaving a resource's `requiredScopes` unset is legitimate when your consent page, rather than the client, picks the scopes: the `401` then names none, MCP clients request none, and `/authorize` applies its own default. Either way, advertise the catalogue as `scopesSupported` so clients can discover it.
|
|
162
|
+
|
|
163
|
+
`requiredScopes` is advertised, not enforced: the handler decides whether a token is sufficient, because only it knows which scopes imply others, which MCP requires servers to account for. `requiredScopes` replaced `resourceMetadata.scopes_supported` in 1.2. The old field still works, but is deprecated: move its value to `requiredScopes`. Setting both throws at construction.
|
|
164
|
+
|
|
165
|
+
## KV storage and cleanup
|
|
166
|
+
|
|
167
|
+
Sensitive values are not stored in plaintext:
|
|
168
|
+
|
|
169
|
+
- Access tokens, refresh tokens, authorization codes, and client secrets are stored only by hash.
|
|
170
|
+
- `props` are encrypted with AES-GCM using key material wrapped by the corresponding secret token.
|
|
171
|
+
- Grant `userId` and `metadata` are not encrypted because applications use them to enumerate and revoke grants. Treat those fields as storage-visible metadata.
|
|
172
|
+
|
|
173
|
+
See [storage-schema.md](../storage-schema.md) for the complete KV layout.
|
|
174
|
+
|
|
175
|
+
By default `completeAuthorization()` revokes the user's earlier grants for the same client and resource. It finds them from KV key metadata that every grant written by 1.0 or later carries, so the cost is one `list()` per thousand grants the user has, not a read per grant. Grants written before 1.0 are read individually, 50 at a time, until a refresh rewrites them with metadata.
|
|
176
|
+
|
|
177
|
+
KV TTLs remove expiring records automatically. `purgeExpiredData()` sweeps orphaned or expired grants and tokens in resumable batches from a Cron Trigger; see [KV cleanup](advanced-configuration.md#kv-cleanup).
|
|
178
|
+
|
|
179
|
+
Deleting a client through `OAuthHelpers.deleteClient()` also revokes its grants and associated tokens across users.
|
|
180
|
+
|
|
181
|
+
## Configuration reference
|
|
182
|
+
|
|
183
|
+
The existing `OAuthProvider` combined configuration uses these options:
|
|
184
|
+
|
|
185
|
+
| Option | Purpose | Default |
|
|
186
|
+
| ---------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------- |
|
|
187
|
+
| `apiRoute` and `apiHandler` | Protect one or more route prefixes with one handler | Use these or `apiHandlers` |
|
|
188
|
+
| `apiHandlers` | Map protected route prefixes to different handlers | Use this or `apiRoute` plus `apiHandler` |
|
|
189
|
+
| `defaultHandler` | Handle authorization UI and other unprotected routes | Required |
|
|
190
|
+
| `authorizeEndpoint` | Application-owned authorization and consent endpoint | Required |
|
|
191
|
+
| `tokenEndpoint` | Provider-owned token and revocation endpoint | Required |
|
|
192
|
+
| `clientRegistrationEndpoint` | Enable RFC 7591 DCR | Disabled |
|
|
193
|
+
| `scopesSupported` | Publish authorization server scopes | Omitted |
|
|
194
|
+
| `resourceMetadata.resource` | Canonical HTTPS resource and token audience | Required |
|
|
195
|
+
| `clientIdMetadataDocumentEnabled` | Enable CIMD lookup and advertisement | `false` |
|
|
196
|
+
| `allowPrivateUseRedirectUris` | Accept RFC 8252 private-use scheme redirect URIs for native apps | `false` |
|
|
197
|
+
| `cookiePrefix` | Prefix for the consent and upstream helpers' cookies (must be `__Host-…`) | `__Host-oauth-` |
|
|
198
|
+
| `disallowPublicClientRegistration` | Reject public clients at DCR | `false` |
|
|
199
|
+
| `clientRegistrationCallback` | Apply application policy before storing a DCR client | None |
|
|
200
|
+
| `allowTokenExchangeGrant` | Enable RFC 8693 | `false` |
|
|
201
|
+
| `tokenExchangeCallback` | Update props, scopes, or lifetimes during token exchange | None |
|
|
202
|
+
| `resolveExternalToken` | Validate external bearer credentials (advanced) | None |
|
|
203
|
+
| `enterpriseManagedAuthorization` | Enable experimental ID-JAG grant support | Disabled |
|
|
204
|
+
| `onError` | Observe or replace OAuth error responses; `internal` names the failed check | Logs a warning |
|
|
205
|
+
|
|
206
|
+
The functional role API adds these surfaces without removing `OAuthProvider`:
|
|
207
|
+
|
|
208
|
+
| Surface | Purpose |
|
|
209
|
+
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
210
|
+
| `new OAuthAuthorizationServer({ issuer, resources, … })` | Create the AS role with a canonical RFC 8414 issuer and its fixed resource registry |
|
|
211
|
+
| `authorizeEndpoint`, `tokenEndpoint` | Optional here; default to `${issuer}/authorize` and `${issuer}/oauth/token` |
|
|
212
|
+
| `validateToken(resource, token, env)` | Validate an access token for one declared resource; what a resource server calls |
|
|
213
|
+
| `defaultResource` | Select a deliberate default for new authorization requests that omit it |
|
|
214
|
+
| `legacyGrantResource` | Select the server-controlled migration target for old unbound grants |
|
|
215
|
+
| `getOAuthApi(env)` | Obtain OAuth helpers for an application-owned authorization route |
|
|
216
|
+
| `new OAuthResourceServer({ … })` | Host one resource, in this Worker or another; `validateToken` points at the AS or a binding |
|
|
217
|
+
|
|
218
|
+
Consult the exported `OAuthProviderOptions`, `OAuthAuthorizationServerOptions`, resource-server callback interfaces, and JSDoc in [`src/oauth-provider.ts`](../src/oauth-provider.ts) for the complete typed API.
|
|
219
|
+
|
|
220
|
+
## OAuth helpers
|
|
221
|
+
|
|
222
|
+
Handlers receive `env.OAUTH_PROVIDER`, which implements `OAuthHelpers`. It can:
|
|
223
|
+
|
|
224
|
+
- Parse authorization requests and complete authorization.
|
|
225
|
+
- Run a consent page and a third-party sign-in redirect safely (`beginConsent()`, `approveConsent()`, `denyConsent()`, `isConsentRemembered()`, `beginUpstream()`, `finishUpstream()`).
|
|
226
|
+
- Look up, create, list, update, and delete clients.
|
|
227
|
+
- List and revoke grants for a user.
|
|
228
|
+
- Inspect internally issued tokens with `unwrapToken()`.
|
|
229
|
+
- Exchange access tokens when RFC 8693 is enabled.
|
|
230
|
+
- Purge expired and orphaned KV data.
|
|
231
|
+
|
|
232
|
+
`getOAuthApi(options, env)` provides the same helper API outside a fetch handler, including RPC methods and other Worker entrypoints.
|
package/docs/consent-page.md
CHANGED
|
@@ -18,19 +18,19 @@ From the MCP authorization spec and security best practices:
|
|
|
18
18
|
|
|
19
19
|
## A minimal page
|
|
20
20
|
|
|
21
|
+
`describeConsent(request)` returns exactly those facts: the client's name, its verified domain for a CIMD client, the redirect URI's hostname, whether that is a local app, and the scopes.
|
|
22
|
+
|
|
21
23
|
```ts
|
|
22
|
-
import type {
|
|
24
|
+
import type { ConsentDescription } from '@cloudflare/workers-oauth-provider';
|
|
23
25
|
|
|
24
26
|
const escape = (value: string) => value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
25
27
|
|
|
26
|
-
function consentPage(
|
|
27
|
-
const name = escape(
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
const origin = client.clientId.startsWith('https://')
|
|
31
|
-
? `Published by <strong>${escape(new URL(client.clientId).hostname)}</strong>.`
|
|
28
|
+
function consentPage(details: ConsentDescription, handle: string): string {
|
|
29
|
+
const name = escape(details.clientName);
|
|
30
|
+
const origin = details.clientDomain
|
|
31
|
+
? `Published by <strong>${escape(details.clientDomain)}</strong>.`
|
|
32
32
|
: 'This app registered itself; its name is not verified.';
|
|
33
|
-
const scopes =
|
|
33
|
+
const scopes = details.scope
|
|
34
34
|
.map(
|
|
35
35
|
(scope) => `<label><input type="checkbox" name="scope" value="${escape(scope)}" checked> ${escape(scope)}</label>`
|
|
36
36
|
)
|
|
@@ -39,8 +39,8 @@ function consentPage(client: ClientInfo, request: AuthRequest, handle: string):
|
|
|
39
39
|
<meta charset="utf-8">
|
|
40
40
|
<title>Authorize ${name}</title>
|
|
41
41
|
<h1>Allow ${name} to access your account?</h1>
|
|
42
|
-
<p>${origin} Access will be sent to <strong>${escape(redirectHost)}</strong>.</p>
|
|
43
|
-
${
|
|
42
|
+
<p>${origin} Access will be sent to <strong>${escape(details.redirectHost)}</strong>.</p>
|
|
43
|
+
${details.redirectIsLoopback ? '<p><strong>This sends access to an app on your computer.</strong> Continue only if you just started signing in from it.</p>' : ''}
|
|
44
44
|
<form method="post">
|
|
45
45
|
<input type="hidden" name="handle" value="${escape(handle)}">
|
|
46
46
|
${scopes}
|
|
@@ -56,10 +56,10 @@ const oauth = authorizationServer.getOAuthApi(env); // or env.OAUTH_PROVIDER wit
|
|
|
56
56
|
|
|
57
57
|
// GET /authorize (after signing the user in with your own session)
|
|
58
58
|
const request = await oauth.parseAuthRequest(req);
|
|
59
|
-
const
|
|
59
|
+
const details = await oauth.describeConsent(request); // first: a failed lookup leaves nothing in KV
|
|
60
60
|
const consent = await oauth.beginConsent(request);
|
|
61
61
|
consent.headers.set('Content-Type', 'text/html; charset=utf-8');
|
|
62
|
-
return new Response(consentPage(
|
|
62
|
+
return new Response(consentPage(details, consent.handle), { headers: consent.headers });
|
|
63
63
|
|
|
64
64
|
// POST /authorize
|
|
65
65
|
const form = await req.formData();
|
|
@@ -101,13 +101,8 @@ A redirect back to the client is only safe once the client and its exact redirec
|
|
|
101
101
|
try {
|
|
102
102
|
// …the handlers above
|
|
103
103
|
} catch (error) {
|
|
104
|
-
if (error instanceof AuthorizationError && error.
|
|
105
|
-
|
|
106
|
-
redirect.searchParams.set('error', error.code);
|
|
107
|
-
redirect.searchParams.set('error_description', error.description);
|
|
108
|
-
if (error.state) redirect.searchParams.set('state', error.state);
|
|
109
|
-
if (error.issuer) redirect.searchParams.set('iss', error.issuer);
|
|
110
|
-
return Response.redirect(redirect.href, 302);
|
|
104
|
+
if (error instanceof AuthorizationError && error.redirectTo) {
|
|
105
|
+
return Response.redirect(error.redirectTo, 302); // error, error_description, state, iss
|
|
111
106
|
}
|
|
112
107
|
if (error instanceof AuthorizationError || error instanceof CimdFetchError) {
|
|
113
108
|
const message = error instanceof AuthorizationError ? error.description : 'This app could not be verified.';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# MCP authorization discovery
|
|
2
|
+
|
|
3
|
+
How an MCP client finds your authorization server, and the two metadata documents it reads on the way.
|
|
4
|
+
|
|
5
|
+
An MCP client discovers authorization in two stages, following the [MCP authorization server discovery rules](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery).
|
|
6
|
+
|
|
7
|
+
For an MCP endpoint at `https://mcp.example.com/mcp`:
|
|
8
|
+
|
|
9
|
+
1. The client sends an unauthenticated request to `/mcp`.
|
|
10
|
+
2. The provider returns `401 Unauthorized` with a challenge similar to:
|
|
11
|
+
|
|
12
|
+
```http
|
|
13
|
+
WWW-Authenticate: Bearer realm="OAuth", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp", scope="mcp:read"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
3. The client fetches the protected resource metadata:
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
https://mcp.example.com/.well-known/oauth-protected-resource/mcp
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
4. That document identifies one or more authorization server issuers through `authorization_servers`.
|
|
23
|
+
5. The client fetches RFC 8414 authorization server metadata from the selected issuer. In [`examples/split-workers`](../examples/split-workers) that is:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
https://auth.example.com/.well-known/oauth-authorization-server
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
6. The metadata tells the client where to authorize, exchange tokens, and register if registration is enabled.
|
|
30
|
+
|
|
31
|
+
Protected resource metadata and authorization server metadata serve different roles:
|
|
32
|
+
|
|
33
|
+
- Protected resource metadata describes the MCP server and identifies its authorization servers.
|
|
34
|
+
- Authorization server metadata describes OAuth endpoints and capabilities such as PKCE and CIMD.
|
|
35
|
+
|
|
36
|
+
## Protected resource metadata
|
|
37
|
+
|
|
38
|
+
Every protected resource needs its own `resourceMetadata.resource`. Configure each canonical HTTPS identifier with a lowercase scheme and host (plain `http` is accepted only on a loopback host, for `wrangler dev`):
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
resourceMetadata: {
|
|
42
|
+
resource: 'https://mcp.example.com/mcp',
|
|
43
|
+
authorization_servers: ['https://auth.example.com'],
|
|
44
|
+
bearer_methods_supported: ['header'],
|
|
45
|
+
resource_name: 'Files MCP server',
|
|
46
|
+
},
|
|
47
|
+
requiredScopes: ['files:read'], // published as this document's scopes_supported
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
For the example above, an unauthenticated request to the exact canonical URL receives a Bearer challenge pointing to:
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
https://mcp.example.com/.well-known/oauth-protected-resource/mcp
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
That document returns the configured canonical `resource`. The discovery URL is built from the canonical resource: an origin uses `/.well-known/oauth-protected-resource`, and a path and query are inserted after the well-known prefix.
|
|
57
|
+
|
|
58
|
+
A canonical path is the base audience for its path-boundary descendants: a token for `https://mcp.example.com/mcp` is accepted at `/mcp/tools`, and a challenge at `/mcp/tools` advertises the one canonical document for `/mcp`, as RFC 9728 §5.1 permits. A request on another origin, or one that the canonical resource does not cover, gets a challenge without `resource_metadata`. Every protected route must be the canonical resource path or a descendant of it; the provider rejects any other `apiRoute` or `apiHandlers` key at construction, because a token could never validate there.
|
|
59
|
+
|
|
60
|
+
`authorization_servers` may contain more than one issuer. Each value must use canonical HTTPS issuer spelling: lowercase scheme and host, with no userinfo, default port, dot segments, query, or fragment. As with resources, `http` is accepted only on a loopback host. OAuth issuer comparison is exact. The MCP client chooses an authorization server and must keep credentials and tokens separate for each issuer. `new OAuthResourceServer()` requires it explicitly, wherever the resource runs.
|
|
61
|
+
|
|
62
|
+
## Authorization server metadata
|
|
63
|
+
|
|
64
|
+
The provider publishes RFC 8414 metadata containing:
|
|
65
|
+
|
|
66
|
+
- `issuer`
|
|
67
|
+
- `authorization_endpoint`
|
|
68
|
+
- `token_endpoint`
|
|
69
|
+
- `protected_resources`, containing the authorization server's registered canonical resources
|
|
70
|
+
- `registration_endpoint`, when DCR is enabled
|
|
71
|
+
- supported response and grant types
|
|
72
|
+
- token endpoint authentication methods
|
|
73
|
+
- PKCE methods
|
|
74
|
+
- revocation endpoint
|
|
75
|
+
- RFC 9207 issuer support
|
|
76
|
+
- CIMD support when it is enabled and safe to use
|
|
77
|
+
|
|
78
|
+
The package serves RFC 8414 metadata rather than OpenID Connect discovery. MCP authorization servers need to provide at least one of those mechanisms, so RFC 8414 is sufficient.
|