@cloudflare/workers-oauth-provider 0.10.4 → 1.1.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
@@ -2,6 +2,8 @@
2
2
 
3
3
  `@cloudflare/workers-oauth-provider` adds OAuth 2.1 authorization to HTTP APIs and remote MCP servers running on Cloudflare Workers.
4
4
 
5
+ > **1.0 is out, with a new split API.** The authorization server (`OAuthAuthorizationServer`) and resource server (`OAuthResourceServer`) are now separate classes that can run in one Worker or several; the [quick start](#quick-start) shows both. The single-Worker `OAuthProvider` is still supported. Upgrading from 0.x? Read the [migration guide](docs/migration-1.0.md), or point your coding agent at the skill in [`skills/migrate-to-1.0/`](skills/migrate-to-1.0/SKILL.md), which also ships in the npm package.
6
+
5
7
  ## Install
6
8
 
7
9
  ```sh
@@ -33,124 +35,154 @@ See [Client registration](#client-registration) for the matching provider option
33
35
 
34
36
  ## Quick start
35
37
 
36
- The provider accepts either plain `ExportedHandler` objects or classes extending `WorkerEntrypoint`. This example uses both.
38
+ An MCP deployment has two roles. The **authorization server** signs users in and issues tokens. The **resource server** is your MCP endpoint: it accepts those tokens and checks them with the authorization server over a [Service Binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/). Each is one class.
39
+
40
+ ### Authorization server Worker
37
41
 
38
42
  ```ts
39
- import {
40
- AuthorizationError,
41
- OAuthProvider,
42
- type AuthRequest,
43
- type OAuthHelpers,
44
- } from '@cloudflare/workers-oauth-provider';
43
+ import { AuthorizationError, OAuthAuthorizationServer, type AuthRequest } from '@cloudflare/workers-oauth-provider';
45
44
  import { WorkerEntrypoint } from 'cloudflare:workers';
46
45
 
46
+ interface Env {
47
+ OAUTH_KV: KVNamespace;
48
+ }
49
+
50
+ const authorizationServer = new OAuthAuthorizationServer<Env>({
51
+ issuer: 'https://auth.example.com',
52
+ resources: ['https://mcp.example.com/mcp'],
53
+ authorizeEndpoint: '/authorize',
54
+ tokenEndpoint: '/oauth/token',
55
+ scopesSupported: ['mcp:read'],
56
+
57
+ // Preferred for clients with no pre-existing relationship.
58
+ // Also requires global_fetch_strictly_public in wrangler.jsonc.
59
+ clientIdMetadataDocumentEnabled: true,
60
+
61
+ // Optional compatibility fallback. MCP 2026 deprecates DCR for new clients.
62
+ clientRegistrationEndpoint: '/oauth/register',
63
+ });
64
+
65
+ async function authorize(request: Request, env: Env): Promise<Response> {
66
+ const oauth = authorizationServer.getOAuthApi(env);
67
+
68
+ // Parses the OAuth parameters and validates the client, redirect URI,
69
+ // response type, resource indicator, and PKCE.
70
+ let oauthRequest: AuthRequest;
71
+ try {
72
+ oauthRequest = await oauth.parseAuthRequest(request);
73
+ } catch (error) {
74
+ if (!(error instanceof AuthorizationError)) throw error;
75
+ if (!error.redirectUri) {
76
+ // Unknown clients and invalid redirects must be rendered locally.
77
+ return new Response(error.description, { status: 400 });
78
+ }
79
+ const redirect = new URL(error.redirectUri);
80
+ redirect.searchParams.set('error', error.code);
81
+ redirect.searchParams.set('error_description', error.description);
82
+ if (error.state) redirect.searchParams.set('state', error.state);
83
+ if (error.issuer) redirect.searchParams.set('iss', error.issuer);
84
+ return Response.redirect(redirect.href, 302);
85
+ }
86
+
87
+ const client = await oauth.lookupClient(oauthRequest.clientId);
88
+ if (!client) return new Response('Unknown OAuth client', { status: 400 });
89
+
90
+ // Authenticate the user and obtain consent here. Never approve automatically
91
+ // in production; this example assumes those steps produced these values.
92
+ const user = { id: 'user-123', displayName: 'Ada' };
93
+ const { redirectTo } = await oauth.completeAuthorization({
94
+ request: oauthRequest,
95
+ userId: user.id,
96
+ metadata: { clientName: client.clientName },
97
+ scope: oauthRequest.scope.filter((scope) => scope === 'mcp:read'),
98
+ props: { userId: user.id, displayName: user.displayName },
99
+ });
100
+ return Response.redirect(redirectTo, 302);
101
+ }
102
+
103
+ export default class AuthServer extends WorkerEntrypoint<Env> {
104
+ // Your /authorize page; everything else (discovery, token, revocation, registration) is the library's.
105
+ fetch(request: Request) {
106
+ if (new URL(request.url).pathname === '/authorize') return authorize(request, this.env);
107
+ return authorizationServer.fetch(request, this.env, this.ctx);
108
+ }
109
+
110
+ // Called by resource Workers over their Service Binding.
111
+ validateToken(resource: string, token: string) {
112
+ return authorizationServer.validateToken(resource, token, this.env);
113
+ }
114
+ }
115
+ ```
116
+
117
+ ### Resource server Worker
118
+
119
+ ```jsonc
120
+ // wrangler.jsonc
121
+ {
122
+ "services": [{ "binding": "AUTH_SERVER", "service": "auth-server" }],
123
+ }
124
+ ```
125
+
126
+ ```ts
127
+ import { OAuthResourceServer, type AuthorizationServerBinding } from '@cloudflare/workers-oauth-provider';
128
+
47
129
  interface AuthProps {
48
130
  userId: string;
49
131
  displayName: string;
50
132
  }
51
133
 
52
134
  interface Env {
53
- OAUTH_KV: KVNamespace;
54
- OAUTH_PROVIDER: OAuthHelpers;
135
+ AUTH_SERVER: AuthorizationServerBinding<AuthProps>;
55
136
  }
56
137
 
57
- class McpApiHandler extends WorkerEntrypoint<Env, AuthProps> {
58
- fetch(request: Request): Response {
59
- return Response.json({
60
- authenticated: true,
61
- userId: this.ctx.props.userId,
62
- displayName: this.ctx.props.displayName,
63
- });
64
- }
65
- }
138
+ export default new OAuthResourceServer<Env, AuthProps>({
139
+ resourceMetadata: {
140
+ resource: 'https://mcp.example.com/mcp',
141
+ authorization_servers: ['https://auth.example.com'],
142
+ scopes_supported: ['mcp:read'],
143
+ resource_name: 'Example MCP server',
144
+ },
145
+ validateToken: (env) => env.AUTH_SERVER.validateToken,
146
+ handler: {
147
+ fetch(request, env, ctx) {
148
+ // ctx.props: what completeAuthorization() stored. ctx.auth: the verified token (scope, userId, clientId, …).
149
+ return Response.json({ userId: ctx.props.userId, scope: ctx.auth.scope });
150
+ },
151
+ },
152
+ });
153
+ ```
66
154
 
67
- const defaultHandler: ExportedHandler<Env> = {
68
- async fetch(request, env) {
69
- const url = new URL(request.url);
155
+ The resource server publishes its RFC 9728 metadata, answers unauthenticated requests with a Bearer challenge that points at it, validates every token for its own resource only, and passes the handler `ctx.props` and `ctx.auth`. The handler still enforces permissions such as ownership and tenancy; `insufficientScope(ctx.auth, scopes)` builds the MCP `403` when a token lacks a scope an operation needs. The binding is not a URL, so the validator is not reachable from the public internet.
70
156
 
71
- if (url.pathname !== '/authorize') {
72
- return new Response('Not found', { status: 404 });
73
- }
157
+ ### More resources, or one Worker
74
158
 
75
- // This parses the OAuth parameters and validates the client, redirect URI,
76
- // response type, resource indicators, and configured PKCE restrictions.
77
- let oauthRequest: AuthRequest;
78
- try {
79
- oauthRequest = await env.OAUTH_PROVIDER.parseAuthRequest(request);
80
- } catch (error) {
81
- if (!(error instanceof AuthorizationError)) throw error;
82
- if (!error.redirectUri) {
83
- // Unknown clients and invalid redirects must be rendered locally.
84
- return new Response(error.description, { status: 400 });
85
- }
86
- const redirect = new URL(error.redirectUri);
87
- redirect.searchParams.set('error', error.code);
88
- redirect.searchParams.set('error_description', error.description);
89
- if (error.state) redirect.searchParams.set('state', error.state);
90
- if (error.issuer) redirect.searchParams.set('iss', error.issuer);
91
- return Response.redirect(redirect, 302);
92
- }
159
+ - **Another MCP resource**: add it to `resources` and deploy another `OAuthResourceServer` with the same binding. A token issued for one resource is refused by every other.
160
+ - **Both roles in one Worker**: construct the `OAuthResourceServer` next to the authorization server with a local validator, `validateToken: (env) => (resource, token) => authorizationServer.validateToken(resource, token, env)`, and route to it from your `fetch`.
93
161
 
94
- const client = await env.OAUTH_PROVIDER.lookupClient(oauthRequest.clientId);
162
+ [docs/resource-servers.md](docs/resource-servers.md) covers both, including a three-domain Hono example, and how to accept another issuer's tokens at your own risk.
95
163
 
96
- if (!client) {
97
- return new Response('Unknown OAuth client', { status: 400 });
98
- }
164
+ ## Single Worker: `OAuthProvider`
99
165
 
100
- // Authenticate the user and obtain consent here. Do not automatically
101
- // approve a request in production. This example assumes those steps have
102
- // produced the following user and scope values.
103
- const user = { id: 'user-123', displayName: 'Ada' };
104
- const grantedScopes = oauthRequest.scope.filter((scope) => scope === 'mcp:read');
105
-
106
- const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
107
- request: oauthRequest,
108
- userId: user.id,
109
- metadata: { clientName: client.clientName },
110
- scope: grantedScopes,
111
- props: {
112
- userId: user.id,
113
- displayName: user.displayName,
114
- },
115
- });
116
-
117
- return Response.redirect(redirectTo, 302);
118
- },
119
- };
166
+ When one Worker is both the authorization server and its only resource, which was the 0.x shape, `OAuthProvider` combines the two roles. Requests to `apiRoute` are protected and reach `apiHandler` with `ctx.props` and `ctx.auth`; everything else that isn't an OAuth endpoint goes to `defaultHandler`, which owns `/authorize` and reaches the same helpers as `env.OAUTH_PROVIDER`:
120
167
 
168
+ ```ts
121
169
  export default new OAuthProvider<Env>({
122
- apiRoute: '/mcp',
123
- apiHandler: McpApiHandler,
124
- defaultHandler,
125
-
170
+ apiRoute: '/mcp', // or apiHandlers: { '/mcp': …, '/mcp/admin': … }
171
+ apiHandler: McpApiHandler, // an object with fetch, or a WorkerEntrypoint class
172
+ defaultHandler, // /authorize, using env.OAUTH_PROVIDER.parseAuthRequest() / completeAuthorization()
126
173
  authorizeEndpoint: '/authorize',
127
174
  tokenEndpoint: '/oauth/token',
128
-
129
175
  scopesSupported: ['mcp:read'],
130
-
131
176
  resourceMetadata: {
132
177
  resource: 'https://mcp.example.com/mcp',
133
178
  authorization_servers: ['https://mcp.example.com'],
134
179
  scopes_supported: ['mcp:read'],
135
- resource_name: 'Example MCP server',
136
180
  },
137
-
138
- // Preferred for clients with no pre-existing relationship.
139
- // Also requires global_fetch_strictly_public in wrangler.jsonc.
140
181
  clientIdMetadataDocumentEnabled: true,
141
-
142
- // Optional compatibility fallback. MCP 2026 deprecates DCR for new clients.
143
- clientRegistrationEndpoint: '/oauth/register',
144
182
  });
145
183
  ```
146
184
 
147
- ## Protecting routes
148
-
149
- `apiRoute` and `apiHandler` protect one or more route prefixes with a single handler. Use `apiHandlers` when different prefixes need different handlers.
150
-
151
- 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.
152
-
153
- Requests outside the protected route prefixes go to `defaultHandler`. In the example above, that handler owns `/authorize`.
185
+ Every protected route must be the canonical `resource` path or a descendant of it; construction rejects anything else.
154
186
 
155
187
  ## How MCP authorization discovery works
156
188
 
@@ -162,7 +194,7 @@ For an MCP endpoint at `https://mcp.example.com/mcp`:
162
194
  2. The provider returns `401 Unauthorized` with a challenge similar to:
163
195
 
164
196
  ```http
165
- WWW-Authenticate: Bearer realm="OAuth", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
197
+ WWW-Authenticate: Bearer realm="OAuth", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp", scope="mcp:read"
166
198
  ```
167
199
 
168
200
  3. The client fetches the protected resource metadata:
@@ -172,10 +204,10 @@ For an MCP endpoint at `https://mcp.example.com/mcp`:
172
204
  ```
173
205
 
174
206
  4. That document identifies one or more authorization server issuers through `authorization_servers`.
175
- 5. The client fetches this provider's RFC 8414 authorization server metadata:
207
+ 5. The client fetches RFC 8414 authorization server metadata from the selected issuer. In the quick start that is:
176
208
 
177
209
  ```text
178
- https://mcp.example.com/.well-known/oauth-authorization-server
210
+ https://auth.example.com/.well-known/oauth-authorization-server
179
211
  ```
180
212
 
181
213
  6. The metadata tells the client where to authorize, exchange tokens, and register if registration is enabled.
@@ -187,21 +219,7 @@ Protected resource metadata and authorization server metadata serve different ro
187
219
 
188
220
  ### Protected resource metadata
189
221
 
190
- The provider always serves RFC 9728 metadata at:
191
-
192
- ```text
193
- /.well-known/oauth-protected-resource
194
- ```
195
-
196
- It also supports path-specific metadata. A request to:
197
-
198
- ```text
199
- /.well-known/oauth-protected-resource/public/mcp
200
- ```
201
-
202
- produces `https://example.com/public/mcp` as the derived resource unless `resourceMetadata.resource` overrides it.
203
-
204
- For MCP deployments, configure the canonical MCP endpoint explicitly:
222
+ Every protected resource needs its own `resourceMetadata.resource`. Configure each canonical HTTPS identifier with a lowercase scheme and host (plain `http` is accepted only on a loopback host, for `wrangler dev`):
205
223
 
206
224
  ```ts
207
225
  resourceMetadata: {
@@ -213,7 +231,17 @@ resourceMetadata: {
213
231
  }
214
232
  ```
215
233
 
216
- `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.
234
+ For the example above, an unauthenticated request to the exact canonical URL receives a Bearer challenge pointing to:
235
+
236
+ ```text
237
+ https://mcp.example.com/.well-known/oauth-protected-resource/mcp
238
+ ```
239
+
240
+ That document returns the configured canonical `resource`. The discovery URL is built from the canonical resource: an origin uses `/.well-known/oauth-protected-resource`, and a path and query are inserted after the well-known prefix.
241
+
242
+ A canonical path is the base audience for its path-boundary descendants: a token for `https://mcp.example.com/mcp` is accepted at `/mcp/tools`, and a challenge at `/mcp/tools` advertises the one canonical document for `/mcp`, as RFC 9728 §5.1 permits. A request on another origin, or one that the canonical resource does not cover, gets a challenge without `resource_metadata`. Every protected route must be the canonical resource path or a descendant of it; the provider rejects any other `apiRoute` or `apiHandlers` key at construction, because a token could never validate there.
243
+
244
+ `authorization_servers` may contain more than one issuer. Each value must use canonical HTTPS issuer spelling: lowercase scheme and host, with no userinfo, default port, dot segments, query, or fragment. As with resources, `http` is accepted only on a loopback host. OAuth issuer comparison is exact. The MCP client chooses an authorization server and must keep credentials and tokens separate for each issuer. `new OAuthResourceServer()` requires it explicitly, wherever the resource runs.
217
245
 
218
246
  ### Authorization server metadata
219
247
 
@@ -222,6 +250,7 @@ The provider publishes RFC 8414 metadata containing:
222
250
  - `issuer`
223
251
  - `authorization_endpoint`
224
252
  - `token_endpoint`
253
+ - `protected_resources`, containing the authorization server's registered canonical resources
225
254
  - `registration_endpoint`, when DCR is enabled
226
255
  - supported response and grant types
227
256
  - token endpoint authentication methods
@@ -242,11 +271,13 @@ A typical flow has three steps:
242
271
  2. Authenticate the user, show consent, and decide which scopes to grant.
243
272
  3. Call `completeAuthorization()` and redirect to its returned `redirectTo` URL.
244
273
 
274
+ [docs/consent-page.md](docs/consent-page.md) shows a safe consent page (what it must display, escaping client metadata, Allow and Deny) and which errors to redirect back to the client and which to render.
275
+
245
276
  `parseAuthRequest()` throws an exported `AuthorizationError` for expected request validation failures. Its optional `redirectUri` is present only after the client and exact registered redirect URI have been validated. Without it, render the error locally and never redirect. With it, the application can safely construct an OAuth error redirect using the error's `code`, `description`, original `state`, and RFC 9207 `issuer`, as shown in the quick start.
246
277
 
247
278
  `completeAuthorization()` repeats response-type validation before writing a grant or revoking existing grants. Validation errors from reconstructed requests are also typed as `AuthorizationError`, but applications should not construct redirects from untrusted reconstructed values; the redirect context is attached only by `parseAuthRequest()`.
248
279
 
249
- `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.
280
+ `completeAuthorization()` stores a new grant and, by default, revokes existing grants for the same user, client, and resource after the new grant is safely stored. A grant for another registered resource is a separate authorization and is not revoked. Set `revokeExistingGrants: false` only when the application intentionally allows concurrent grants within the same resource.
250
281
 
251
282
  For Client ID Metadata Document clients, whose client_id is the metadata URL shared by every installation, default revocation is additionally scoped to grants created from the same redirect URI, so one installation's re-authorization does not revoke another's. Grants created before the redirect URI was recorded are never auto-revoked by CIMD clients.
252
283
 
@@ -324,7 +355,7 @@ clientRegistrationEndpoint: '/oauth/register';
324
355
 
325
356
  MCP 2026-07-28 deprecates DCR for new implementations in favor of CIMD. The endpoint remains useful for compatibility with clients that do not support CIMD.
326
357
 
327
- Registration accepts only authentication methods, grants, and response types implemented by the configured provider, and rejects inconsistent grant/response combinations before storage. Choice-valued `token_endpoint_auth_methods_supported` input is negotiated to one effective `token_endpoint_auth_method`; grant and response registrations remain strict. Omitted metadata uses the RFC 7591 defaults: `client_secret_basic`, `grant_types: ["authorization_code"]`, and `response_types: ["code"]`.
358
+ Registration accepts only authentication methods, grants, and response types implemented by the configured provider, and rejects inconsistent grant/response combinations before storage. Choice-valued `token_endpoint_auth_methods_supported` input is negotiated to one effective `token_endpoint_auth_method`; grant and response registrations remain strict. Omitted metadata uses the RFC 7591 defaults: `client_secret_basic`, `grant_types: ["authorization_code"]`, and `response_types: ["code"]`. The token endpoint enforces each client's registered grant types with `unauthorized_client`; `refresh_token` is implied by `authorization_code`, and a client must register `urn:ietf:params:oauth:grant-type:token-exchange` to use token exchange.
328
359
 
329
360
  The effective `token_endpoint_auth_method` returned by registration is enforced exactly. When both authentication metadata fields are omitted, no explicit-method marker is stored and the client may use either `client_secret_basic` or `client_secret_post`, provided the same stored secret validates. Client records written by earlier releases have no marker and receive the same compatibility. This never crosses between `none` and a secret method and does not apply to CIMD clients.
330
361
 
@@ -332,7 +363,7 @@ Calling `OAuthHelpers.updateClient()` with `tokenEndpointAuthMethod` adds the ma
332
363
 
333
364
  Related options:
334
365
 
335
- - `clientRegistrationTTL` controls the lifetime of dynamically registered clients. The default is 90 days.
366
+ - `clientRegistrationTTL` controls the lifetime of dynamically registered clients. The default is 90 days. A registration still in use does not expire: once it has passed half its lifetime, the next successful token request renews it for the full TTL, so a client that keeps refreshing keeps its `client_id` while an abandoned one is cleaned up. The `client_secret_expires_at` returned at registration describes the initial lifetime; there is no channel to report a renewal, so a client that honours it re-registers on that schedule as before.
336
367
  - `disallowPublicClientRegistration` rejects DCR clients using `token_endpoint_auth_method: "none"`.
337
368
  - `clientRegistrationCallback` can allow or reject registration based on application policy.
338
369
 
@@ -352,21 +383,36 @@ allowPlainPKCE: true;
352
383
 
353
384
  The provider owns `tokenEndpoint`. It exchanges authorization codes for tokens, refreshes access tokens, and handles RFC 7009 revocation. Refresh tokens rotate on use. The immediately previous token remains valid until its replacement is first used, allowing a client to retry after losing a refresh response.
354
385
 
386
+ A grant expires `refreshTokenTTL` seconds after the code exchange (30 days by default) however often it is refreshed. Set `refreshTokenIdleTTL` to make that lifetime slide instead: each successful refresh moves the expiry to that many seconds later, so a grant lives while the client keeps using it and expires once idle. `tokenExchangeCallback` can return `refreshTokenIdleTTL` to set the lifetime for one refresh, which lets a Worker that proxies an upstream OAuth service match the lifetime of the upstream refresh token it just rotated. See [Advanced configuration](docs/advanced-configuration.md#token-and-client-lifetimes).
387
+
355
388
  ## Resources and token audiences
356
389
 
357
- MCP clients are required to send the canonical MCP server URI as `resource` in authorization and token requests. The provider tolerates omission for compatibility: when `resourceMetadata.resource` is configured, it is used as the canonical default and inherited by later token requests; otherwise a token request inherits any resource already stored on the grant. An explicit resource that does not match a bound grant is rejected with `invalid_target`.
390
+ An authorization server may register one or more protected resources. Each resource has one canonical `resourceMetadata.resource`: an absolute HTTPS URI without a fragment, with lowercase `https` and a lowercase host, and an RFC 3986-safe producer serialization. Userinfo, default ports, dot-segment paths, and an empty path before a query are rejected because `Request` would rewrite them before RFC 9728 comparison. A bare origin is the only empty-path exception; use `/` before a query. Query components are supported but discouraged by RFC 9728.
391
+
392
+ For local development, `http` is accepted for resources, `authorization_servers`, the explicit `OAuthAuthorizationServer` issuer, and absolute endpoint URLs only when the host is a loopback address (`localhost`, `127.0.0.0/8`, `::1`), so `wrangler dev` works at `http://localhost:8787`. Any other host must use `https`: Workers are always served over `https`, and OAuth 2.1 requires it. A local MCP client's loopback redirect URI is unaffected by this rule; it is governed by the RFC 8252 loopback handling described under client registration.
358
393
 
359
- Legacy grants may have no stored resource. With no configured canonical resource, omitting `resource` preserves that unbound state. If a client supplies a resource during code exchange or refresh, it applies to that issued token but is not persisted as a new grant binding. Path-aware audiences use path-boundary prefix matching, so a token for `https://example.com/mcp` can be used at `/mcp/tools`, but not at `/mcp-other`.
394
+ Every authorization grant and access token is bound to exactly one registered resource. A central authorization server can therefore issue separate Calendar and Drive tokens from one KV namespace, but it never turns those into one multi-audience bearer token. Completing a new authorization for Drive does not replace the same user and client's Calendar grant.
360
395
 
361
- `resourceMatchOriginOnly` is deprecated; its existing behavior is unchanged. Prefer `resourceMetadata.resource` for new deployments.
396
+ Conforming MCP clients are required to send `resource` in authorization and token requests. Resource selection and compatibility work as follows:
397
+
398
+ - When the authorization server has one registered resource, that sole resource is selected if an authorization request omits `resource`. This preserves existing `OAuthProvider` behavior.
399
+ - When it has multiple registered resources, an authorization request must identify exactly one of them. Set `defaultResource` on `OAuthAuthorizationServer` only when older clients that omit `resource` should be routed to a deliberate compatibility default.
400
+ - An authorization-code or refresh-token request may omit `resource`; the server inherits the resource already stored on the grant. If present, it must match that grant and cannot retarget it.
401
+ - Malformed, unknown, or multi-valued resource input returns `invalid_target` before code consumption, callbacks, refresh rotation, or storage writes.
402
+
403
+ ASCII case differences in the URI scheme and host are accepted, but port, path, query, trailing slash, and array cardinality remain strict. The authorization server always stores and returns the configured lowercase scheme-and-host spelling. The token response includes the selected resource, and the access-token audience contains that resource alone.
404
+
405
+ Token exchange cannot change the resource. Both the subject-token audience and any explicit requested resource must resolve to the same registered canonical value. A token is exchanged by the client its grant was issued to unless `tokenExchangeCallback` returns `allowCrossClientExchange: true`. Internally and externally validated tokens are accepted at a protected route only when their audience matches that route's resource.
406
+
407
+ Path-aware API validation uses path-boundary prefix matching. A canonical audience for `https://example.com/mcp` covers `/mcp` and `/mcp/tools`, but not `/mcp-other`. A canonical trailing slash remains significant.
362
408
 
363
409
  ## Scopes and step-up authorization
364
410
 
365
- `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.
411
+ `scopesSupported` is published only in authorization server metadata. Configure each protected resource's `resourceMetadata.scopes_supported` explicitly with the minimal scopes required for its basic functionality and baseline Bearer challenges.
366
412
 
367
413
  The application decides which requested scopes to grant through `completeAuthorization({ scope })`. Token and refresh requests can only narrow those scopes.
368
414
 
369
- 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.
415
+ Both hosts name `scopes_supported` in the initial `401` challenge, so a client asks for the right scopes first time. Operation-level policy stays in the handler, which reads the token's scopes from `ctx.auth.scope` and answers a shortfall with `insufficientScope(ctx.auth, ['files:write'])`: `403`, `error="insufficient_scope"`, every scope the operation needs in one challenge, and the resource's metadata URL. See [docs/resource-servers.md](docs/resource-servers.md#what-the-handler-sees).
370
416
 
371
417
  ## Advanced features
372
418
 
@@ -375,9 +421,11 @@ The package also supports:
375
421
  - External API keys and bearer credentials through `resolveExternalToken` as an advanced compatibility feature.
376
422
  - Updating encrypted props, token scope, and token lifetimes with `tokenExchangeCallback`.
377
423
  - OAuth 2.0 Token Exchange when `allowTokenExchangeGrant` is enabled.
424
+ - Signing users in through another OAuth provider (GitHub, Google, …) with per-client consent and browser-bound `state`. See [docs/upstream-sign-in.md](docs/upstream-sign-in.md).
378
425
  - Structured callback errors through the exported `OAuthError` and `ExternalTokenError` classes.
379
426
  - Custom error observation or responses through `onError`.
380
427
  - Experimental MCP Enterprise-Managed Authorization using ID-JAG assertions.
428
+ - One authorization server with multiple same-Worker or separately routed MCP resources.
381
429
  - Multiple protected handlers through `apiHandlers`.
382
430
  - Configurable access token, refresh token, and DCR client lifetimes.
383
431
 
@@ -393,6 +441,8 @@ Sensitive values are not stored in plaintext:
393
441
 
394
442
  See [storage-schema.md](https://github.com/cloudflare/workers-oauth-provider/blob/main/storage-schema.md) for the complete KV layout.
395
443
 
444
+ By default `completeAuthorization()` revokes the user's earlier grants for the same client and resource. It finds them from KV key metadata that every grant written by 1.0 or later carries, so the cost is one `list()` per thousand grants the user has, not a read per grant. Grants written before 1.0 are read individually, `revokeExistingGrantsBatchSize` at a time (default 50), until a refresh rewrites them with metadata.
445
+
396
446
  KV TTLs remove expiring records automatically. `purgeExpiredData()` provides a manual sweep for orphaned or expired grants and tokens:
397
447
 
398
448
  ```ts
@@ -417,35 +467,49 @@ Deleting a client through `OAuthHelpers.deleteClient()` also revokes its grants
417
467
 
418
468
  ## Configuration reference
419
469
 
420
- | Option | Purpose | Default |
421
- | ---------------------------------- | -------------------------------------------------------- | ------------------------------------------- |
422
- | `apiRoute` and `apiHandler` | Protect one or more route prefixes with one handler | Use these or `apiHandlers` |
423
- | `apiHandlers` | Map protected route prefixes to different handlers | Use this or `apiRoute` plus `apiHandler` |
424
- | `defaultHandler` | Handle authorization UI and other unprotected routes | Required |
425
- | `authorizeEndpoint` | Application-owned authorization and consent endpoint | Required |
426
- | `tokenEndpoint` | Provider-owned token and revocation endpoint | Required |
427
- | `clientRegistrationEndpoint` | Enable RFC 7591 DCR | Disabled |
428
- | `scopesSupported` | Publish authorization server scopes | Omitted |
429
- | `resourceMetadata` | Configure RFC 9728 metadata | Derived from the request and token endpoint |
430
- | `clientIdMetadataDocumentEnabled` | Enable CIMD lookup and advertisement | `false` |
431
- | `allowPlainPKCE` | Permit the legacy plain PKCE method | `false` |
432
- | `allowImplicitFlow` | Enable implicit token responses | `false` |
433
- | `disallowPublicClientRegistration` | Reject public clients at DCR | `false` |
434
- | `clientRegistrationCallback` | Apply application policy before storing a DCR client | None |
435
- | `allowTokenExchangeGrant` | Enable RFC 8693 | `false` |
436
- | `tokenExchangeCallback` | Update props, scopes, or lifetimes during token exchange | None |
437
- | `resolveExternalToken` | Validate external bearer credentials (advanced) | None |
438
- | `resourceMatchOriginOnly` | Deprecated origin-only resource comparison | `false` |
439
- | `enterpriseManagedAuthorization` | Enable experimental ID-JAG grant support | Disabled |
440
- | `onError` | Observe or replace OAuth error responses | Logs a warning |
441
-
442
- 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.
470
+ The existing `OAuthProvider` combined configuration uses these options:
471
+
472
+ | Option | Purpose | Default |
473
+ | ---------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------- |
474
+ | `apiRoute` and `apiHandler` | Protect one or more route prefixes with one handler | Use these or `apiHandlers` |
475
+ | `apiHandlers` | Map protected route prefixes to different handlers | Use this or `apiRoute` plus `apiHandler` |
476
+ | `defaultHandler` | Handle authorization UI and other unprotected routes | Required |
477
+ | `authorizeEndpoint` | Application-owned authorization and consent endpoint | Required |
478
+ | `tokenEndpoint` | Provider-owned token and revocation endpoint | Required |
479
+ | `clientRegistrationEndpoint` | Enable RFC 7591 DCR | Disabled |
480
+ | `scopesSupported` | Publish authorization server scopes | Omitted |
481
+ | `resourceMetadata.resource` | Canonical HTTPS resource and token audience | Required |
482
+ | `clientIdMetadataDocumentEnabled` | Enable CIMD lookup and advertisement | `false` |
483
+ | `cookiePrefix` | Prefix for the consent and upstream helpers' cookies (must be `__Host-…`) | `__Host-oauth-` |
484
+ | `allowPlainPKCE` | Permit the legacy plain PKCE method | `false` |
485
+ | `allowImplicitFlow` | Enable implicit token responses | `false` |
486
+ | `disallowPublicClientRegistration` | Reject public clients at DCR | `false` |
487
+ | `clientRegistrationCallback` | Apply application policy before storing a DCR client | None |
488
+ | `allowTokenExchangeGrant` | Enable RFC 8693 | `false` |
489
+ | `tokenExchangeCallback` | Update props, scopes, or lifetimes during token exchange | None |
490
+ | `resolveExternalToken` | Validate external bearer credentials (advanced) | None |
491
+ | `enterpriseManagedAuthorization` | Enable experimental ID-JAG grant support | Disabled |
492
+ | `onError` | Observe or replace OAuth error responses; `internal` names the failed check | Logs a warning |
493
+
494
+ The functional role API adds these surfaces without removing `OAuthProvider`:
495
+
496
+ | Surface | Purpose |
497
+ | -------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
498
+ | `new OAuthAuthorizationServer({ issuer, resources, … })` | Create the AS role with a canonical RFC 8414 issuer and its fixed resource registry |
499
+ | `validateToken(resource, token, env)` | Validate an access token for one declared resource; what a resource server calls |
500
+ | `defaultResource` | Select a deliberate default for new authorization requests that omit it |
501
+ | `legacyGrantResource` | Select the server-controlled migration target for old unbound grants |
502
+ | `getOAuthApi(env)` | Obtain OAuth helpers for an application-owned authorization route |
503
+ | `new OAuthResourceServer({ … })` | Host one resource, in this Worker or another; `validateToken` points at the AS or a binding |
504
+
505
+ Consult the exported `OAuthProviderOptions`, `OAuthAuthorizationServerOptions`, resource-server callback interfaces, and JSDoc in [`src/oauth-provider.ts`](https://github.com/cloudflare/workers-oauth-provider/blob/main/src/oauth-provider.ts) for the complete typed API.
443
506
 
444
507
  ## OAuth helpers
445
508
 
446
509
  Handlers receive `env.OAUTH_PROVIDER`, which implements `OAuthHelpers`. It can:
447
510
 
448
511
  - Parse authorization requests and complete authorization.
512
+ - Run a consent page and a third-party sign-in redirect safely (`beginConsent()`, `approveConsent()`, `denyConsent()`, `isConsentRemembered()`, `beginUpstream()`, `finishUpstream()`).
449
513
  - Look up, create, list, update, and delete clients.
450
514
  - List and revoke grants for a user.
451
515
  - Inspect internally issued tokens with `unwrapToken()`.