@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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `@cloudflare/workers-oauth-provider` adds OAuth 2.1 authorization to HTTP APIs and remote MCP servers running on Cloudflare Workers.
4
4
 
5
- > **1.0 is out, with a new split API.** The authorization server (`OAuthAuthorizationServer`) and resource server (`OAuthResourceServer`) are now separate classes that can run in one Worker or several; the [quick start](#quick-start) shows both. The single-Worker `OAuthProvider` is still supported. Upgrading from 0.x? Read the [migration guide](docs/migration-1.0.md), or point your coding agent at the skill in [`skills/migrate-to-1.0/`](skills/migrate-to-1.0/SKILL.md), which also ships in the npm package.
5
+ > **Upgrading from 0.x, 1.0 or 1.1?** Read the [migration guide](docs/migration-1.0.md), which covers every change since 0.10 with code, or point your coding agent at [`skills/migrate-to-1.0/`](skills/migrate-to-1.0/SKILL.md), which also ships in the npm package.
6
6
 
7
7
  ## Install
8
8
 
@@ -10,546 +10,97 @@
10
10
  npm install @cloudflare/workers-oauth-provider
11
11
  ```
12
12
 
13
- The Worker needs a KV namespace bound as `OAUTH_KV`:
14
-
15
- ```jsonc
16
- {
17
- "kv_namespaces": [
18
- {
19
- "binding": "OAUTH_KV",
20
- "id": "YOUR_KV_NAMESPACE_ID",
21
- },
22
- ],
23
- }
24
- ```
25
-
26
- To enable Client ID Metadata Documents, also add Cloudflare's SSRF protection compatibility flag:
27
-
28
- ```jsonc
29
- {
30
- "compatibility_flags": ["global_fetch_strictly_public"],
31
- }
32
- ```
33
-
34
- See [Client registration](#client-registration) for the matching provider option.
13
+ The authorization server needs a KV namespace bound as `OAUTH_KV`, and the `global_fetch_strictly_public` compatibility flag if it accepts Client ID Metadata Documents.
35
14
 
36
15
  ## Quick start
37
16
 
38
- An MCP deployment has two roles. The **authorization server** signs users in and issues tokens. The **resource server** is your MCP endpoint: it accepts those tokens and checks them with the authorization server over a [Service Binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/). Each is one class.
39
-
40
- ### Authorization server Worker
17
+ An MCP deployment has two roles. The **authorization server** signs users in and issues tokens. The **resource server** is your MCP endpoint: it accepts those tokens and checks them with the authorization server over a [Service Binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/).
41
18
 
42
19
  ```ts
43
- import { AuthorizationError, OAuthAuthorizationServer, type AuthRequest } from '@cloudflare/workers-oauth-provider';
44
- import { WorkerEntrypoint } from 'cloudflare:workers';
45
-
46
- interface Env {
47
- OAUTH_KV: KVNamespace;
48
- }
49
-
20
+ // auth-server/index.ts
50
21
  const authorizationServer = new OAuthAuthorizationServer<Env>({
51
22
  issuer: 'https://auth.example.com',
52
23
  resources: ['https://mcp.example.com/mcp'],
53
- authorizeEndpoint: '/authorize',
54
- tokenEndpoint: '/oauth/token',
55
- scopesSupported: ['mcp:read'],
56
-
57
- // Preferred for clients with no pre-existing relationship.
58
- // Also requires global_fetch_strictly_public in wrangler.jsonc.
24
+ scopesSupported: ['mcp:read', 'mcp:write', 'offline_access'], // everything this server can grant
59
25
  clientIdMetadataDocumentEnabled: true,
60
-
61
- // Optional compatibility fallback. MCP 2026 deprecates DCR for new clients.
62
- clientRegistrationEndpoint: '/oauth/register',
63
26
  });
64
27
 
65
- async function authorize(request: Request, env: Env): Promise<Response> {
66
- const oauth = authorizationServer.getOAuthApi(env);
67
-
68
- // Parses the OAuth parameters and validates the client, redirect URI,
69
- // response type, resource indicator, and PKCE.
70
- let oauthRequest: AuthRequest;
71
- try {
72
- oauthRequest = await oauth.parseAuthRequest(request);
73
- } catch (error) {
74
- if (!(error instanceof AuthorizationError)) throw error;
75
- if (!error.redirectUri) {
76
- // Unknown clients and invalid redirects must be rendered locally.
77
- return new Response(error.description, { status: 400 });
78
- }
79
- const redirect = new URL(error.redirectUri);
80
- redirect.searchParams.set('error', error.code);
81
- redirect.searchParams.set('error_description', error.description);
82
- if (error.state) redirect.searchParams.set('state', error.state);
83
- if (error.issuer) redirect.searchParams.set('iss', error.issuer);
84
- return Response.redirect(redirect.href, 302);
85
- }
86
-
87
- const client = await oauth.lookupClient(oauthRequest.clientId);
88
- if (!client) return new Response('Unknown OAuth client', { status: 400 });
89
-
90
- // Authenticate the user and obtain consent here. Never approve automatically
91
- // in production; this example assumes those steps produced these values.
92
- const user = { id: 'user-123', displayName: 'Ada' };
93
- const { redirectTo } = await oauth.completeAuthorization({
94
- request: oauthRequest,
95
- userId: user.id,
96
- metadata: { clientName: client.clientName },
97
- scope: oauthRequest.scope.filter((scope) => scope === 'mcp:read'),
98
- props: { userId: user.id, displayName: user.displayName },
99
- });
100
- return Response.redirect(redirectTo, 302);
101
- }
102
-
103
28
  export default class AuthServer extends WorkerEntrypoint<Env> {
104
- // Your /authorize page; everything else (discovery, token, revocation, registration) is the library's.
105
29
  fetch(request: Request) {
30
+ // /authorize is yours: parseAuthRequest(), sign the user in and ask for consent, completeAuthorization().
106
31
  if (new URL(request.url).pathname === '/authorize') return authorize(request, this.env);
107
- return authorizationServer.fetch(request, this.env, this.ctx);
32
+ return authorizationServer.fetch(request, this.env, this.ctx); // discovery, token, revocation
108
33
  }
109
34
 
110
- // Called by resource Workers over their Service Binding.
35
+ // Resource servers call this over their Service Binding.
111
36
  validateToken(resource: string, token: string) {
112
37
  return authorizationServer.validateToken(resource, token, this.env);
113
38
  }
114
39
  }
115
40
  ```
116
41
 
117
- ### Resource server Worker
118
-
119
- ```jsonc
120
- // wrangler.jsonc
121
- {
122
- "services": [{ "binding": "AUTH_SERVER", "service": "auth-server" }],
123
- }
124
- ```
125
-
126
42
  ```ts
127
- import { OAuthResourceServer, type AuthorizationServerBinding } from '@cloudflare/workers-oauth-provider';
128
-
129
- interface AuthProps {
130
- userId: string;
131
- displayName: string;
132
- }
133
-
134
- interface Env {
135
- AUTH_SERVER: AuthorizationServerBinding<AuthProps>;
136
- }
137
-
43
+ // mcp-server/index.ts
138
44
  export default new OAuthResourceServer<Env, AuthProps>({
139
45
  resourceMetadata: {
140
46
  resource: 'https://mcp.example.com/mcp',
141
47
  authorization_servers: ['https://auth.example.com'],
142
- scopes_supported: ['mcp:read'],
143
- resource_name: 'Example MCP server',
144
48
  },
49
+ requiredScopes: ['mcp:read'], // needed for any access; clients request these first
145
50
  validateToken: (env) => env.AUTH_SERVER.validateToken,
146
51
  handler: {
147
52
  fetch(request, env, ctx) {
148
- // ctx.props: what completeAuthorization() stored. ctx.auth: the verified token (scope, userId, clientId, …).
149
- return Response.json({ userId: ctx.props.userId, scope: ctx.auth.scope });
53
+ // ctx.props: what completeAuthorization() stored. ctx.auth: the verified token.
54
+ // Step-up: a 403 naming the missing scope, and the client re-authorizes for it.
55
+ const needed = request.method === 'GET' ? ['mcp:read'] : ['mcp:read', 'mcp:write'];
56
+ if (!needed.every((scope) => ctx.auth.scope.includes(scope))) return insufficientScope(ctx.auth, needed);
57
+ return Response.json({ userId: ctx.props.userId });
150
58
  },
151
59
  },
152
60
  });
153
61
  ```
154
62
 
155
- The resource server publishes its RFC 9728 metadata, answers unauthenticated requests with a Bearer challenge that points at it, validates every token for its own resource only, and passes the handler `ctx.props` and `ctx.auth`. The handler still enforces permissions such as ownership and tenancy; `insufficientScope(ctx.auth, scopes)` builds the MCP `403` when a token lacks a scope an operation needs. The binding is not a URL, so the validator is not reachable from the public internet.
63
+ **[`examples/split-workers`](examples/split-workers)** has both Workers in full, including the `/authorize` handler and `wrangler.jsonc`, with an end-to-end test that runs them in workerd.
156
64
 
157
- ### More resources, or one Worker
65
+ The resource server publishes its RFC 9728 metadata, answers unauthenticated requests with a challenge pointing at it, and accepts only tokens issued for its own resource. The handler owns authorization beyond that: scopes, ownership, tenancy.
158
66
 
159
- - **Another MCP resource**: add it to `resources` and deploy another `OAuthResourceServer` with the same binding. A token issued for one resource is refused by every other.
160
- - **Both roles in one Worker**: construct the `OAuthResourceServer` next to the authorization server with a local validator, `validateToken: (env) => (resource, token) => authorizationServer.validateToken(resource, token, env)`, and route to it from your `fetch`.
67
+ On the wire both lists are called `scopes_supported`, as the specs name them, but they mean different things: `scopesSupported` is everything the authorization server can grant; a resource's `requiredScopes` is what any access needs, so MCP clients request it first, and more comes by step-up. The handler checks `ctx.auth.scope`: the library advertises the required scopes but doesn't enforce them, since only your code knows which scopes imply others. See [Scopes](docs/authorization-server.md#scopes-and-step-up-authorization).
161
68
 
162
- [docs/resource-servers.md](docs/resource-servers.md) covers both, including a three-domain Hono example, and how to accept another issuer's tokens at your own risk.
69
+ ## One Worker: `OAuthProvider`
163
70
 
164
- ## Single Worker: `OAuthProvider`
165
-
166
- When one Worker is both the authorization server and its only resource, which was the 0.x shape, `OAuthProvider` combines the two roles. Requests to `apiRoute` are protected and reach `apiHandler` with `ctx.props` and `ctx.auth`; everything else that isn't an OAuth endpoint goes to `defaultHandler`, which owns `/authorize` and reaches the same helpers as `env.OAUTH_PROVIDER`:
71
+ The split roles above are the recommended shape. `OAuthProvider` remains fully supported for one Worker that is both the authorization server and its only resource, which was the 0.x shape: it combines the roles. Requests under `apiRoute` reach `apiHandler` with `ctx.props` and `ctx.auth`; everything else goes to `defaultHandler`, which owns `/authorize` and reaches the helpers as `env.OAUTH_PROVIDER`:
167
72
 
168
73
  ```ts
169
74
  export default new OAuthProvider<Env>({
170
- apiRoute: '/mcp', // or apiHandlers: { '/mcp': …, '/mcp/admin': … }
171
- apiHandler: McpApiHandler, // an object with fetch, or a WorkerEntrypoint class
172
- defaultHandler, // /authorize, using env.OAUTH_PROVIDER.parseAuthRequest() / completeAuthorization()
75
+ apiRoute: '/mcp',
76
+ apiHandler: McpApiHandler,
77
+ defaultHandler, // your /authorize page
173
78
  authorizeEndpoint: '/authorize',
174
79
  tokenEndpoint: '/oauth/token',
175
- scopesSupported: ['mcp:read'],
80
+ scopesSupported: ['mcp:read', 'mcp:write', 'offline_access'], // everything this server can grant
176
81
  resourceMetadata: {
177
82
  resource: 'https://mcp.example.com/mcp',
178
83
  authorization_servers: ['https://mcp.example.com'],
179
- scopes_supported: ['mcp:read'],
180
84
  },
85
+ requiredScopes: ['mcp:read'], // needed for any access; clients request these first
181
86
  clientIdMetadataDocumentEnabled: true,
182
87
  });
183
88
  ```
184
89
 
185
- Every protected route must be the canonical `resource` path or a descendant of it; construction rejects anything else.
186
-
187
- ## How MCP authorization discovery works
188
-
189
- 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).
190
-
191
- For an MCP endpoint at `https://mcp.example.com/mcp`:
192
-
193
- 1. The client sends an unauthenticated request to `/mcp`.
194
- 2. The provider returns `401 Unauthorized` with a challenge similar to:
195
-
196
- ```http
197
- WWW-Authenticate: Bearer realm="OAuth", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp", scope="mcp:read"
198
- ```
199
-
200
- 3. The client fetches the protected resource metadata:
201
-
202
- ```text
203
- https://mcp.example.com/.well-known/oauth-protected-resource/mcp
204
- ```
205
-
206
- 4. That document identifies one or more authorization server issuers through `authorization_servers`.
207
- 5. The client fetches RFC 8414 authorization server metadata from the selected issuer. In the quick start that is:
208
-
209
- ```text
210
- https://auth.example.com/.well-known/oauth-authorization-server
211
- ```
212
-
213
- 6. The metadata tells the client where to authorize, exchange tokens, and register if registration is enabled.
214
-
215
- Protected resource metadata and authorization server metadata serve different roles:
216
-
217
- - Protected resource metadata describes the MCP server and identifies its authorization servers.
218
- - Authorization server metadata describes OAuth endpoints and capabilities such as PKCE and CIMD.
219
-
220
- ### Protected resource metadata
221
-
222
- 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`):
223
-
224
- ```ts
225
- resourceMetadata: {
226
- resource: 'https://mcp.example.com/mcp',
227
- authorization_servers: ['https://auth.example.com'],
228
- scopes_supported: ['files:read'],
229
- bearer_methods_supported: ['header'],
230
- resource_name: 'Files MCP server',
231
- }
232
- ```
233
-
234
- For the example above, an unauthenticated request to the exact canonical URL receives a Bearer challenge pointing to:
235
-
236
- ```text
237
- https://mcp.example.com/.well-known/oauth-protected-resource/mcp
238
- ```
239
-
240
- 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.
241
-
242
- 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.
243
-
244
- `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.
245
-
246
- ### Authorization server metadata
247
-
248
- The provider publishes RFC 8414 metadata containing:
249
-
250
- - `issuer`
251
- - `authorization_endpoint`
252
- - `token_endpoint`
253
- - `protected_resources`, containing the authorization server's registered canonical resources
254
- - `registration_endpoint`, when DCR is enabled
255
- - supported response and grant types
256
- - token endpoint authentication methods
257
- - PKCE methods
258
- - revocation endpoint
259
- - RFC 9207 issuer support
260
- - CIMD support when it is enabled and safe to use
261
-
262
- 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.
263
-
264
- ## Authorization endpoint
265
-
266
- Your `authorizeEndpoint` belongs to the application's `defaultHandler` because user authentication and consent are application-specific. The provider is not an identity provider.
267
-
268
- A typical flow has three steps:
269
-
270
- 1. Call `parseAuthRequest(request)` to validate the client, redirect URI, response type, resource, and PKCE restrictions.
271
- 2. Authenticate the user, show consent, and decide which scopes to grant.
272
- 3. Call `completeAuthorization()` and redirect to its returned `redirectTo` URL.
273
-
274
- [docs/consent-page.md](docs/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.
275
-
276
- `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 the quick start.
277
-
278
- `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()`.
279
-
280
- `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.
281
-
282
- 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.
283
-
284
- For users with many grants, `revokeExistingGrantsBatchSize` controls the KV page size used during that scan. It defaults to `50` and is capped at KV's maximum page size of `1000`.
285
-
286
- ### Authorization response issuer
287
-
288
- RFC 9207 issuer identification is always enabled. Authorization server metadata advertises `authorization_response_iss_parameter_supported: true`, and successful authorization responses include `iss` automatically.
289
-
290
- `parseAuthRequest()` returns the expected `issuer`. If the application creates a terminal OAuth error redirect, include that value:
291
-
292
- ```ts
293
- const oauthRequest = await env.OAUTH_PROVIDER.parseAuthRequest(request);
294
- const redirect = new URL(oauthRequest.redirectUri);
295
- redirect.searchParams.set('error', 'access_denied');
296
- redirect.searchParams.set('state', oauthRequest.state);
297
- if (oauthRequest.issuer) redirect.searchParams.set('iss', oauthRequest.issuer);
298
- return Response.redirect(redirect.toString(), 302);
299
- ```
300
-
301
- Intermediate identity-provider redirects and local HTML error pages do not need the OAuth `iss` parameter.
302
-
303
- ## Client registration
304
-
305
- [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.
306
-
307
- ### Pre-registered clients
308
-
309
- Use `OAuthHelpers.createClient()` to create clients through application or administrative code. These clients are stored in KV and are not subject to `clientRegistrationTTL`.
310
-
311
- ### Client ID Metadata Documents
312
-
313
- 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.
314
-
315
- Enable it in both places:
316
-
317
- ```ts
318
- new OAuthProvider({
319
- // Other options...
320
- clientIdMetadataDocumentEnabled: true,
321
- });
322
- ```
323
-
324
- ```jsonc
325
- {
326
- "compatibility_flags": ["global_fetch_strictly_public"],
327
- }
328
- ```
329
-
330
- 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).
331
-
332
- 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:
333
-
334
- - An HTTPS Client Identifier URL with a path component and no userinfo, fragment, or dot path segments.
335
- - A document `client_id` exactly matching its URL.
336
- - Non-empty `client_name` and `redirect_uris` fields, as MCP requires, with unsafe redirect schemes rejected at ingestion.
337
- - Exact authorization-request redirect URI validation, with RFC 8252 loopback port handling.
338
- - A 5 KB response size limit and a 10 second timeout covering both headers and body.
339
- - Valid UTF-8 JSON object syntax and safe URI schemes for client metadata fields.
340
- - No embedded client secrets or private JWK material.
341
-
342
- 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.
343
-
344
- 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.
345
-
346
- 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](https://github.com/cloudflare/workers-oauth-provider/blob/main/docs/advanced-configuration.md#cimd-fetch-errors) for an example.
347
-
348
- ### Dynamic Client Registration
349
-
350
- Set `clientRegistrationEndpoint` to enable RFC 7591 Dynamic Client Registration:
351
-
352
- ```ts
353
- clientRegistrationEndpoint: '/oauth/register';
354
- ```
355
-
356
- 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.
357
-
358
- 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.
359
-
360
- 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.
361
-
362
- Calling `OAuthHelpers.updateClient()` with `tokenEndpointAuthMethod` adds the marker; unrelated updates leave it unchanged.
363
-
364
- Related options:
365
-
366
- - `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.
367
- - `disallowPublicClientRegistration` rejects DCR clients using `token_endpoint_auth_method: "none"`.
368
- - `clientRegistrationCallback` can allow or reject registration based on application policy.
369
-
370
- Clients created by `OAuthHelpers.createClient()` are not affected by the DCR TTL or public-registration restriction.
371
-
372
- ## PKCE and token lifecycle
373
-
374
- Public clients must use PKCE with authorization code flow. PKCE challenges use only S256 by default. Confidential clients may still omit PKCE.
375
-
376
- Legacy deployments with clients that cannot use S256 can opt back into plain PKCE:
377
-
378
- ```ts
379
- allowPlainPKCE: true;
380
- ```
381
-
382
- `allowImplicitFlow` defaults to `false`; leave it disabled for MCP and other new OAuth deployments.
383
-
384
- 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.
385
-
386
- 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](docs/advanced-configuration.md#token-and-client-lifetimes).
387
-
388
- ## Resources and token audiences
389
-
390
- 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.
391
-
392
- 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.
393
-
394
- 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.
395
-
396
- Conforming MCP clients are required to send `resource` in authorization and token requests. Resource selection and compatibility work as follows:
397
-
398
- - When the authorization server has one registered resource, that sole resource is selected if an authorization request omits `resource`. This preserves existing `OAuthProvider` behavior.
399
- - 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.
400
- - 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.
401
- - Malformed, unknown, or multi-valued resource input returns `invalid_target` before code consumption, callbacks, refresh rotation, or storage writes.
402
-
403
- 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.
404
-
405
- 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.
406
-
407
- 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.
408
-
409
- ## Scopes and step-up authorization
410
-
411
- `scopesSupported` is published only in authorization server metadata. Configure each protected resource's `resourceMetadata.scopes_supported` explicitly with the minimal scopes required for its basic functionality and baseline Bearer challenges.
412
-
413
- The application decides which requested scopes to grant through `completeAuthorization({ scope })`. Token and refresh requests can only narrow those scopes.
414
-
415
- Both hosts name `scopes_supported` in the initial `401` challenge, so a client asks for the right scopes first time. Operation-level policy stays in the handler, which reads the token's scopes from `ctx.auth.scope` and answers a shortfall with `insufficientScope(ctx.auth, ['files:write'])`: `403`, `error="insufficient_scope"`, every scope the operation needs in one challenge, and the resource's metadata URL. See [docs/resource-servers.md](docs/resource-servers.md#what-the-handler-sees).
416
-
417
- ## Advanced features
418
-
419
- The package also supports:
420
-
421
- - External API keys and bearer credentials through `resolveExternalToken` as an advanced compatibility feature.
422
- - Updating encrypted props, token scope, and token lifetimes with `tokenExchangeCallback`.
423
- - OAuth 2.0 Token Exchange when `allowTokenExchangeGrant` is enabled.
424
- - Signing users in through another OAuth provider (GitHub, Google, …) with per-client consent and browser-bound `state`. See [docs/upstream-sign-in.md](docs/upstream-sign-in.md).
425
- - Structured callback errors through the exported `OAuthError` and `ExternalTokenError` classes.
426
- - Custom error observation or responses through `onError`.
427
- - Experimental MCP Enterprise-Managed Authorization using ID-JAG assertions.
428
- - One authorization server with multiple same-Worker or separately routed MCP resources.
429
- - Multiple protected handlers through `apiHandlers`.
430
- - Configurable access token, refresh token, and DCR client lifetimes.
431
-
432
- See [Advanced configuration](https://github.com/cloudflare/workers-oauth-provider/blob/main/docs/advanced-configuration.md) for examples and security notes.
433
-
434
- ## KV storage and cleanup
435
-
436
- Sensitive values are not stored in plaintext:
437
-
438
- - Access tokens, refresh tokens, authorization codes, and client secrets are stored only by hash.
439
- - `props` are encrypted with AES-GCM using key material wrapped by the corresponding secret token.
440
- - Grant `userId` and `metadata` are not encrypted because applications use them to enumerate and revoke grants. Treat those fields as storage-visible metadata.
441
-
442
- See [storage-schema.md](https://github.com/cloudflare/workers-oauth-provider/blob/main/storage-schema.md) for the complete KV layout.
443
-
444
- 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, `revokeExistingGrantsBatchSize` at a time (default 50), until a refresh rewrites them with metadata.
445
-
446
- KV TTLs remove expiring records automatically. `purgeExpiredData()` provides a manual sweep for orphaned or expired grants and tokens:
447
-
448
- ```ts
449
- const provider = new OAuthProvider({
450
- // Options...
451
- });
452
-
453
- export default {
454
- fetch(request, env, ctx) {
455
- return provider.fetch(request, env, ctx);
456
- },
457
- async scheduled(_event, env) {
458
- const result = await provider.purgeExpiredData(env, { batchSize: 100 });
459
- console.log(result);
460
- },
461
- };
462
- ```
463
-
464
- The default batch size is 50. `result.done` reports whether both key spaces were scanned completely during that invocation.
465
-
466
- Deleting a client through `OAuthHelpers.deleteClient()` also revokes its grants and associated tokens across users.
467
-
468
- ## Configuration reference
469
-
470
- The existing `OAuthProvider` combined configuration uses these options:
471
-
472
- | Option | Purpose | Default |
473
- | ---------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------- |
474
- | `apiRoute` and `apiHandler` | Protect one or more route prefixes with one handler | Use these or `apiHandlers` |
475
- | `apiHandlers` | Map protected route prefixes to different handlers | Use this or `apiRoute` plus `apiHandler` |
476
- | `defaultHandler` | Handle authorization UI and other unprotected routes | Required |
477
- | `authorizeEndpoint` | Application-owned authorization and consent endpoint | Required |
478
- | `tokenEndpoint` | Provider-owned token and revocation endpoint | Required |
479
- | `clientRegistrationEndpoint` | Enable RFC 7591 DCR | Disabled |
480
- | `scopesSupported` | Publish authorization server scopes | Omitted |
481
- | `resourceMetadata.resource` | Canonical HTTPS resource and token audience | Required |
482
- | `clientIdMetadataDocumentEnabled` | Enable CIMD lookup and advertisement | `false` |
483
- | `cookiePrefix` | Prefix for the consent and upstream helpers' cookies (must be `__Host-…`) | `__Host-oauth-` |
484
- | `allowPlainPKCE` | Permit the legacy plain PKCE method | `false` |
485
- | `allowImplicitFlow` | Enable implicit token responses | `false` |
486
- | `disallowPublicClientRegistration` | Reject public clients at DCR | `false` |
487
- | `clientRegistrationCallback` | Apply application policy before storing a DCR client | None |
488
- | `allowTokenExchangeGrant` | Enable RFC 8693 | `false` |
489
- | `tokenExchangeCallback` | Update props, scopes, or lifetimes during token exchange | None |
490
- | `resolveExternalToken` | Validate external bearer credentials (advanced) | None |
491
- | `enterpriseManagedAuthorization` | Enable experimental ID-JAG grant support | Disabled |
492
- | `onError` | Observe or replace OAuth error responses; `internal` names the failed check | Logs a warning |
493
-
494
- The functional role API adds these surfaces without removing `OAuthProvider`:
495
-
496
- | Surface | Purpose |
497
- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
498
- | `new OAuthAuthorizationServer({ issuer, resources, … })` | Create the AS role with a canonical RFC 8414 issuer and its fixed resource registry |
499
- | `validateToken(resource, token, env)` | Validate an access token for one declared resource; what a resource server calls |
500
- | `defaultResource` | Select a deliberate default for new authorization requests that omit it |
501
- | `legacyGrantResource` | Select the server-controlled migration target for old unbound grants |
502
- | `getOAuthApi(env)` | Obtain OAuth helpers for an application-owned authorization route |
503
- | `new OAuthResourceServer({ … })` | Host one resource, in this Worker or another; `validateToken` points at the AS or a binding |
504
-
505
- Consult the exported `OAuthProviderOptions`, `OAuthAuthorizationServerOptions`, resource-server callback interfaces, and JSDoc in [`src/oauth-provider.ts`](https://github.com/cloudflare/workers-oauth-provider/blob/main/src/oauth-provider.ts) for the complete typed API.
90
+ ## Documentation
506
91
 
507
- ## OAuth helpers
508
-
509
- Handlers receive `env.OAUTH_PROVIDER`, which implements `OAuthHelpers`. It can:
510
-
511
- - Parse authorization requests and complete authorization.
512
- - Run a consent page and a third-party sign-in redirect safely (`beginConsent()`, `approveConsent()`, `denyConsent()`, `isConsentRemembered()`, `beginUpstream()`, `finishUpstream()`).
513
- - Look up, create, list, update, and delete clients.
514
- - List and revoke grants for a user.
515
- - Inspect internally issued tokens with `unwrapToken()`.
516
- - Exchange access tokens when RFC 8693 is enabled.
517
- - Purge expired and orphaned KV data.
518
-
519
- `getOAuthApi(options, env)` provides the same helper API outside a fetch handler, including RPC methods and other Worker entrypoints.
92
+ - [Resource servers](docs/resource-servers.md): more resources, both roles in one Worker, what the handler sees, other issuers.
93
+ - [Consent page](docs/consent-page.md): what it must show, Allow and Deny, remembering consent.
94
+ - [Signing in through another provider](docs/upstream-sign-in.md): GitHub, Google and friends as the identity step.
95
+ - [Authorization server reference](docs/authorization-server.md): the authorize endpoint, client registration (pre-registered, CIMD, DCR), PKCE and token lifetimes, resources and audiences, scopes, KV storage, every option.
96
+ - [MCP authorization discovery](docs/mcp-discovery.md): how a client gets from a `401` to your authorization server.
97
+ - [Advanced configuration](docs/advanced-configuration.md): external tokens, token exchange, `tokenExchangeCallback`, `onError`, Enterprise-Managed Authorization (experimental).
98
+ - [Storage schema](storage-schema.md): the KV layout. Tokens, codes and secrets are stored only as hashes; `props` are encrypted with a key only the token holder can unwrap.
520
99
 
521
100
  ## Standards
522
101
 
523
- The package implements or supports the relevant portions of:
524
-
525
- - [MCP authorization, 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization)
526
- - [OAuth 2.1, draft-ietf-oauth-v2-1-13](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13)
527
- - [OAuth 2.0 Bearer Token Usage, RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750)
528
- - [OAuth 2.0 Token Revocation, RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009)
529
- - [OAuth 2.0 Dynamic Client Registration, RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)
530
- - [Proof Key for Code Exchange, RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)
531
- - [OAuth 2.0 Authorization Server Metadata, RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414)
532
- - [OAuth 2.0 Token Exchange, RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693)
533
- - [Resource Indicators for OAuth 2.0, RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)
534
- - [OAuth 2.0 Authorization Server Issuer Identification, RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207)
535
- - [OAuth 2.0 Protected Resource Metadata, RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)
536
- - [OAuth Client ID Metadata Documents](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00)
537
- - [OpenID Connect RP Metadata Choices 1.0](https://openid.net/specs/openid-connect-rp-metadata-choices-1_0-final.html)
538
- - [MCP Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization), with experimental package support
102
+ [MCP authorization 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization), [OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13), and RFCs [6750](https://datatracker.ietf.org/doc/html/rfc6750), [7009](https://datatracker.ietf.org/doc/html/rfc7009), [7591](https://datatracker.ietf.org/doc/html/rfc7591), [7636](https://datatracker.ietf.org/doc/html/rfc7636), [8414](https://datatracker.ietf.org/doc/html/rfc8414), [8693](https://datatracker.ietf.org/doc/html/rfc8693), [8707](https://datatracker.ietf.org/doc/html/rfc8707), [9207](https://datatracker.ietf.org/doc/html/rfc9207) and [9728](https://datatracker.ietf.org/doc/html/rfc9728), plus [Client ID Metadata Documents](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00), [OpenID Connect RP Metadata Choices](https://openid.net/specs/openid-connect-rp-metadata-choices-1_0-final.html) and, experimentally, [MCP Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization).
539
103
 
540
104
  ## Development
541
105
 
542
- Node 24 or newer is required.
543
-
544
- ```sh
545
- npm install
546
- npm run build
547
- npm run check
548
- npm run prettier
549
- ```
550
-
551
- Changes that affect behavior or the public API need a Changeset. See [AGENTS.md](https://github.com/cloudflare/workers-oauth-provider/blob/main/AGENTS.md) for repository conventions and [SECURITY.md](https://github.com/cloudflare/workers-oauth-provider/blob/main/SECURITY.md) for vulnerability reporting.
552
-
553
- ## Project history
554
-
555
- Kenton Varda's original account of how this library was created is preserved in [HISTORY.md](https://github.com/cloudflare/workers-oauth-provider/blob/main/HISTORY.md).
106
+ Node 24 or newer. `npm install`, `npm run build`, `npm run check`. Changes that affect behavior or the public API need a Changeset; see [AGENTS.md](AGENTS.md) for conventions, [SECURITY.md](SECURITY.md) for vulnerability reporting, and [HISTORY.md](HISTORY.md) for how the library began.