@cloudflare/workers-oauth-provider 0.8.3 → 0.9.0

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
@@ -1,565 +1,470 @@
1
1
  # OAuth 2.1 Provider Framework for Cloudflare Workers
2
2
 
3
- This is a TypeScript library that implements the provider side of the OAuth 2.1 protocol with PKCE support. The library is intended to be used on Cloudflare Workers.
3
+ `@cloudflare/workers-oauth-provider` adds OAuth 2.1 authorization to HTTP APIs and remote MCP servers running on Cloudflare Workers.
4
4
 
5
- ## Benefits of this library
5
+ ## Install
6
6
 
7
- - The library acts as a wrapper around your Worker code, which adds authorization for your API endpoints.
8
- - All token management is handled automatically.
9
- - Your API handler is written like a regular fetch handler, but receives the already-authenticated user details as a parameter. No need to perform any checks of your own.
10
- - The library is agnostic to how you manage and authenticate users.
11
- - The library is agnostic to how you build your UI. Your authorization flow can be implemented using whatever UI framework you use for everything else.
12
- - The library's storage does not store any secrets, only hashes of them.
7
+ ```sh
8
+ npm install @cloudflare/workers-oauth-provider
9
+ ```
13
10
 
14
- ## Usage
11
+ The Worker needs a KV namespace bound as `OAUTH_KV`:
15
12
 
16
- A Worker that uses the library might look like this:
13
+ ```jsonc
14
+ {
15
+ "kv_namespaces": [
16
+ {
17
+ "binding": "OAUTH_KV",
18
+ "id": "YOUR_KV_NAMESPACE_ID",
19
+ },
20
+ ],
21
+ }
22
+ ```
17
23
 
18
- ```ts
19
- import { OAuthProvider } from '@cloudflare/workers-oauth-provider';
20
- import { WorkerEntrypoint } from 'cloudflare:workers';
24
+ To enable Client ID Metadata Documents, also add Cloudflare's SSRF protection compatibility flag:
21
25
 
22
- // We export the OAuthProvider instance as the entrypoint to our Worker. This means it
23
- // implements the `fetch()` handler, receiving all HTTP requests.
24
- export default new OAuthProvider({
25
- // Configure API routes. Any requests whose URL starts with any of these prefixes will be
26
- // considered API requests. The OAuth provider will check the access token on these requests,
27
- // and then, if the token is valid, send the request to the API handler.
28
- // You can provide:
29
- // - A single route (string) or multiple routes (array)
30
- // - Full URLs (which will match the hostname) or just paths (which will match any hostname)
31
- apiRoute: [
32
- '/api/', // Path only - will match any hostname
33
- 'https://api.example.com/', // Full URL - will check hostname
34
- ],
26
+ ```jsonc
27
+ {
28
+ "compatibility_flags": ["global_fetch_strictly_public"],
29
+ }
30
+ ```
35
31
 
36
- // When the OAuth system receives an API request with a valid access token, it passes the request
37
- // to this handler object's fetch method.
38
- // You can provide either an object with a fetch method (ExportedHandler)
39
- // or a class extending WorkerEntrypoint.
40
- apiHandler: ApiHandler, // Using a WorkerEntrypoint class
41
-
42
- // For multi-handler setups, you can use apiHandlers instead of apiRoute+apiHandler.
43
- // This allows you to use different handlers for different API routes.
44
- // Note: You must use either apiRoute+apiHandler (single-handler) OR apiHandlers (multi-handler), not both.
45
- // Example:
46
- // apiHandlers: {
47
- // "/api/users/": UsersApiHandler,
48
- // "/api/documents/": DocumentsApiHandler,
49
- // "https://api.example.com/": ExternalApiHandler,
50
- // },
51
-
52
- // Any requests which aren't API request will be passed to the default handler instead.
53
- // Again, this can be either an object or a WorkerEntrypoint.
54
- defaultHandler: defaultHandler, // Using an object with a fetch method
55
-
56
- // This specifies the URL of the OAuth authorization flow UI. This UI is NOT implemented by
57
- // the OAuthProvider. It is up to the application to implement a UI here. The only reason why
58
- // this URL is given to the OAuthProvider is so that it can implement the RFC-8414 metadata
59
- // discovery endpoint, i.e. `.well-known/oauth-authorization-server`.
60
- // Can also be specified as just a path (e.g., "/authorize").
61
- authorizeEndpoint: 'https://example.com/authorize',
62
-
63
- // This specifies the OAuth 2 token exchange endpoint. The OAuthProvider will implement this
64
- // endpoint (by directly responding to requests with a matching URL).
65
- // Can also be specified as just a path (e.g., "/oauth/token").
66
- tokenEndpoint: 'https://example.com/oauth/token',
67
-
68
- // This specifies the RFC-7591 dynamic client registration endpoint. This setting is optional,
69
- // but if provided, the OAuthProvider will implement this endpoint to allow dynamic client
70
- // registration.
71
- // Can also be specified as just a path (e.g., "/oauth/register").
72
- clientRegistrationEndpoint: 'https://example.com/oauth/register',
73
-
74
- // Optional list of scopes supported by this OAuth provider.
75
- // If provided, this will be included in the RFC 8414 metadata as 'scopes_supported'.
76
- // If not provided, the 'scopes_supported' field will be omitted from the metadata.
77
- scopesSupported: ['document.read', 'document.write', 'profile'],
78
-
79
- // Optional: Controls whether the OAuth implicit flow is allowed.
80
- // The implicit flow is discouraged in OAuth 2.1 but may be needed for some clients.
81
- // Defaults to false.
82
- allowImplicitFlow: false,
83
-
84
- // Optional: Controls whether the plain PKCE code_challenge_method is allowed.
85
- // OAuth 2.1 recommends using S256 exclusively as plain offers no cryptographic protection.
86
- // When false, only S256 is accepted and advertised in the metadata endpoint.
87
- // Defaults to true for backward compatibility.
88
- allowPlainPKCE: true,
89
-
90
- // Optional: Controls whether public clients (clients without a secret, like SPAs)
91
- // can register via the dynamic client registration endpoint.
92
- // When true, only confidential clients can register.
93
- // Note: Creating public clients via the OAuthHelpers.createClient() method
94
- // is always allowed regardless of this setting.
95
- // Defaults to false.
96
- disallowPublicClientRegistration: false,
97
-
98
- // Optional: Time-to-live for refresh tokens in seconds.
99
- // Defaults to 30 days (2,592,000 seconds).
100
- // Set to 0 to disable refresh tokens (only access tokens will be issued).
101
- // Set to `undefined` explicitly for refresh tokens that never expire.
102
- refreshTokenTTL: 2592000, // 30 days (the default)
103
-
104
- // Optional: Time-to-live for access tokens in seconds.
105
- // Defaults to 1 hour (3600 seconds) if not specified.
106
- accessTokenTTL: 3600,
107
-
108
- // Optional: Time-to-live for dynamically registered clients in seconds.
109
- // Defaults to 90 days (7,776,000 seconds).
110
- // Clients created via OAuthHelpers.createClient() are not affected.
111
- // Set to `undefined` explicitly for clients that never expire.
112
- clientRegistrationTTL: 7776000, // 90 days (the default)
113
-
114
- // Optional: Controls whether OAuth 2.0 Token Exchange (RFC 8693) is allowed.
115
- // When false, the token exchange grant type will not be advertised in metadata
116
- // and token exchange requests will be rejected.
117
- // Defaults to false.
118
- allowTokenExchangeGrant: false,
119
-
120
- // Optional: Experimental MCP Enterprise-Managed Authorization support.
121
- // When enabled, the token endpoint accepts ID-JAG JWTs with the JWT bearer grant.
122
- enterpriseManagedAuthorization: undefined,
123
-
124
- // Optional: Explicitly enable Client ID Metadata Document (CIMD) support.
125
- // When true, URL-formatted client_ids will be fetched as metadata documents.
126
- // Requires the 'global_fetch_strictly_public' compatibility flag.
127
- // See the CIMD section below for details. Defaults to false.
128
- clientIdMetadataDocumentEnabled: false,
129
- });
32
+ See [Client registration](#client-registration) for the matching provider option.
130
33
 
131
- // The default handler object - the OAuthProvider will pass through HTTP requests to this object's fetch method
132
- // if they aren't API requests or do not have a valid access token
133
- const defaultHandler = {
134
- // This fetch method works just like a standard Cloudflare Workers fetch handler
135
- //
136
- // The `request`, `env`, and `ctx` parameters are the same as for a normal Cloudflare Workers fetch
137
- // handler, and are exactly the objects that the `OAuthProvider` itself received from the Workers
138
- // runtime.
139
- //
140
- // The `env.OAUTH_PROVIDER` provides an API by which the application can call back to the
141
- // OAuthProvider.
142
- async fetch(request: Request, env, ctx) {
143
- let url = new URL(request.url);
144
-
145
- if (url.pathname == '/authorize') {
146
- // This is a request for our OAuth authorization flow UI. It is up to the application to
147
- // implement this. However, the OAuthProvider library provides some helpers to assist.
148
-
149
- // `env.OAUTH_PROVIDER.parseAuthRequest()` parses the OAuth authorization request to extract the parameters
150
- // required by the OAuth 2 standard, namely response_type, client_id, redirect_uri, scope, and
151
- // state. It returns an object containing all these (using idiomatic camelCase naming).
152
- let oauthReqInfo = await env.OAUTH_PROVIDER.parseAuthRequest(request);
153
-
154
- // `env.OAUTH_PROVIDER.lookupClient()` looks up metadata about the client, as definetd by RFC-7591. This
155
- // includes things like redirect_uris, client_name, logo_uri, etc.
156
- let clientInfo = await env.OAUTH_PROVIDER.lookupClient(oauthReqInfo.clientId);
157
-
158
- // At this point, the application should use `oauthReqInfo` and `clientInfo` to render an
159
- // authorization consent UI to the user. The details of this are up to the app so are not
160
- // shown here.
161
-
162
- // After the user has granted consent, the application calls `env.OAUTH_PROVIDER.completeAuthorization()` to
163
- // grant the authorization.
164
- let { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
165
- // The application passes back the original OAuth request info that was returned by
166
- // `parseAuthRequest()` earlier.
167
- request: oauthReqInfo,
168
-
169
- // The application must specify the user's ID, which is some sort of string. This is needed
170
- // so that the application can later query the OAuthProvider to enumerate all grants
171
- // belonging to a particular user, e.g. to implement an audit and revocation UI.
172
- userId: '1234',
173
-
174
- // The application can specify some arbitary metadata which describes this grant. The
175
- // metadata can contain any JSON-serializable content. This metadata is not used by the
176
- // OAuthProvider, but the application can read back the metadata attached to specific
177
- // grants when enumerating them later, again e.g. to implement an udit and revocation UI.
178
- metadata: { label: 'foo' },
179
-
180
- // The application specifies the list of OAuth scope identifiers that were granted. This
181
- // may or may not be the same as was requested in `oauthReqInfo.scope`.
182
- scope: ['document.read', 'document.write'],
183
-
184
- // `props` is an arbitrary JSON-serializable object which will be passed back to the API
185
- // handler for every request authorized by this grant.
186
- props: {
187
- userId: 1234,
188
- username: 'Bob',
189
- },
190
- });
191
-
192
- // `completeAuthorization()` will have returned the URL to which the user should be redirected
193
- // in order to complete the authorization flow. This is the requesting client's OAuth
194
- // redirect_uri with the appropriate query parameters added to complete the flow and obtain
195
- // tokens.
196
- return Response.redirect(redirectTo, 302);
197
- }
34
+ ## Quick start
198
35
 
199
- // ... the application can implement other non-API HTTP endpoints here ...
36
+ The provider accepts either plain `ExportedHandler` objects or classes extending `WorkerEntrypoint`. This example uses both.
200
37
 
201
- return new Response('Not found', { status: 404 });
202
- },
203
- };
38
+ ```ts
39
+ import { OAuthProvider, type AuthRequest, type OAuthHelpers } from '@cloudflare/workers-oauth-provider';
40
+ import { WorkerEntrypoint } from 'cloudflare:workers';
204
41
 
205
- // The API handler object - the OAuthProivder will pass authorized API requests to this object's fetch method
206
- // (because we provided it as the `apiHandler` setting, above). This is ONLY called for API requests
207
- // that had a valid access token.
208
- class ApiHandler extends WorkerEntrypoint {
209
- // This fetch method works just like any other WorkerEntrypoint fetch method. The `request` is
210
- // passed as a parameter, while `env` and `ctx` are available as `this.env` and `this.ctx`.
211
- //
212
- // The `this.env.OAUTH_PROVIDER` is available just like in the default handler.
213
- //
214
- // The `this.ctx.props` property contains the `props` value that was passed to
215
- // `env.OAUTH_PROVIDER.completeAuthorization()` during the authorization flow that authorized this client.
216
- fetch(request: Request) {
217
- // The application can implement its API endpoints like normal. This app implements a single
218
- // endpoint, `/api/whoami`, which returns the user's authenticated identity.
219
-
220
- let url = new URL(request.url);
221
- if (url.pathname == '/api/whoami') {
222
- // Since the username is embedded in `ctx.props`, which came from the access token that the
223
- // OAuthProivder already verified, we don't need to do any other authentication steps.
224
- return new Response(`You are authenticated as: ${this.ctx.props.username}`);
225
- }
42
+ interface AuthProps {
43
+ userId: string;
44
+ displayName: string;
45
+ }
46
+
47
+ interface Env {
48
+ OAUTH_KV: KVNamespace;
49
+ OAUTH_PROVIDER: OAuthHelpers;
50
+ }
226
51
 
227
- return new Response('Not found', { status: 404 });
52
+ class McpApiHandler extends WorkerEntrypoint<Env, AuthProps> {
53
+ fetch(request: Request): Response {
54
+ return Response.json({
55
+ authenticated: true,
56
+ userId: this.ctx.props.userId,
57
+ displayName: this.ctx.props.displayName,
58
+ });
228
59
  }
229
60
  }
230
- ```
231
61
 
232
- By default, `completeAuthorization()` revokes existing grants for the same user and client after storing the new
233
- grant. This prevents stale tokens from continuing to use old `props` after a user re-authorizes. Set
234
- `revokeExistingGrants: false` only if your application intentionally allows multiple concurrent grants for the same
235
- user and client.
62
+ const defaultHandler: ExportedHandler<Env> = {
63
+ async fetch(request, env) {
64
+ const url = new URL(request.url);
236
65
 
237
- For users with many grants, `revokeExistingGrantsBatchSize` controls the KV page size used while scanning existing
238
- grants for revocation. It defaults to `50`, must be a positive integer, and is capped at Cloudflare KV's maximum page
239
- size of `1000`.
66
+ if (url.pathname !== '/authorize') {
67
+ return new Response('Not found', { status: 404 });
68
+ }
240
69
 
241
- This implementation requires that your worker is configured with a Workers KV namespace binding called `OAUTH_KV`, which is used to store token information. See the file `storage-schema.md` for details on the schema of this namespace.
70
+ // This parses the OAuth parameters and validates the client, redirect URI,
71
+ // response type, resource indicators, and configured PKCE restrictions.
72
+ let oauthRequest: AuthRequest;
73
+ try {
74
+ oauthRequest = await env.OAUTH_PROVIDER.parseAuthRequest(request);
75
+ } catch {
76
+ // Do not redirect until the client and redirect URI have been validated.
77
+ return new Response('Invalid authorization request', { status: 400 });
78
+ }
242
79
 
243
- The `env.OAUTH_PROVIDER` object available to the fetch handlers provides some methods to query the storage, including:
80
+ const client = await env.OAUTH_PROVIDER.lookupClient(oauthRequest.clientId);
244
81
 
245
- - Create, list, modify, and delete client_id registrations (in addition to `lookupClient()`, already shown in the example code).
246
- - List all active authorization grants for a particular user.
247
- - Revoke (delete) an authorization grant.
248
- - Purge expired and orphaned data from the KV namespace.
82
+ if (!client) {
83
+ return new Response('Unknown OAuth client', { status: 400 });
84
+ }
249
85
 
250
- Note that `deleteClient()` cascades: it revokes all grants (and their associated tokens) for the deleted client across all users.
86
+ // Authenticate the user and obtain consent here. Do not automatically
87
+ // approve a request in production. This example assumes those steps have
88
+ // produced the following user and scope values.
89
+ const user = { id: 'user-123', displayName: 'Ada' };
90
+ const grantedScopes = oauthRequest.scope.filter((scope) => scope === 'mcp:read');
91
+
92
+ const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
93
+ request: oauthRequest,
94
+ userId: user.id,
95
+ metadata: { clientName: client.clientName },
96
+ scope: grantedScopes,
97
+ props: {
98
+ userId: user.id,
99
+ displayName: user.displayName,
100
+ },
101
+ });
251
102
 
252
- See the `OAuthHelpers` interface definition for full API details.
103
+ return Response.redirect(redirectTo, 302);
104
+ },
105
+ };
253
106
 
254
- ## Token Exchange Callback
107
+ export default new OAuthProvider<Env>({
108
+ apiRoute: '/mcp',
109
+ apiHandler: McpApiHandler,
110
+ defaultHandler,
255
111
 
256
- This library allows you to update the `props` value during token exchanges by configuring a callback function. This is useful for scenarios where the application needs to perform additional processing when tokens are issued or refreshed.
112
+ authorizeEndpoint: '/authorize',
113
+ tokenEndpoint: '/oauth/token',
257
114
 
258
- For example, if your application is also a client to some other OAuth API, you might want to perform an equivalent upstream token exchange and store the result in the `props`. The callback can be used to update the props for both the grant record and specific access tokens.
115
+ scopesSupported: ['mcp:read'],
259
116
 
260
- To use this feature, provide a `tokenExchangeCallback` in your OAuthProvider options:
117
+ resourceMetadata: {
118
+ resource: 'https://mcp.example.com/mcp',
119
+ authorization_servers: ['https://mcp.example.com'],
120
+ scopes_supported: ['mcp:read'],
121
+ resource_name: 'Example MCP server',
122
+ },
261
123
 
262
- ```ts
263
- new OAuthProvider({
264
- // ... other options ...
265
- tokenExchangeCallback: async (options) => {
266
- // options.grantType is either 'authorization_code' or 'refresh_token'
267
- // options.props contains the current props
268
- // options.clientId, options.userId, and options.scope are also available
269
-
270
- if (options.grantType === 'authorization_code') {
271
- // For authorization code exchange, might want to obtain upstream tokens
272
- const upstreamTokens = await exchangeUpstreamToken(options.props.someCode);
273
-
274
- return {
275
- // Update the props stored in the access token
276
- accessTokenProps: {
277
- ...options.props,
278
- upstreamAccessToken: upstreamTokens.access_token,
279
- },
280
- // Update the props stored in the grant (for future token refreshes)
281
- newProps: {
282
- ...options.props,
283
- upstreamRefreshToken: upstreamTokens.refresh_token,
284
- },
285
- };
286
- }
124
+ // Preferred for clients with no pre-existing relationship.
125
+ // Also requires global_fetch_strictly_public in wrangler.jsonc.
126
+ clientIdMetadataDocumentEnabled: true,
287
127
 
288
- if (options.grantType === 'refresh_token') {
289
- // For refresh token exchanges, might want to refresh upstream tokens too
290
- const upstreamTokens = await refreshUpstreamToken(options.props.upstreamRefreshToken);
291
-
292
- return {
293
- accessTokenProps: {
294
- ...options.props,
295
- upstreamAccessToken: upstreamTokens.access_token,
296
- },
297
- newProps: {
298
- ...options.props,
299
- upstreamRefreshToken: upstreamTokens.refresh_token || options.props.upstreamRefreshToken,
300
- },
301
- // Optionally override the default access token TTL to match the upstream token
302
- accessTokenTTL: upstreamTokens.expires_in,
303
- };
304
- }
305
- },
128
+ // Optional compatibility fallback. MCP 2026 deprecates DCR for new clients.
129
+ clientRegistrationEndpoint: '/oauth/register',
306
130
  });
307
131
  ```
308
132
 
309
- The callback can:
133
+ ## Protecting routes
310
134
 
311
- - Return both `accessTokenProps` and `newProps` to update both
312
- - Return only `accessTokenProps` to update just the current access token
313
- - Return only `newProps` to update both the grant and access token (the access token inherits these props)
314
- - Return `accessTokenTTL` to override the default TTL for this specific access token
315
- - Return `refreshTokenTTL` to override the default TTL for this specific refresh token
316
- - Return nothing to keep the original props unchanged
135
+ `apiRoute` and `apiHandler` protect one or more route prefixes with a single handler. Use `apiHandlers` when different prefixes need different handlers.
317
136
 
318
- The `accessTokenTTL` override is particularly useful when the application is also an OAuth client to another service and wants to match its access token TTL to the upstream access token TTL. This helps prevent situations where the downstream token is still valid but the upstream token has expired.
137
+ Before calling a protected handler, the provider reads the bearer token, rejects missing, invalid, or expired credentials, checks its audience, and exposes the authenticated application data through `ctx.props`. The handler does not need to parse or validate the token, but it must still enforce application permissions such as scope, ownership, and tenancy.
319
138
 
320
- The `props` values are end-to-end encrypted, so they can safely contain sensitive information.
139
+ Requests outside the protected route prefixes go to `defaultHandler`. In the example above, that handler owns `/authorize`.
321
140
 
322
- ### Reporting errors from the callback
141
+ ## How MCP authorization discovery works
323
142
 
324
- Throw `OAuthError` from `tokenExchangeCallback` to return a structured OAuth `/token` error (`{ error, error_description }`) instead of a generic 500:
143
+ 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).
325
144
 
326
- ```ts
327
- import { OAuthError, OAuthProvider } from '@cloudflare/workers-oauth-provider';
145
+ For an MCP endpoint at `https://mcp.example.com/mcp`:
328
146
 
329
- new OAuthProvider({
330
- // …
331
- tokenExchangeCallback: async (options) => {
332
- if (options.grantType === 'refresh_token') {
333
- return { newProps: await refreshUpstream(options.props) };
334
- }
335
- },
336
- });
147
+ 1. The client sends an unauthenticated request to `/mcp`.
148
+ 2. The provider returns `401 Unauthorized` with a challenge similar to:
337
149
 
338
- async function refreshUpstream(props) {
339
- const res = await fetch(/* upstream token endpoint */);
150
+ ```http
151
+ WWW-Authenticate: Bearer realm="OAuth", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
152
+ ```
340
153
 
341
- if (res.status === 401) {
342
- throw new OAuthError('invalid_grant', {
343
- description: 'upstream refresh token is invalid',
344
- });
345
- }
154
+ 3. The client fetches the protected resource metadata:
346
155
 
347
- if (res.status === 429) {
348
- throw new OAuthError('temporarily_unavailable', {
349
- description: 'upstream rate limited',
350
- statusCode: 429,
351
- headers: { 'Retry-After': res.headers.get('retry-after') ?? '60' },
352
- });
353
- }
156
+ ```text
157
+ https://mcp.example.com/.well-known/oauth-protected-resource/mcp
158
+ ```
354
159
 
355
- return await res.json();
356
- }
357
- ```
160
+ 4. That document identifies one or more authorization server issuers through `authorization_servers`.
161
+ 5. The client fetches this provider's RFC 8414 authorization server metadata:
358
162
 
359
- `OAuthError(code, options)` takes:
163
+ ```text
164
+ https://mcp.example.com/.well-known/oauth-authorization-server
165
+ ```
360
166
 
361
- - `code` — OAuth error code returned in the `error` field. This may be a standard code (`OAuthTokenErrorCode`) or an application-defined string.
362
- - `options.description` — human-readable text returned in `error_description`.
363
- - `options.statusCode` — HTTP status code (default `400`).
364
- - `options.headers` — additional response headers, such as `Retry-After` for transient failures. There is no implicit `Retry-After` default for callback-thrown errors.
167
+ 6. The metadata tells the client where to authorize, exchange tokens, and register if registration is enabled.
365
168
 
366
- Only `OAuthError` from this package is converted into a structured `/token` response. Plain errors, plain objects with a `code` field, and app-local error classes continue to surface as 500s so unexpected failures stay visible. Import `OAuthError` from `@cloudflare/workers-oauth-provider` rather than copying or re-implementing it.
169
+ Protected resource metadata and authorization server metadata serve different roles:
367
170
 
368
- ## Enterprise-Managed Authorization (Experimental)
171
+ - Protected resource metadata describes the MCP server and identifies its authorization servers.
172
+ - Authorization server metadata describes OAuth endpoints and capabilities such as PKCE and CIMD.
369
173
 
370
- Accepts ID-JAG assertions at `/token` per the [MCP Enterprise-Managed Authorization extension](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization). The enterprise IdP issues an ID-JAG JWT and the MCP client exchanges it here for an opaque access token.
174
+ ### Protected resource metadata
371
175
 
372
- ```ts
373
- new OAuthProvider({
374
- // ... other options ...
375
- resourceMetadata: { resource: 'https://mcp.example.com/mcp' },
376
- enterpriseManagedAuthorization: {
377
- trustedIssuers: async ({ iss }) =>
378
- iss === 'https://idp.example.com'
379
- ? { issuer: iss, jwksUri: 'https://idp.example.com/.well-known/jwks.json', algorithms: ['RS256'] }
380
- : null,
381
- async mapClaims({ claims, requestedScope }) {
382
- return {
383
- // Opaque tokens use ':' as a separator — encode subjects that may contain it.
384
- userId: `enterprise-${claims.sub}`,
385
- scope: requestedScope,
386
- metadata: { enterpriseIssuer: claims.iss, enterpriseSubject: claims.sub },
387
- props: { enterprise: true, subject: claims.sub, email: claims.email },
388
- };
389
- },
390
- },
391
- });
392
- ```
176
+ The provider always serves RFC 9728 metadata at:
393
177
 
394
- Setup:
178
+ ```text
179
+ /.well-known/oauth-protected-resource
180
+ ```
395
181
 
396
- 1. Configure your IdP as an ID-JAG issuer with a public JWKS endpoint. If it includes the optional `resource` claim, configure it with the MCP endpoint URL.
397
- 2. Set `resourceMetadata.resource` to the MCP endpoint URL (required when EMA is enabled).
398
- 3. Implement `trustedIssuers` as a resolver — for multi-tenant deployments it can read `env` / `clientInfo` to look up per-tenant IdP config without redeploying.
182
+ It also supports path-specific metadata. A request to:
399
183
 
400
- The AS enforces `resolved.issuer === iss` (confused-deputy guard) and validates ID-JAG `typ`, signature, audience, client binding, any supplied resource, `exp` / `iat` / `nbf`, max lifetime, and `jti` replay. When the optional `resource` claim is omitted, the AS uses `resourceMetadata.resource`, so issued tokens remain pinned to the configured MCP resource. Refresh tokens are not issued for this grant — the ID-JAG itself is the renewable assertion.
184
+ ```text
185
+ /.well-known/oauth-protected-resource/public/mcp
186
+ ```
401
187
 
402
- ### Public clients
188
+ produces `https://example.com/public/mcp` as the derived resource unless `resourceMetadata.resource` overrides it.
403
189
 
404
- By default the EMA grant requires client authentication, so public clients (`token_endpoint_auth_method: 'none'`) are rejected. Set `allowPublicClients: true` to also accept them:
190
+ For MCP deployments, configure the canonical MCP endpoint explicitly:
405
191
 
406
192
  ```ts
407
- enterpriseManagedAuthorization: {
408
- allowPublicClients: true,
409
- // ... trustedIssuers, mapClaims ...
193
+ resourceMetadata: {
194
+ resource: 'https://mcp.example.com/mcp',
195
+ authorization_servers: ['https://auth.example.com'],
196
+ scopes_supported: ['files:read'],
197
+ bearer_methods_supported: ['header'],
198
+ resource_name: 'Files MCP server',
410
199
  }
411
200
  ```
412
201
 
413
- This is useful for clients registered via a [Client ID Metadata Document (CIMD)](https://modelcontextprotocol.io/), which are always public and therefore cannot present a client secret. With this enabled, trust rests on the IdP-issued, signature-verified, short-lived, single-use ID-JAG assertion (audience- and client-bound), together with the provider's configured resource pinning, rather than on a separately presented client secret. Leave it unset (default `false`) to keep the spec-default behavior of requiring client authentication.
202
+ `authorization_servers` may contain more than one issuer. The MCP client chooses an authorization server and must keep credentials and tokens separate for each issuer.
414
203
 
415
- Experimental — the MCP extension is still a draft.
204
+ ### Authorization server metadata
416
205
 
417
- ## Custom Error Responses
206
+ The provider publishes RFC 8414 metadata containing:
418
207
 
419
- By using the `onError` option, you can emit notifications or take other actions when an error response was to be emitted:
208
+ - `issuer`
209
+ - `authorization_endpoint`
210
+ - `token_endpoint`
211
+ - `registration_endpoint`, when DCR is enabled
212
+ - supported response and grant types
213
+ - token endpoint authentication methods
214
+ - PKCE methods
215
+ - revocation endpoint
216
+ - RFC 9207 issuer support
217
+ - CIMD support when it is enabled and safe to use
420
218
 
421
- ```ts
422
- new OAuthProvider({
423
- // ... other options ...
424
- onError({ code, description, status, headers }) {
425
- Sentry.captureMessage(/* ... */);
426
- },
427
- });
428
- ```
219
+ 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.
220
+
221
+ ## Authorization endpoint
222
+
223
+ Your `authorizeEndpoint` belongs to the application's `defaultHandler` because user authentication and consent are application-specific. The provider is not an identity provider.
224
+
225
+ A typical flow has three steps:
226
+
227
+ 1. Call `parseAuthRequest(request)` to validate the client, redirect URI, response type, resource, and PKCE restrictions.
228
+ 2. Authenticate the user, show consent, and decide which scopes to grant.
229
+ 3. Call `completeAuthorization()` and redirect to its returned `redirectTo` URL.
230
+
231
+ `completeAuthorization()` repeats response-type validation before writing a grant or revoking existing grants. The application remains responsible for rendering local authorization errors and for constructing any terminal OAuth error redirect only after client and redirect URI validation.
232
+
233
+ `completeAuthorization()` stores a new grant and, by default, revokes existing grants for the same user and client after the new grant is safely stored. Set `revokeExistingGrants: false` only when the application intentionally allows concurrent grants for the same user and client.
234
+
235
+ 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`.
429
236
 
430
- By returning a `Response` you can also override what the OAuthProvider returns to your users:
237
+ ### Authorization response issuer
238
+
239
+ RFC 9207 issuer identification is always enabled. Authorization server metadata advertises `authorization_response_iss_parameter_supported: true`, and successful authorization responses include `iss` automatically.
240
+
241
+ `parseAuthRequest()` returns the expected `issuer`. If the application creates a terminal OAuth error redirect, include that value:
431
242
 
432
243
  ```ts
433
- new OAuthProvider({
434
- // ... other options ...
435
- onError({ code, description, status, headers }) {
436
- if (code === 'unsupported_grant_type') {
437
- return new Response('...', { status, headers });
438
- }
439
- // returning undefined (i.e. void) uses the default Response generation
440
- },
441
- });
244
+ const oauthRequest = await env.OAUTH_PROVIDER.parseAuthRequest(request);
245
+ const redirect = new URL(oauthRequest.redirectUri);
246
+ redirect.searchParams.set('error', 'access_denied');
247
+ redirect.searchParams.set('state', oauthRequest.state);
248
+ if (oauthRequest.issuer) redirect.searchParams.set('iss', oauthRequest.issuer);
249
+ return Response.redirect(redirect.toString(), 302);
442
250
  ```
443
251
 
444
- By default, the `onError` callback is set to ``({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`)``.
252
+ Intermediate identity-provider redirects and local HTML error pages do not need the OAuth `iss` parameter.
253
+
254
+ ## Client registration
255
+
256
+ [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.
445
257
 
446
- ## KV Namespace Cleanup
258
+ ### Pre-registered clients
447
259
 
448
- The library uses KV TTLs to automatically expire access tokens, refresh tokens (grants), and dynamically registered clients. As defense-in-depth, the library also provides a `purgeExpiredData()` method that cleans up orphaned and expired records. This is designed to be called from a [Cron Trigger](https://developers.cloudflare.com/workers/configuration/cron-triggers/) (scheduled handler):
260
+ Use `OAuthHelpers.createClient()` to create clients through application or administrative code. These clients are stored in KV and are not subject to `clientRegistrationTTL`.
261
+
262
+ ### Client ID Metadata Documents
263
+
264
+ 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.
265
+
266
+ Enable it in both places:
449
267
 
450
268
  ```ts
451
- const oauthProvider = new OAuthProvider({
452
- // ... options ...
269
+ new OAuthProvider({
270
+ // Other options...
271
+ clientIdMetadataDocumentEnabled: true,
453
272
  });
273
+ ```
454
274
 
455
- export default {
456
- fetch(request, env, ctx) {
457
- return oauthProvider.fetch(request, env, ctx);
458
- },
459
- async scheduled(event, env, ctx) {
460
- const result = await oauthProvider.purgeExpiredData(env, { batchSize: 100 });
461
- console.log(`Checked ${result.grantsChecked} grants, purged ${result.grantsPurged}`);
462
- },
463
- };
275
+ ```jsonc
276
+ {
277
+ "compatibility_flags": ["global_fetch_strictly_public"],
278
+ }
464
279
  ```
465
280
 
466
- The method processes records in configurable batches (default: 50) to stay within Cloudflare's subrequest limits. It performs two sweep phases:
281
+ 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.
282
+
283
+ CIMD validation includes:
467
284
 
468
- 1. **Grant sweep**: Removes orphaned grants (whose client no longer exists) and expired grants.
469
- 2. **Token sweep**: Removes orphaned tokens (whose grant no longer exists).
285
+ - HTTPS URL with a non-root path.
286
+ - A document `client_id` exactly matching its URL.
287
+ - Non-empty `client_name` and `redirect_uris` fields.
288
+ - Exact authorization-request redirect URI validation, with RFC 8252 loopback port handling.
289
+ - A 5 KB response size limit and 10 second fetch timeout.
290
+ - Safe URI schemes for client metadata fields.
470
291
 
471
- Call it repeatedly via a cron trigger — deleted records disappear from KV, so subsequent invocations naturally process fresh records without needing a persisted cursor. The `result.done` field indicates whether the full key space was scanned in this invocation.
292
+ CIMD currently supports only `token_endpoint_auth_method: "none"`.
472
293
 
473
- ## Protected Resource Metadata (RFC 9728)
294
+ 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.
474
295
 
475
- The library automatically serves a `/.well-known/oauth-protected-resource` endpoint. By default, it uses the request origin as the resource identifier and the token endpoint's origin as the authorization server. You can customize this with the `resourceMetadata` option:
296
+ ### Dynamic Client Registration
297
+
298
+ Set `clientRegistrationEndpoint` to enable RFC 7591 Dynamic Client Registration:
476
299
 
477
300
  ```ts
478
- new OAuthProvider({
479
- // ... other options ...
480
- resourceMetadata: {
481
- resource: 'https://api.example.com',
482
- authorization_servers: ['https://auth.example.com'],
483
- scopes_supported: ['read', 'write'],
484
- bearer_methods_supported: ['header'],
485
- resource_name: 'My API',
486
- },
487
- });
301
+ clientRegistrationEndpoint: '/oauth/register';
488
302
  ```
489
303
 
490
- ## Standards Compliance
304
+ 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.
491
305
 
492
- This library implements the following OAuth and MCP specifications:
306
+ Registration accepts only authentication methods, grants, and response types implemented by the configured provider, and rejects inconsistent grant/response combinations before storage. Omitted metadata uses the RFC 7591 defaults: `client_secret_basic`, `grant_types: ["authorization_code"]`, and `response_types: ["code"]`.
493
307
 
494
- - [OAuth 2.1 (draft-ietf-oauth-v2-1-13)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13) — Core authorization framework with PKCE
495
- - [OAuth 2.0 Authorization Server Metadata (RFC 8414)](https://datatracker.ietf.org/doc/html/rfc8414) — `/.well-known/oauth-authorization-server` discovery endpoint
496
- - [OAuth 2.0 Protected Resource Metadata (RFC 9728)](https://datatracker.ietf.org/doc/html/rfc9728) — `/.well-known/oauth-protected-resource` discovery endpoint
497
- - [OAuth 2.0 Dynamic Client Registration (RFC 7591)](https://datatracker.ietf.org/doc/html/rfc7591) — Dynamic client registration endpoint
498
- - [OAuth Client ID Metadata Documents (draft-ietf-oauth-client-id-metadata-document)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document) — HTTPS URLs as client IDs
499
- - [MCP Enterprise-Managed Authorization extension](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization) — experimental ID-JAG JWT bearer grant support
308
+ Related options:
500
309
 
501
- These are the specifications required by the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
310
+ - `clientRegistrationTTL` controls the lifetime of dynamically registered clients. The default is 90 days.
311
+ - `disallowPublicClientRegistration` rejects DCR clients using `token_endpoint_auth_method: "none"`.
312
+ - `clientRegistrationCallback` can allow or reject registration based on application policy.
502
313
 
503
- ## Implementation Notes
314
+ Clients created by `OAuthHelpers.createClient()` are not affected by the DCR TTL or public-registration restriction.
504
315
 
505
- ### End-to-end encryption
316
+ ## PKCE and token lifecycle
506
317
 
507
- This library stores records about authorization tokens in KV. The storage schema is carefully designed such that a complete leak of the storage only reveals mundane metadata about what has been granted. In particular:
318
+ Public clients must use PKCE with authorization code flow. PKCE challenges use only S256 by default. Confidential clients may still omit PKCE.
508
319
 
509
- - Secrets (including access tokens, refresh tokens, authorization codes, and client secrets) are stored only by hash. Hence, such secrets cannot be derived from the storage alone.
510
- - The `props` associated with a grant (which are passed back to the application when API requests are performed) are stored encrypted with the secret token as key material. Hence, the contents of `props` are impossible to derive from storage unless a valid token is provided.
320
+ Legacy deployments with clients that cannot use S256 can opt back into plain PKCE:
511
321
 
512
- Note that the `userId` and the `metadata` associated with each grant are not encrypted, because the purpose of these values is to allow grants to be enumerated for audit and revocation purposes. However, these values are completely opaque to the library. An application is free to omit them or apply its own encryption to them before passing them into the library, if it desires.
322
+ ```ts
323
+ allowPlainPKCE: true;
324
+ ```
513
325
 
514
- ### Single-use refresh tokens?
326
+ `allowImplicitFlow` defaults to `false`; leave it disabled for MCP and other new OAuth deployments.
515
327
 
516
- OAuth 2.1 requires that refresh tokens are either "cryptographically bound" to the client, or are single-use. This library currently does not implement any cryptographic binding, thus seemingly requiring single-use tokens. Under this requirement, every token refresh request invalidates the old refresh token and issues a new one.
328
+ 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.
517
329
 
518
- This requirement is seemingly fundamentally flawed as it assumes that every refresh request will complete with no errors. In the real world, a transient network error, machine failure, or software fault could mean that the client fails to store the new refresh token after a refresh request. In this case, the client would be permanently unable to make any further requests, as the only token it has is no longer valid.
330
+ ## Resources and token audiences
519
331
 
520
- This library implements a compromise: At any particular time, a grant may have two valid refresh tokens. When the client uses one of them, the other one is invalidated, and a new one is generated and returned. Thus, if the client correctly uses the new refresh token each time, then older refresh tokens are continuously invalidated. But if a transient failure prevents the client from updating its token, it can always retry the request with the token it used previously.
332
+ MCP clients must send the canonical MCP server URI as `resource` in authorization and token requests. The provider parses RFC 8707 resource indicators, stores the authorized resource on the grant, uses it as the access-token audience, and rejects resource expansion or audience mismatch.
521
333
 
522
- ## Client ID Metadata Document (CIMD) Support
334
+ Resource policy follows `resourceMetadata.resource`:
523
335
 
524
- This library supports [Client ID Metadata Documents](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document), which allow clients to use HTTPS URLs as their `client_id`. When a client presents an HTTPS URL with a non-root path as its `client_id`, the library will fetch and validate the metadata document from that URL.
336
+ - When configured, authorization requests, token requests, and externally resolved tokens must use that one exact resource. `resourceMatchOriginOnly` cannot weaken this policy.
337
+ - When omitted, valid resources are accepted. Token requests may inherit the authorization resource. If the authorization request also omits it, the provider uses the request origin as the default and issues an origin-bound token.
525
338
 
526
- ### Enabling CIMD
339
+ Path-aware audiences use path-boundary prefix matching. A token for `https://example.com/mcp` can be used at `/mcp/tools`, but not at `/mcp-other`. Split deployments and deployments requiring path isolation should configure the canonical resource explicitly.
527
340
 
528
- CIMD support is opt-in and requires two things:
341
+ `resourceMatchOriginOnly` remains a migration option for grants created before path-aware resources were introduced. Do not enable it for a new deployment.
529
342
 
530
- 1. Set `clientIdMetadataDocumentEnabled: true` in your OAuthProvider options:
343
+ ## Scopes and step-up authorization
531
344
 
532
- ```ts
533
- new OAuthProvider({
534
- // ... other options ...
535
- clientIdMetadataDocumentEnabled: true,
536
- });
537
- ```
345
+ `scopesSupported` is published only in authorization server metadata. Configure `resourceMetadata.scopes_supported` explicitly with the minimal scopes required for basic protected-resource functionality and baseline Bearer challenges.
538
346
 
539
- 2. Add the `global_fetch_strictly_public` compatibility flag to your `wrangler.jsonc`:
347
+ The application decides which requested scopes to grant through `completeAuthorization({ scope })`. Token and refresh requests can only narrow those scopes.
540
348
 
541
- ```jsonc
542
- {
543
- "compatibility_flags": ["global_fetch_strictly_public"],
544
- }
545
- ```
349
+ The provider does not expose a standard effective-token authorization context to API handlers or enforce operation-level scope policy. Protected resource metadata supplies baseline scope guidance in Bearer challenges. Advanced integrations can provide operation-specific step-up guidance through external-token validation.
350
+
351
+ ## Advanced features
352
+
353
+ The package also supports:
354
+
355
+ - External API keys and bearer credentials through `resolveExternalToken` as an advanced compatibility feature.
356
+ - Updating encrypted props, token scope, and token lifetimes with `tokenExchangeCallback`.
357
+ - OAuth 2.0 Token Exchange when `allowTokenExchangeGrant` is enabled.
358
+ - Structured callback errors through the exported `OAuthError` and `ExternalTokenError` classes.
359
+ - Custom error observation or responses through `onError`.
360
+ - Experimental MCP Enterprise-Managed Authorization using ID-JAG assertions.
361
+ - Multiple protected handlers through `apiHandlers`.
362
+ - Configurable access token, refresh token, and DCR client lifetimes.
546
363
 
547
- The compatibility flag is required for SSRF (Server-Side Request Forgery) protection. Due to a legacy quirk, `fetch()` requests to URLs within your zone's domain are sent directly to the origin server, bypassing Cloudflare. The `global_fetch_strictly_public` flag disables this behavior. See [Cloudflare's documentation](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public) for more details.
364
+ See [Advanced configuration](https://github.com/cloudflare/workers-oauth-provider/blob/main/docs/advanced-configuration.md) for examples and security notes.
548
365
 
549
- When CIMD is not enabled (the default), URL-formatted `client_id` values fall through to standard KV lookup. When enabled, if fetching the metadata document fails, the library logs a warning and returns an `invalid_client` error, allowing MCP clients to recover by falling back to Dynamic Client Registration.
366
+ ## KV storage and cleanup
550
367
 
551
- The OAuth metadata endpoint reports `client_id_metadata_document_supported: true` only when both the option is enabled and the compatibility flag is present.
368
+ Sensitive values are not stored in plaintext:
552
369
 
553
- ## Written using Claude
370
+ - Access tokens, refresh tokens, authorization codes, and client secrets are stored only by hash.
371
+ - `props` are encrypted with AES-GCM using key material wrapped by the corresponding secret token.
372
+ - Grant `userId` and `metadata` are not encrypted because applications use them to enumerate and revoke grants. Treat those fields as storage-visible metadata.
554
373
 
555
- This library (including the schema documentation) was largely written with the help of [Claude](https://claude.ai), the AI model by Anthropic. Claude's output was thoroughly reviewed by Cloudflare engineers with careful attention paid to security and compliance with standards. Many improvements were made on the initial output, mostly again by prompting Claude (and reviewing the results). Check out the commit history to see how Claude was prompted and what code it produced.
374
+ See [storage-schema.md](https://github.com/cloudflare/workers-oauth-provider/blob/main/storage-schema.md) for the complete KV layout.
556
375
 
557
- **"NOOOOOOOO!!!! You can't just use an LLM to write an auth library!"**
376
+ KV TTLs remove expiring records automatically. `purgeExpiredData()` provides a manual sweep for orphaned or expired grants and tokens:
558
377
 
559
- "haha gpus go brrr"
378
+ ```ts
379
+ const provider = new OAuthProvider({
380
+ // Options...
381
+ });
382
+
383
+ export default {
384
+ fetch(request, env, ctx) {
385
+ return provider.fetch(request, env, ctx);
386
+ },
387
+ async scheduled(_event, env) {
388
+ const result = await provider.purgeExpiredData(env, { batchSize: 100 });
389
+ console.log(result);
390
+ },
391
+ };
392
+ ```
393
+
394
+ The default batch size is 50. `result.done` reports whether both key spaces were scanned completely during that invocation.
395
+
396
+ Deleting a client through `OAuthHelpers.deleteClient()` also revokes its grants and associated tokens across users.
397
+
398
+ ## Configuration reference
399
+
400
+ | Option | Purpose | Default |
401
+ | ---------------------------------- | -------------------------------------------------------- | ------------------------------------------- |
402
+ | `apiRoute` and `apiHandler` | Protect one or more route prefixes with one handler | Use these or `apiHandlers` |
403
+ | `apiHandlers` | Map protected route prefixes to different handlers | Use this or `apiRoute` plus `apiHandler` |
404
+ | `defaultHandler` | Handle authorization UI and other unprotected routes | Required |
405
+ | `authorizeEndpoint` | Application-owned authorization and consent endpoint | Required |
406
+ | `tokenEndpoint` | Provider-owned token and revocation endpoint | Required |
407
+ | `clientRegistrationEndpoint` | Enable RFC 7591 DCR | Disabled |
408
+ | `scopesSupported` | Publish authorization server scopes | Omitted |
409
+ | `resourceMetadata` | Configure RFC 9728 metadata | Derived from the request and token endpoint |
410
+ | `clientIdMetadataDocumentEnabled` | Enable CIMD lookup and advertisement | `false` |
411
+ | `allowPlainPKCE` | Permit the legacy plain PKCE method | `false` |
412
+ | `allowImplicitFlow` | Enable implicit token responses | `false` |
413
+ | `disallowPublicClientRegistration` | Reject public clients at DCR | `false` |
414
+ | `clientRegistrationCallback` | Apply application policy before storing a DCR client | None |
415
+ | `allowTokenExchangeGrant` | Enable RFC 8693 | `false` |
416
+ | `tokenExchangeCallback` | Update props, scopes, or lifetimes during token exchange | None |
417
+ | `resolveExternalToken` | Validate external bearer credentials (advanced) | None |
418
+ | `resourceMatchOriginOnly` | Migration mode for old origin-only resource grants | `false` |
419
+ | `enterpriseManagedAuthorization` | Enable experimental ID-JAG grant support | Disabled |
420
+ | `onError` | Observe or replace OAuth error responses | Logs a warning |
421
+
422
+ Consult the exported `OAuthProviderOptions`, 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.
423
+
424
+ ## OAuth helpers
425
+
426
+ Handlers receive `env.OAUTH_PROVIDER`, which implements `OAuthHelpers`. It can:
427
+
428
+ - Parse authorization requests and complete authorization.
429
+ - Look up, create, list, update, and delete clients.
430
+ - List and revoke grants for a user.
431
+ - Inspect internally issued tokens with `unwrapToken()`.
432
+ - Exchange access tokens when RFC 8693 is enabled.
433
+ - Purge expired and orphaned KV data.
434
+
435
+ `getOAuthApi(options, env)` provides the same helper API outside a fetch handler, including RPC methods and other Worker entrypoints.
436
+
437
+ ## Standards
438
+
439
+ The package implements or supports the relevant portions of:
440
+
441
+ - [MCP authorization, 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization)
442
+ - [OAuth 2.1, draft-ietf-oauth-v2-1-13](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13)
443
+ - [OAuth 2.0 Bearer Token Usage, RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750)
444
+ - [OAuth 2.0 Token Revocation, RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009)
445
+ - [OAuth 2.0 Dynamic Client Registration, RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)
446
+ - [Proof Key for Code Exchange, RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)
447
+ - [OAuth 2.0 Authorization Server Metadata, RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414)
448
+ - [OAuth 2.0 Token Exchange, RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693)
449
+ - [Resource Indicators for OAuth 2.0, RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707)
450
+ - [OAuth 2.0 Authorization Server Issuer Identification, RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207)
451
+ - [OAuth 2.0 Protected Resource Metadata, RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)
452
+ - [OAuth Client ID Metadata Documents](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00)
453
+ - [MCP Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization), with experimental package support
454
+
455
+ ## Development
456
+
457
+ Node 24 or newer is required.
458
+
459
+ ```sh
460
+ npm install
461
+ npm run build
462
+ npm run check
463
+ npm run prettier
464
+ ```
560
465
 
561
- In all seriousness, two months ago (January 2025), I ([@kentonv](https://github.com/kentonv)) would have agreed. I was an AI skeptic. I thought LLMs were glorified Markov chain generators that didn't actually understand code and couldn't produce anything novel. I started this project on a lark, fully expecting the AI to produce terrible code for me to laugh at. And then, uh... the code actually looked pretty good. Not perfect, but I just told the AI to fix things, and it did. I was shocked.
466
+ 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.
562
467
 
563
- To emphasize, **this is not "vibe coded"**. Every line was thoroughly reviewed and cross-referenced with relevant RFCs, by security experts with previous experience with those RFCs. I was _trying_ to validate my skepticism. I ended up proving myself wrong.
468
+ ## Project history
564
469
 
565
- Again, please check out the commit history -- especially early commits -- to understand how this went.
470
+ 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).