@cloudflare/workers-oauth-provider 0.10.3 → 1.0.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.
@@ -0,0 +1,370 @@
1
+ # Advanced configuration
2
+
3
+ This guide covers features that are useful for proxying another authorization system, changing token data during exchange, or operating the provider over time. Start with the main [README](../README.md) for MCP discovery, client registration, and a minimal Worker.
4
+
5
+ ## Token exchange callback
6
+
7
+ `tokenExchangeCallback` runs during authorization code and refresh token exchanges. It is useful when the Worker also acts as an OAuth client to an upstream service.
8
+
9
+ ```ts
10
+ new OAuthProvider({
11
+ // Other options...
12
+ tokenExchangeCallback: async (options) => {
13
+ if (options.grantType === 'authorization_code') {
14
+ const upstream = await exchangeUpstream(options.props.authorizationCode);
15
+ return {
16
+ accessTokenProps: {
17
+ ...options.props,
18
+ upstreamAccessToken: upstream.access_token,
19
+ },
20
+ newProps: {
21
+ ...options.props,
22
+ upstreamRefreshToken: upstream.refresh_token,
23
+ },
24
+ accessTokenTTL: upstream.expires_in,
25
+ };
26
+ }
27
+
28
+ if (options.grantType === 'refresh_token') {
29
+ const upstream = await refreshUpstream(options.props.upstreamRefreshToken);
30
+ return {
31
+ accessTokenProps: {
32
+ ...options.props,
33
+ upstreamAccessToken: upstream.access_token,
34
+ },
35
+ newProps: {
36
+ ...options.props,
37
+ upstreamRefreshToken: upstream.refresh_token,
38
+ },
39
+ accessTokenTTL: upstream.expires_in,
40
+ // The upstream just issued a new refresh token; let this grant live as long as it does.
41
+ refreshTokenIdleTTL: upstream.refresh_expires_in,
42
+ };
43
+ }
44
+ },
45
+ });
46
+ ```
47
+
48
+ The callback receives the grant type, client ID, user ID, grant ID, grant scopes, effective requested scopes, and decrypted props. It may return:
49
+
50
+ - `accessTokenProps` for the current access token.
51
+ - `newProps` for the grant and future refreshes.
52
+ - `accessTokenTTL` for the current access token.
53
+ - `refreshTokenTTL` during authorization code exchange.
54
+ - `refreshTokenIdleTTL` during refresh token exchange, to move the grant's expiry to that many seconds from now.
55
+ - `accessTokenScope` to narrow the current token.
56
+
57
+ Return nothing to keep the existing values. If `newProps` is returned without `accessTokenProps`, the current access token also uses `newProps`.
58
+
59
+ Throw `OAuthError` when an upstream failure should become an OAuth token response:
60
+
61
+ ```ts
62
+ throw new OAuthError('temporarily_unavailable', {
63
+ description: 'The upstream authorization server is rate limited',
64
+ statusCode: 429,
65
+ headers: { 'Retry-After': '60' },
66
+ });
67
+ ```
68
+
69
+ Plain errors continue to surface as 500 responses.
70
+
71
+ ## OAuth 2.0 Token Exchange
72
+
73
+ Set `allowTokenExchangeGrant: true` to enable RFC 8693. Clients can exchange an existing access token for a token with narrower scopes or a shorter lifetime. Token exchange cannot change the subject grant's registered canonical resource audience.
74
+
75
+ Application code can also call:
76
+
77
+ ```ts
78
+ await env.OAUTH_PROVIDER.exchangeToken({
79
+ subjectToken,
80
+ scope: ['documents:read'],
81
+ aud: 'https://mcp.example.com/mcp', // Must match the subject grant's resource
82
+ expiresIn: 900,
83
+ });
84
+ ```
85
+
86
+ The new token cannot exceed the subject token's scope ceiling or remaining lifetime. Its subject audience and any `aud` request value must resolve to the same registered resource. Subject-token failures return `invalid_request`, as RFC 8693 §2.2.2 requires.
87
+
88
+ A client must register `urn:ietf:params:oauth:grant-type:token-exchange` in its `grant_types` to use the grant at the token endpoint. A token is exchanged by the client its grant was issued to. Exchanging a token that another client obtained is rejected with `invalid_request` unless `tokenExchangeCallback` allows that specific exchange. The callback sees both parties, so the decision can be per client pair:
89
+
90
+ ```ts
91
+ tokenExchangeCallback: (options) => {
92
+ if (options.grantType === 'urn:ietf:params:oauth:grant-type:token-exchange') {
93
+ // options.clientId is the exchanging client; options.subjectClientId issued the grant.
94
+ const trusted = options.subjectClientId === options.clientId || DELEGATES.has(options.clientId);
95
+ return { allowCrossClientExchange: trusted };
96
+ }
97
+ },
98
+ ```
99
+
100
+ ## Enterprise-managed authorization
101
+
102
+ **Experimental.** The [MCP Enterprise-Managed Authorization extension](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization) is itself young, so the `enterpriseManagedAuthorization` option and its exported types (`EmaValidationError`, trust-policy shapes) are exempt from 1.x semver: they may change in a minor release, with the change documented in the changelog. Everything else on this page is stable API.
103
+
104
+ The token endpoint accepts a validated ID-JAG assertion using the JWT bearer grant and returns an opaque, resource-bound access token.
105
+
106
+ ```ts
107
+ new OAuthProvider({
108
+ // Other options...
109
+ resourceMetadata: { resource: 'https://mcp.example.com/mcp' },
110
+ enterpriseManagedAuthorization: {
111
+ trustedIssuers: async ({ iss }) =>
112
+ iss === 'https://idp.example.com'
113
+ ? {
114
+ issuer: iss,
115
+ jwksUri: 'https://idp.example.com/.well-known/jwks.json',
116
+ algorithms: ['RS256'],
117
+ }
118
+ : null,
119
+ mapClaims: async ({ claims, requestedScope }) => ({
120
+ userId: `enterprise-${encodeURIComponent(claims.sub)}`,
121
+ scope: requestedScope,
122
+ metadata: { issuer: claims.iss, subject: claims.sub },
123
+ props: { subject: claims.sub, email: claims.email },
124
+ }),
125
+ },
126
+ });
127
+ ```
128
+
129
+ The provider validates ID-JAG type, signature, issuer, audience, client binding, resource, timestamps, maximum lifetime, and replay identifier. The audience must be the authorization server issuer as a string or a single-element array. Assertions containing `authorization_details` or `cnf` fail closed until typed authorization-detail and DPoP processing are implemented. Refresh tokens are not issued for this grant.
130
+
131
+ The grant requires client authentication by default. Set `allowPublicClients: true` only when public clients, including CIMD clients, must use it and the ID-JAG trust model is appropriate for the deployment.
132
+
133
+ The default replay marker uses KV and is best effort across Cloudflare locations because KV is eventually consistent. Signature checks, short assertion lifetime, audience, resource, and client binding limit the replay window. The package does not currently expose a custom replay store, so do not describe ID-JAG redemption as globally single-use.
134
+
135
+ ## Client registration policy
136
+
137
+ `clientRegistrationCallback` runs before a DCR client is stored. Return nothing to allow registration, or return an object to reject it:
138
+
139
+ ```ts
140
+ clientRegistrationCallback: async ({ clientMetadata, request }) => {
141
+ if (!(await registrationIsAllowed(clientMetadata, request))) {
142
+ return {
143
+ code: 'access_denied',
144
+ description: 'Client registration is not permitted',
145
+ status: 403,
146
+ };
147
+ }
148
+ };
149
+ ```
150
+
151
+ The callback receives raw client metadata and a clone of the request whose body can still be read. If `software_statement` is present, the application is responsible for verifying it and applying its claims.
152
+
153
+ `disallowPublicClientRegistration` affects DCR only. It does not prevent administrative code from creating a public client through `OAuthHelpers.createClient()`.
154
+
155
+ ## CIMD fetch errors
156
+
157
+ A client ID Metadata Document can fail because of a timeout, network error, upstream response, or invalid document. Those failures are different from a client that does not exist.
158
+
159
+ At the token endpoint, the provider keeps the wire response generic as `invalid_client`. It sends the diagnostic reason to `onError.internal` with category `client-id-metadata-document`, stable reason `metadata_resolution_failed`, the metadata URL, and the underlying message.
160
+
161
+ `OAuthHelpers.lookupClient()`, `parseAuthRequest()`, `completeAuthorization()`, and `exchangeToken()` throw the exported `CimdFetchError` when they cannot resolve a CIMD client. `lookupClient()` returns `null` only when the client does not exist:
162
+
163
+ ```ts
164
+ import { CimdFetchError } from '@cloudflare/workers-oauth-provider';
165
+
166
+ try {
167
+ const client = await env.OAUTH_PROVIDER.lookupClient(clientId);
168
+ } catch (error) {
169
+ if (error instanceof CimdFetchError) {
170
+ console.error(error.reason, error.metadataUrl, error.detail);
171
+ }
172
+ throw error;
173
+ }
174
+ ```
175
+
176
+ Do not log credentials or request bodies when recording these failures.
177
+
178
+ ## Custom error responses
179
+
180
+ `onError` runs whenever the provider is about to return an OAuth error. Use it for logging or monitoring:
181
+
182
+ ```ts
183
+ new OAuthProvider({
184
+ // Other options...
185
+ onError({ code, description, status, headers, internal }) {
186
+ console.warn({ code, description, status, headers, internal });
187
+ },
188
+ });
189
+ ```
190
+
191
+ Return a `Response` to replace the default response. Return nothing to use the provider's RFC-formatted response.
192
+
193
+ ### The internal reason
194
+
195
+ Every OAuth error response the library builds — everything `onError` observes — carries `internal: { category, reason, detail? }`: the exact check that failed, which the wire response deliberately does not reveal (RFC 6749 §5.2). Bare non-OAuth responses (the credential-less `401` challenge, `404`/`405` on metadata URLs) carry no OAuth error and do not run `onError`. It exists only on the path to `onError` and is never sent to the client, so `error_description` can stay generic while logs and alerting key on stable slugs instead of matching text:
196
+
197
+ ```ts
198
+ onError({ code, internal }) {
199
+ metrics.increment(`oauth.${internal.category}.${internal.reason}`);
200
+ },
201
+ ```
202
+
203
+ `category` names the subsystem (kebab-case), `reason` the failed check (snake_case); both are stable across versions — treat additions like new enum members. `detail`, when present, carries structured context such as the caught error, a CIMD fetch diagnosis, or the offending parameter name; it never carries a secret.
204
+
205
+ | Category | Examples of `reason` |
206
+ | ---------------------------------- | --------------------------------------------------------------------------------------------------- |
207
+ | `token-endpoint-request` | `method_not_allowed`, `repeated_parameter`, `grant_type_not_supported`, `grant_type_not_registered` |
208
+ | `client-authentication` | `client_not_found`, `client_secret_mismatch`, `multiple_authentication_methods` |
209
+ | `client-id-metadata-document` | `metadata_resolution_failed` and the other typed CIMD reasons |
210
+ | `authorization-code-grant` | `code_replayed`, `code_verifier_mismatch`, `redirect_uri_mismatch`, `grant_not_found` |
211
+ | `refresh-token-grant` | `refresh_token_mismatch`, `refresh_token_expired`, `client_mismatch`, `grant_not_found` |
212
+ | `token-exchange-grant` | `subject_token_invalid`, `subject_token_near_expiry`, `requested_ttl_too_short` |
213
+ | `enterprise-managed-authorization` | the typed EMA validator reasons (`signature_failed`, `replayed`, `aud_mismatch`, …) |
214
+ | `resource-indicator` | `resource_not_configured`, `resource_grant_mismatch`, `legacy_grant_unbound` |
215
+ | `token-issuance` | `kv_rate_limited`, `requested_ttl_too_short` |
216
+ | `token-revocation` | `token_missing` |
217
+ | `client-registration` | `json_malformed`, `metadata_invalid`, `callback_denied`, `payload_too_large` |
218
+ | `protected-resource` | `token_not_found`, `token_expired`, `audience_mismatch`, `resolver_rejected` |
219
+ | `token-exchange-callback` | `callback_error` — an `OAuthError` thrown by a deployer callback without its own `internal` |
220
+
221
+ `OAuthError(code, options)` supports token-endpoint errors from `tokenExchangeCallback`. `ExternalTokenError(code, options)` supports protected-resource errors from `resolveExternalToken`, including `requiredScopes` for an `insufficient_scope` challenge.
222
+
223
+ Both classes accept a public `description`, `statusCode`, and response `headers`. Only the exported class intended for that callback boundary is converted. Other errors remain unexpected failures. An `OAuthError` may also set `options.internal` to give `onError` its own category and reason; without one it arrives as `{ category: 'token-exchange-callback', reason: 'callback_error', detail: error }`.
224
+
225
+ ## Token and client lifetimes
226
+
227
+ | Option | Default | Notes |
228
+ | ----------------------- | ----------------- | ----------------------------------------------------------------------------------- |
229
+ | `accessTokenTTL` | 3,600 seconds | Must be at least 60 seconds because of KV limits |
230
+ | `refreshTokenTTL` | 2,592,000 seconds | 30 days; set to `0` to disable refresh tokens; explicit `undefined` means no expiry |
231
+ | `refreshTokenIdleTTL` | unset | Sliding expiry: each successful refresh moves the grant's expiry this far out |
232
+ | `clientRegistrationTTL` | 7,776,000 seconds | 90 days for DCR clients, renewed while in use; explicit `undefined` means no expiry |
233
+
234
+ A refresh rotates the token. The newly issued token and the immediately previous token can both recover a refresh whose response was lost. Once the new token is used, the previous token is invalidated and another token is issued.
235
+
236
+ Per-token `accessTokenTTL` and `refreshTokenTTL` overrides are available through `tokenExchangeCallback`.
237
+
238
+ ### Sliding expiry
239
+
240
+ By default a grant's lifetime is fixed at the code exchange: it expires `refreshTokenTTL` seconds later no matter how often it is refreshed, which is the right policy when users must re-authenticate on a schedule. A Worker that proxies an upstream OAuth service usually wants the opposite: the grant should live as long as the upstream credentials it holds, and no longer.
241
+
242
+ `refreshTokenIdleTTL` makes the lifetime slide. Every successful refresh moves the grant's expiry, and the KV expiration of its record, to that many seconds after the refresh. `refreshTokenTTL` still sets a new grant's lifetime, so "30 days to start, then at least weekly" is `refreshTokenTTL: 30 * 86400` with `refreshTokenIdleTTL: 7 * 86400`. A grant that never expired keeps that status until its first refresh, after which it too idles out.
243
+
244
+ Returning `refreshTokenIdleTTL` from `tokenExchangeCallback` sets the lifetime for that one refresh and overrides the option, as in the example above. It sets rather than extends, so returning the upstream's remaining lifetime makes the grant track it exactly; return nothing when the upstream did not rotate and the option, or the fixed lifetime, applies.
245
+
246
+ The slide is committed by the same grant write that rotates the refresh token. A callback that throws fails the refresh before that write, so a failed upstream refresh never renews the downstream grant. A grant that has already expired, including one that expires while a slow callback runs, is rejected with `invalid_grant` and is not revived. If the access-token write after the grant write fails, the grant is already rotated and extended, and the client's previous refresh token is still valid to retry with; that retry slides the expiry again.
247
+
248
+ There is no built-in absolute maximum. A grant with `refreshTokenTTL: undefined` already lives indefinitely, and the callback is the place for lifetime policy: record the authorization time in `props` when the grant is created, and return a shrinking `refreshTokenIdleTTL`, or throw, once the grant is older than you allow.
249
+
250
+ ## KV cleanup
251
+
252
+ KV TTLs remove expiring records automatically. `purgeExpiredData()` provides a defense-in-depth sweep for orphaned or expired grants and tokens:
253
+
254
+ ```ts
255
+ const provider = new OAuthProvider({
256
+ // Options...
257
+ });
258
+
259
+ export default {
260
+ fetch(request, env, ctx) {
261
+ return provider.fetch(request, env, ctx);
262
+ },
263
+ async scheduled(_event, env) {
264
+ const result = await provider.purgeExpiredData(env, { batchSize: 100 });
265
+ console.log(result);
266
+ },
267
+ };
268
+ ```
269
+
270
+ The default batch size is 50. `result.done` reports whether both key spaces were scanned completely during that invocation.
271
+
272
+ Deleting a client through `OAuthHelpers.deleteClient()` also revokes its grants and associated tokens across users.
273
+
274
+ ## Multiple protected handlers
275
+
276
+ Use `apiHandlers` when different route prefixes need different handlers:
277
+
278
+ ```ts
279
+ new OAuthProvider({
280
+ apiHandlers: {
281
+ '/api/users/': UsersApiHandler,
282
+ '/api/documents/': DocumentsApiHandler,
283
+ 'https://api.example.com/': ExternalApiHandler,
284
+ },
285
+ // Other options...
286
+ });
287
+ ```
288
+
289
+ Use either `apiHandlers` or `apiRoute` plus `apiHandler`, not both. Routes can be paths or full URLs.
290
+
291
+ ## Helper access outside fetch
292
+
293
+ `getOAuthApi(options, env)` returns `OAuthHelpers` for RPC methods and other Worker entrypoints that do not receive the injected `env.OAUTH_PROVIDER` value.
294
+
295
+ ## External token resolution
296
+
297
+ `resolveExternalToken` accepts a bearer credential that was not issued or stored by this provider. It runs only after the internal token lookup fails. The credential can be an external OAuth access token, opaque API key, or personal access token (PAT).
298
+
299
+ ### MCP compatibility warning
300
+
301
+ This is an advanced compatibility feature, not the normal MCP authorization flow. The MCP 2026-07-28 specification says:
302
+
303
+ - MCP clients must not send tokens other than ones issued by the MCP server's authorization server.
304
+ - MCP servers must accept only tokens intended for their own resource.
305
+ - If an MCP server calls an upstream API, it must use a separate upstream token and must not forward the token received from the MCP client.
306
+
307
+ Accepting an API key minted by an upstream API directly at the MCP endpoint therefore falls outside the MCP authorization profile. Setting `audience` in the callback applies the provider's local resource policy, but it does not change who issued the credential or make an upstream API key MCP-compliant.
308
+
309
+ Some deployments intentionally use this compatibility pattern so users can present API keys minted by an existing upstream API. `resolveExternalToken` supports that choice, including validation through a trusted upstream endpoint and passing derived identity or permissions to the protected handler. If the handler forwards the same key to access upstream application data, that is token passthrough under the MCP security guidance and must not be described as MCP-conformant.
310
+
311
+ The preferred MCP design is to issue a local, audience-bound access token for the MCP server and keep any separate upstream credential in encrypted `props`. When compatibility requires direct upstream keys, restrict validation and use to fixed upstream hosts, request the narrowest permissions possible, never log the key, do not include it in errors or metadata, and make the non-conformant trust model explicit to operators.
312
+
313
+ ### Validating an upstream API key
314
+
315
+ ```ts
316
+ import { ExternalTokenError, OAuthProvider } from '@cloudflare/workers-oauth-provider';
317
+
318
+ const MCP_RESOURCE = 'https://mcp.example.com/mcp';
319
+
320
+ new OAuthProvider({
321
+ // Other options...
322
+ resourceMetadata: { resource: MCP_RESOURCE },
323
+
324
+ resolveExternalToken: async ({ token, request, env }) => {
325
+ const result = await validateUpstreamApiKey(token, request, env);
326
+
327
+ if (result.kind === 'invalid') return null;
328
+ if (result.kind === 'insufficient_scope') {
329
+ throw new ExternalTokenError('insufficient_scope', {
330
+ description: 'The API key needs account:read permission',
331
+ statusCode: 403,
332
+ requiredScopes: ['account:read'],
333
+ });
334
+ }
335
+ if (result.kind === 'rate_limited') {
336
+ throw new ExternalTokenError('temporarily_unavailable', {
337
+ description: 'API key validation is temporarily rate limited',
338
+ statusCode: 429,
339
+ headers: { 'Retry-After': result.retryAfter },
340
+ });
341
+ }
342
+
343
+ return {
344
+ props: {
345
+ upstreamSubject: result.subject,
346
+ permissions: result.permissions,
347
+ },
348
+
349
+ // An opaque key has no MCP audience claim. This is an explicit local
350
+ // policy binding applied only after successful validation.
351
+ audience: MCP_RESOURCE,
352
+ };
353
+ },
354
+ });
355
+ ```
356
+
357
+ The callback can:
358
+
359
+ - Return `{ props, audience }` to authenticate. The audience is required, must be a single string, and must identify the configured canonical `resourceMetadata.resource`; scheme and host comparisons are ASCII case-insensitive and an empty path equals `/`, while port, path, query, and trailing slash are strict. An array is rejected.
360
+ - Return `null` for a generic `401 invalid_token` response.
361
+ - Throw the exported `ExternalTokenError` for an intentional structured response.
362
+
363
+ `audience` means the local protected resource where the credential is accepted. It does not mean the issuer, user, upstream API, or permissions. For an opaque key, the callback supplies this as a local policy decision after validation. Do not use an upstream API URL as the audience for requests to the Worker.
364
+
365
+ Unexpected errors, plain objects, `OAuthError`, and app-local lookalike classes are re-thrown so validator bugs remain visible as 500 responses. Existing callback behavior is unchanged unless the callback deliberately throws this package's `ExternalTokenError`.
366
+
367
+ References:
368
+
369
+ - [MCP access token handling](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#token-handling)
370
+ - [MCP access token privilege restriction](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations#access-token-privilege-restriction)
@@ -0,0 +1,97 @@
1
+ # Migrating from 0.x to 1.0
2
+
3
+ 1.0 binds every grant and access token to one canonical resource (RFC 8707 / RFC 9728) and adds role classes for multi-Worker deployments. Stored 0.x data keeps working — there is no KV migration. For most deployments the code diff is one field.
4
+
5
+ ## Am I affected?
6
+
7
+ - **Single Worker on `OAuthProvider`** (the common shape): add `resourceMetadata: { resource }` if you don't have it. Usually that is the whole migration.
8
+ - **You set `resourceMatchOriginOnly`**: remove it; construction now rejects it.
9
+ - **You use `resolveExternalToken`**: return `audience` (now required).
10
+ - **You registered clients over DCR with an explicit narrow `grant_types`**: registered grant types are now enforced at the token endpoint.
11
+ - **Your clients send a different `redirect_uri` at code exchange than at authorization**: OAuth 2.1 §4.1.3 is now enforced.
12
+ - **Nothing else**: existing grants, refresh tokens, access tokens and authorization codes keep working under the legacy rules below.
13
+
14
+ ## The canonical resource is required
15
+
16
+ ```diff
17
+ export default new OAuthProvider<Env>({
18
+ apiRoute: ['/mcp'],
19
+ apiHandler,
20
+ defaultHandler,
21
+ authorizeEndpoint: '/authorize',
22
+ tokenEndpoint: '/oauth/token',
23
+ + resourceMetadata: { resource: 'https://mcp.example.com/mcp' },
24
+ });
25
+ ```
26
+
27
+ `resource` is the URL your MCP clients connect to: an absolute HTTPS URI with lowercase scheme and host, no fragment, userinfo, default port, or dot segments. A bare origin (`https://mcp.example.com`) is allowed and covers every path. `http` is accepted only on loopback hosts, so `wrangler dev` works at `http://localhost:8787`.
28
+
29
+ Construction validates the whole configuration against it and throws with a named rule when something is off:
30
+
31
+ - Every `apiRoute` / `apiHandlers` key must be the resource's path or a path-boundary descendant (`/mcp` covers `/mcp` and `/mcp/tools`, not `/mcp-other`); absolute routes must be on the resource's origin.
32
+ - The resource must not sit inside `/.well-known/oauth-protected-resource`.
33
+
34
+ In 0.x the metadata document was derived per request when `resourceMetadata` was omitted; in 1.0 the configured resource is the identity every token is bound to, so it cannot be implicit.
35
+
36
+ ## Removed: `resourceMatchOriginOnly`
37
+
38
+ A configuration that still sets it fails at construction. Audiences are now compared exactly against the canonical resource, with two tolerances: ASCII case in scheme and host is folded, and an empty path equals `/` (RFC 3986 §6.2.3). Port, path, query, and trailing slash stay strict.
39
+
40
+ ## `resolveExternalToken` names its audience
41
+
42
+ `ResolveExternalTokenResult.audience` is required and a single string, and must be the configured canonical resource — a token accepted for another audience is a 401:
43
+
44
+ ```diff
45
+ resolveExternalToken: async ({ token }) => {
46
+ const session = await upstream.introspect(token);
47
+ if (!session) return null;
48
+ - return { props: { userId: session.sub } };
49
+ + return { props: { userId: session.sub }, audience: 'https://mcp.example.com/mcp' };
50
+ },
51
+ ```
52
+
53
+ ## Registered grant types are enforced
54
+
55
+ A token request for a grant type the client did not register fails with `unauthorized_client`. `refresh_token` is implied by `authorization_code`; token exchange must be registered explicitly (and enabled with `allowTokenExchangeGrant`). This is a data-side change: clients registered under 0.x with a deliberately narrow `grant_types` array may now be refused where 0.x ignored the field.
56
+
57
+ ## Token exchange
58
+
59
+ - A subject token is exchanged by the client its grant was issued to. The cross-client case requires `tokenExchangeCallback` to return `allowCrossClientExchange: true`; the callback receives `subjectClientId` to decide.
60
+ - Subject-token failures return `invalid_request`.
61
+ - The exchange cannot retarget the resource: the subject token's audience and any explicit `resource` parameter must resolve to the same registered value.
62
+
63
+ ## `redirect_uri` is bound to the authorization request
64
+
65
+ OAuth 2.1 §4.1.3: the `redirect_uri` presented at code exchange must equal the one used in the authorization request (0.x accepted any registered URI). Without PKCE, `redirect_uri` is required at exchange.
66
+
67
+ ## One grant per user, client, and resource
68
+
69
+ Completing a new authorization replaces the user and client's earlier grant _for the same resource_ only. In 0.x replacement was per user and client, so a central server issuing tokens for several resources no longer drops one resource's grant when the user authorizes another.
70
+
71
+ ## Existing stored data — nothing to do
72
+
73
+ - An access token stored without an audience keeps working until it expires. It is treated as bound to the migration resource: the sole configured resource, or `legacyGrantResource` on a multi-resource server.
74
+ - Refresh binds the grant to that resource and returns a bound replacement token.
75
+ - A stored 0.x audience array resolves to the registered resource it contains.
76
+ - A grant bound only to unregistered values fails refresh with `invalid_grant`; conformant clients (Claude, the MCP SDKs) answer that by starting a fresh authorization.
77
+ - A multi-resource `OAuthAuthorizationServer` without `legacyGrantResource` has no safe destination for unbound records and rejects them; set it for the migration window and keep it fixed.
78
+ - Authorization codes issued by 0.x redeem under the same rules.
79
+
80
+ ## Type-level changes
81
+
82
+ | 0.x | 1.0 |
83
+ | --------------------------------------------------- | ------------------- |
84
+ | `ExchangeTokenOptions.aud?: string \| string[]` | `aud?: string` |
85
+ | `AuthRequest.resource?: string \| string[]` | `resource?: string` |
86
+ | `TokenExchangeCallbackOptions.resource` (array-ish) | single `string` |
87
+ | `ResolveExternalTokenResult.audience?` (optional) | required `string` |
88
+
89
+ ## New in 1.0, adopt when useful
90
+
91
+ None of these require changes to a migrated 0.x deployment:
92
+
93
+ - **Role classes** — `OAuthAuthorizationServer` (one AS, many resources) and `OAuthResourceServer` (host a resource in the AS Worker or its own, validating over a Service Binding). See [resource-servers.md](resource-servers.md).
94
+ - **`ctx.auth` and `insufficientScope()`** — handlers see the verified token facts beside `ctx.props` and answer scope shortfalls with the MCP `403` challenge. See the README's scopes section.
95
+ - **`onError.internal`** — every library error carries a stable `{ category, reason }` for logs and alerting; the wire stays generic.
96
+ - **`refreshTokenIdleTTL`** — opt-in sliding refresh-token expiry.
97
+ - Dynamically registered clients in active use renew automatically; grant listing and revocation are KV-bounded.
@@ -0,0 +1,153 @@
1
+ # Resource servers
2
+
3
+ An `OAuthAuthorizationServer` issues tokens; a resource server accepts them. Every resource, whether it runs in the authorization server's Worker or in its own, is hosted the same way:
4
+
5
+ ```ts
6
+ new OAuthResourceServer<Env, Props>({
7
+ resourceMetadata: { resource, authorization_servers: [issuer], scopes_supported: ['calendar:read'] },
8
+ validateToken: (env, request) => (resource, token) =>
9
+ Promise<{ props; audience; expiresAt?; scope?; userId?; clientId? } | null>,
10
+ handler: { fetch(request, env, ctx) {} }, // ctx.props: Props, ctx.auth: OAuthResourceAuth
11
+ });
12
+ ```
13
+
14
+ The host publishes RFC 9728 metadata at `/.well-known/oauth-protected-resource<path>`, answers unauthenticated requests with a Bearer challenge that names it and the `scopes_supported` to ask for, calls your validator with its own canonical resource and the presented token, refuses a result whose `audience` is not that resource, and answers `503` when the validator throws. Only `validateToken` changes between the topologies below.
15
+
16
+ ## What the handler sees
17
+
18
+ `ctx.props` is the application data the validator returned. `ctx.auth` is what was verified about the token: `{ token, audience, expiresAt?, scope, userId?, clientId? }`. `OAuthAuthorizationServer.validateToken()` fills all of it; a validator of your own reports what it knows and `scope` defaults to `[]`.
19
+
20
+ Scope policy is the handler's. When a valid token lacks what an operation needs, answer with `insufficientScope`, which builds the MCP scope challenge — `403`, `error="insufficient_scope"`, every scope the operation requires, and the same `resource_metadata` URL the `401` advertised — so the client can step up in one round trip:
21
+
22
+ ```ts
23
+ handler: {
24
+ fetch(request, env, ctx) {
25
+ if (request.method === 'DELETE' && !ctx.auth.scope.includes('calendar:write')) {
26
+ return insufficientScope(ctx.auth, ['calendar:write']);
27
+ }
28
+ // …
29
+ },
30
+ },
31
+ ```
32
+
33
+ A `WorkerEntrypoint` handler reads the same fields from `this.ctx`; declare it as `OAuthResourceContext<Props>` to type them. `OAuthProvider` sets `ctx.auth` for its `apiHandler` too, from its own token record, so a handler moves between the two hosts unchanged.
34
+
35
+ ## Same Worker
36
+
37
+ ```ts
38
+ const authorizationServer = new OAuthAuthorizationServer<Env>({
39
+ issuer: 'https://auth.example.com',
40
+ resources: ['https://calendar.example.com/mcp', 'https://drive.example.com/mcp'],
41
+ authorizeEndpoint: '/authorize',
42
+ tokenEndpoint: '/oauth/token',
43
+ });
44
+
45
+ const local = (env: Env) => (resource: string, token: string) =>
46
+ authorizationServer.validateToken(resource, token, env);
47
+
48
+ const calendar = new OAuthResourceServer<Env, AuthProps>({
49
+ resourceMetadata: {
50
+ resource: 'https://calendar.example.com/mcp',
51
+ authorization_servers: ['https://auth.example.com'],
52
+ },
53
+ validateToken: local,
54
+ handler: calendarHandler,
55
+ });
56
+ ```
57
+
58
+ You own routing. This example (`npm install hono`) puts one Worker on three custom domains and uses Hono's hostname-aware path so no `switch` is needed; the original `Request` is forwarded as `c.req.raw` so URL validation sees the real origin:
59
+
60
+ ```ts
61
+ const app = new Hono<{ Bindings: Env }>({
62
+ getPath: (request) => `/${new URL(request.url).hostname}${new URL(request.url).pathname}`,
63
+ });
64
+
65
+ app.get('/auth.example.com/authorize', async (c) => {
66
+ const oauth = authorizationServer.getOAuthApi(c.env);
67
+ const request = await oauth.parseAuthRequest(c.req.raw); // render AuthorizationError safely in production
68
+ const { redirectTo } = await oauth.completeAuthorization({
69
+ request,
70
+ userId: 'user-123',
71
+ metadata: {},
72
+ scope: request.scope,
73
+ props: { userId: 'user-123', scopes: request.scope },
74
+ });
75
+ return c.redirect(redirectTo);
76
+ });
77
+ app.all('/auth.example.com/*', (c) => authorizationServer.fetch(c.req.raw, c.env, c.executionCtx));
78
+ app.all('/calendar.example.com/*', (c) => calendar.fetch(c.req.raw, c.env, c.executionCtx));
79
+ app.all('/drive.example.com/*', (c) => drive.fetch(c.req.raw, c.env, c.executionCtx));
80
+
81
+ export default app;
82
+ ```
83
+
84
+ ```jsonc
85
+ {
86
+ "workers_dev": false,
87
+ "routes": [
88
+ { "pattern": "auth.example.com", "custom_domain": true },
89
+ { "pattern": "calendar.example.com", "custom_domain": true },
90
+ { "pattern": "drive.example.com", "custom_domain": true },
91
+ ],
92
+ }
93
+ ```
94
+
95
+ Authorization server metadata advertises every declared resource in `protected_resources`; each resource publishes its own protected resource metadata pointing back at the issuer. `resources` is fixed at construction and `defaultResource` and `legacyGrantResource` are checked against it then; a resource server that asks about an undeclared resource gets a rejection, which the host turns into `503`.
96
+
97
+ ## Separate Workers
98
+
99
+ Only the authorization server can validate a token: the props are encrypted with a key wrapped by the token itself, and the token record lives in its KV. A resource Worker therefore asks it, over a Service Binding.
100
+
101
+ Authorization Worker, exposing the method from a `WorkerEntrypoint`:
102
+
103
+ ```ts
104
+ import { WorkerEntrypoint } from 'cloudflare:workers';
105
+
106
+ export default class AuthServer extends WorkerEntrypoint<Env> {
107
+ fetch(request: Request) {
108
+ // Your /authorize route goes here too; everything else is the authorization server's.
109
+ return authorizationServer.fetch(request, this.env, this.ctx);
110
+ }
111
+ validateToken(resource: string, token: string) {
112
+ return authorizationServer.validateToken(resource, token, this.env);
113
+ }
114
+ }
115
+ ```
116
+
117
+ Resource Worker, with a binding to it:
118
+
119
+ ```jsonc
120
+ { "services": [{ "binding": "AUTH_SERVER", "service": "auth" }] }
121
+ ```
122
+
123
+ ```ts
124
+ import { OAuthResourceServer, type AuthorizationServerBinding } from '@cloudflare/workers-oauth-provider';
125
+
126
+ interface Env {
127
+ AUTH_SERVER: AuthorizationServerBinding<AuthProps>; // or Service<AuthServer> from wrangler types
128
+ }
129
+
130
+ export default new OAuthResourceServer<Env, AuthProps>({
131
+ resourceMetadata: {
132
+ resource: 'https://calendar.example.com/mcp',
133
+ authorization_servers: ['https://auth.example.com'],
134
+ },
135
+ validateToken: (env) => env.AUTH_SERVER.validateToken,
136
+ handler,
137
+ });
138
+ ```
139
+
140
+ The host calls the method it is handed with its resource and the token, so neither is repeated. The binding is not a URL: the validator is never exposed to the public internet, and the resource Worker cannot ask about another resource's tokens by accident because the host always passes its own. One RPC per request, on Cloudflare's network. `ctx.props` is the same `AuthProps` the authorization flow stored, decrypted by the authorization server; `ctx.auth` carries the token's scopes, subject and client back with it; and revocation is immediate.
141
+
142
+ ## Another issuer, at your own risk
143
+
144
+ `validateToken` is just a function. A resource that accepts tokens from an authorization server that is not this package validates them itself — an RFC 7662 introspection call, a JWT library against that issuer's JWKS — and returns `{ props, audience, expiresAt }`:
145
+
146
+ ```ts
147
+ validateToken: (env) => async (resource, token) => {
148
+ const { payload } = await jwtVerify(token, keys, { issuer: OTHER_ISSUER, audience: resource, typ: 'at+jwt' });
149
+ return { props: { userId: payload.sub! }, audience: resource, expiresAt: payload.exp };
150
+ },
151
+ ```
152
+
153
+ The host still enforces the audience and expiry it is given, and it fails closed on a malformed `scope`, `userId` or `clientId`. Everything else about that issuer's tokens is between you and it. MCP's security guidance is blunt on the point that a resource server must accept only tokens issued for it; keep `audience` honest.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "0.10.3",
3
+ "version": "1.0.0",
4
4
  "description": "OAuth provider for Cloudflare Workers",
5
5
  "main": "dist/oauth-provider.js",
6
6
  "types": "dist/oauth-provider.d.ts",
@@ -8,7 +8,9 @@
8
8
  "license": "MIT",
9
9
  "sideEffects": false,
10
10
  "files": [
11
- "dist"
11
+ "dist",
12
+ "docs",
13
+ "skills"
12
14
  ],
13
15
  "type": "module",
14
16
  "publishConfig": {