@cloudflare/workers-oauth-provider 1.0.0 → 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,108 +35,25 @@ 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
 
47
- interface AuthProps {
48
- userId: string;
49
- displayName: string;
50
- }
51
-
52
46
  interface Env {
53
47
  OAUTH_KV: KVNamespace;
54
- OAUTH_PROVIDER: OAuthHelpers;
55
48
  }
56
49
 
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
- }
66
-
67
- const defaultHandler: ExportedHandler<Env> = {
68
- async fetch(request, env) {
69
- const url = new URL(request.url);
70
-
71
- if (url.pathname !== '/authorize') {
72
- return new Response('Not found', { status: 404 });
73
- }
74
-
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
- }
93
-
94
- const client = await env.OAUTH_PROVIDER.lookupClient(oauthRequest.clientId);
95
-
96
- if (!client) {
97
- return new Response('Unknown OAuth client', { status: 400 });
98
- }
99
-
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
- };
120
-
121
- export default new OAuthProvider<Env>({
122
- apiRoute: '/mcp',
123
- apiHandler: McpApiHandler,
124
- defaultHandler,
125
-
50
+ const authorizationServer = new OAuthAuthorizationServer<Env>({
51
+ issuer: 'https://auth.example.com',
52
+ resources: ['https://mcp.example.com/mcp'],
126
53
  authorizeEndpoint: '/authorize',
127
54
  tokenEndpoint: '/oauth/token',
128
-
129
55
  scopesSupported: ['mcp:read'],
130
56
 
131
- resourceMetadata: {
132
- resource: 'https://mcp.example.com/mcp',
133
- authorization_servers: ['https://mcp.example.com'],
134
- scopes_supported: ['mcp:read'],
135
- resource_name: 'Example MCP server',
136
- },
137
-
138
57
  // Preferred for clients with no pre-existing relationship.
139
58
  // Also requires global_fetch_strictly_public in wrangler.jsonc.
140
59
  clientIdMetadataDocumentEnabled: true,
@@ -142,65 +61,128 @@ export default new OAuthProvider<Env>({
142
61
  // Optional compatibility fallback. MCP 2026 deprecates DCR for new clients.
143
62
  clientRegistrationEndpoint: '/oauth/register',
144
63
  });
145
- ```
146
64
 
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.
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
+ }
150
86
 
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` and what it verified about the token through `ctx.auth` (`scope`, `userId`, `clientId`, `audience`, `expiresAt`). The handler does not need to parse or validate the token, but it still enforces application permissions such as scope, ownership, and tenancy; `insufficientScope(ctx.auth, scopes)` builds the MCP `403` challenge when a token lacks what an operation needs.
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
+ }
152
102
 
153
- Requests outside the protected route prefixes go to `defaultHandler`. In the example above, that handler owns `/authorize`.
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
+ }
154
109
 
155
- ## One authorization server with multiple MCP resources
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
+ ```
156
116
 
157
- `OAuthAuthorizationServer` is the authorization-server role on its own: discovery, token, revocation and registration endpoints from `fetch()`, the interactive flow through `getOAuthApi()`, and `validateToken(resource, token, env)` for any resource in its fixed `resources` registry. Each resource is hosted by `new OAuthResourceServer()`, whose `validateToken` option points back at the authorization server — in this Worker or another. Same host, same `ctx.props`, wherever the resource runs.
117
+ ### Resource server Worker
158
118
 
159
- **Same Worker.** Route the authorization server's origin to `authorizationServer.fetch()`, your `/authorize` page to `getOAuthApi()`, and each resource to its host. The validator is a direct call:
119
+ ```jsonc
120
+ // wrangler.jsonc
121
+ {
122
+ "services": [{ "binding": "AUTH_SERVER", "service": "auth-server" }],
123
+ }
124
+ ```
160
125
 
161
126
  ```ts
162
- const authorizationServer = new OAuthAuthorizationServer<Env>({
163
- issuer: 'https://auth.example.com',
164
- resources: ['https://calendar.example.com/mcp'],
165
- authorizeEndpoint: '/authorize',
166
- tokenEndpoint: '/oauth/token',
167
- });
127
+ import { OAuthResourceServer, type AuthorizationServerBinding } from '@cloudflare/workers-oauth-provider';
168
128
 
169
- const calendar = new OAuthResourceServer<Env, AuthProps>({
129
+ interface AuthProps {
130
+ userId: string;
131
+ displayName: string;
132
+ }
133
+
134
+ interface Env {
135
+ AUTH_SERVER: AuthorizationServerBinding<AuthProps>;
136
+ }
137
+
138
+ export default new OAuthResourceServer<Env, AuthProps>({
170
139
  resourceMetadata: {
171
- resource: 'https://calendar.example.com/mcp',
140
+ resource: 'https://mcp.example.com/mcp',
172
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
+ },
173
151
  },
174
- validateToken: (env) => (resource, token) => authorizationServer.validateToken(resource, token, env),
175
- handler: { fetch: (_request, _env, ctx) => Response.json({ userId: ctx.props.userId }) },
176
152
  });
177
153
  ```
178
154
 
179
- **Separate Workers.** The authorization Worker exposes `validateToken` from a `WorkerEntrypoint`; the resource Worker holds a Service Binding to it and hands the host that method. Nothing else to configure, and the validator is a binding, not a URL — it is not reachable from the public internet:
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.
180
156
 
181
- ```ts
182
- // auth Worker
183
- export default class AuthServer extends WorkerEntrypoint<Env> {
184
- fetch(request: Request) {
185
- return authorizationServer.fetch(request, this.env, this.ctx);
186
- }
187
- validateToken(resource: string, token: string) {
188
- return authorizationServer.validateToken(resource, token, this.env);
189
- }
190
- }
157
+ ### More resources, or one Worker
191
158
 
192
- // calendar Worker, with `"services": [{ "binding": "AUTH_SERVER", "service": "auth" }]` in wrangler.jsonc
193
- export default new OAuthResourceServer<Env, AuthProps>({
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`.
161
+
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.
163
+
164
+ ## Single Worker: `OAuthProvider`
165
+
166
+ When one Worker is both the authorization server and its only resource, which was the 0.x shape, `OAuthProvider` combines the two roles. Requests to `apiRoute` are protected and reach `apiHandler` with `ctx.props` and `ctx.auth`; everything else that isn't an OAuth endpoint goes to `defaultHandler`, which owns `/authorize` and reaches the same helpers as `env.OAUTH_PROVIDER`:
167
+
168
+ ```ts
169
+ export default new OAuthProvider<Env>({
170
+ apiRoute: '/mcp', // or apiHandlers: { '/mcp': …, '/mcp/admin': … }
171
+ apiHandler: McpApiHandler, // an object with fetch, or a WorkerEntrypoint class
172
+ defaultHandler, // /authorize, using env.OAUTH_PROVIDER.parseAuthRequest() / completeAuthorization()
173
+ authorizeEndpoint: '/authorize',
174
+ tokenEndpoint: '/oauth/token',
175
+ scopesSupported: ['mcp:read'],
194
176
  resourceMetadata: {
195
- resource: 'https://calendar.example.com/mcp',
196
- authorization_servers: ['https://auth.example.com'],
177
+ resource: 'https://mcp.example.com/mcp',
178
+ authorization_servers: ['https://mcp.example.com'],
179
+ scopes_supported: ['mcp:read'],
197
180
  },
198
- validateToken: (env) => env.AUTH_SERVER.validateToken,
199
- handler,
181
+ clientIdMetadataDocumentEnabled: true,
200
182
  });
201
183
  ```
202
184
 
203
- Either way the resource server publishes its own RFC 9728 metadata, issues Bearer challenges that point at it, checks the returned audience against its canonical resource, and answers `503` when validation infrastructure fails. Tokens are opaque throughout; a validator returns the decrypted `props` the authorization flow stored. See [docs/resource-servers.md](docs/resource-servers.md) for the three-domain Hono example, the `AuthorizationServerBinding` type for your `Env`, and how to validate tokens from another issuer at your own risk.
185
+ Every protected route must be the canonical `resource` path or a descendant of it; construction rejects anything else.
204
186
 
205
187
  ## How MCP authorization discovery works
206
188
 
@@ -222,10 +204,10 @@ For an MCP endpoint at `https://mcp.example.com/mcp`:
222
204
  ```
223
205
 
224
206
  4. That document identifies one or more authorization server issuers through `authorization_servers`.
225
- 5. The client fetches RFC 8414 authorization server metadata from the selected issuer. In the single-origin quick start that is:
207
+ 5. The client fetches RFC 8414 authorization server metadata from the selected issuer. In the quick start that is:
226
208
 
227
209
  ```text
228
- https://mcp.example.com/.well-known/oauth-authorization-server
210
+ https://auth.example.com/.well-known/oauth-authorization-server
229
211
  ```
230
212
 
231
213
  6. The metadata tells the client where to authorize, exchange tokens, and register if registration is enabled.
@@ -289,6 +271,8 @@ A typical flow has three steps:
289
271
  2. Authenticate the user, show consent, and decide which scopes to grant.
290
272
  3. Call `completeAuthorization()` and redirect to its returned `redirectTo` URL.
291
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
+
292
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.
293
277
 
294
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()`.
@@ -422,23 +406,6 @@ Token exchange cannot change the resource. Both the subject-token audience and a
422
406
 
423
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.
424
408
 
425
- ### Upgrading to 1.0
426
-
427
- The step-by-step guide, including an "Am I affected" checklist and an agent skill (`skills/migrate-to-1.0/`), is [docs/migration-1.0.md](docs/migration-1.0.md). The compatibility rules it relies on:
428
-
429
- The existing combined `OAuthProvider` configuration has one `resourceMetadata.resource`. That sole resource automatically acts as both the omitted-authorization default and the migration destination for grants created before resource binding, so existing single-resource clients can continue without adding a `resource` parameter.
430
-
431
- For a multi-resource `OAuthAuthorizationServer`, `defaultResource` and `legacyGrantResource` solve different compatibility problems:
432
-
433
- - `defaultResource` selects the resource for a new authorization request that omits `resource`.
434
- - `legacyGrantResource` is the server-controlled migration destination for an old stored grant or access token that has no resource. A client-supplied token-request parameter cannot choose or change this destination. It is deployment policy rather than an issuance-time claim, so changing it re-targets every surviving unbound record; keep it fixed for the migration window.
435
-
436
- Both values must name a declared resource and are checked at construction. If a multi-resource server omits `legacyGrantResource`, an old unbound grant cannot be migrated safely. A stored grant already bound to a registered resource keeps that resource, and a stored 0.x array that contains the registered resource resolves to it. A grant bound only to unregistered values fails its refresh with `invalid_grant`, which conformant clients answer by starting a new authorization.
437
-
438
- Previously issued access tokens with no audience keep working until they expire. They are treated as bound to the server-selected migration resource (the sole resource, or `legacyGrantResource`), and refresh binds the grant and returns a bound replacement token. A multi-resource server without `legacyGrantResource` has no safe destination, so it rejects such tokens and their refresh grants must be reauthorized. Multiple resources can share the same authorization server, provider implementation, and KV namespace; separate storage is an optional deployment boundary, not a resource-binding requirement.
439
-
440
- The 1.0 API removes `resourceMatchOriginOnly`, and a configuration that still sets it fails at construction. Canonical matching with scheme/host case tolerance replaces it.
441
-
442
409
  ## Scopes and step-up authorization
443
410
 
444
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.
@@ -454,6 +421,7 @@ The package also supports:
454
421
  - External API keys and bearer credentials through `resolveExternalToken` as an advanced compatibility feature.
455
422
  - Updating encrypted props, token scope, and token lifetimes with `tokenExchangeCallback`.
456
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).
457
425
  - Structured callback errors through the exported `OAuthError` and `ExternalTokenError` classes.
458
426
  - Custom error observation or responses through `onError`.
459
427
  - Experimental MCP Enterprise-Managed Authorization using ID-JAG assertions.
@@ -512,6 +480,7 @@ The existing `OAuthProvider` combined configuration uses these options:
512
480
  | `scopesSupported` | Publish authorization server scopes | Omitted |
513
481
  | `resourceMetadata.resource` | Canonical HTTPS resource and token audience | Required |
514
482
  | `clientIdMetadataDocumentEnabled` | Enable CIMD lookup and advertisement | `false` |
483
+ | `cookiePrefix` | Prefix for the consent and upstream helpers' cookies (must be `__Host-…`) | `__Host-oauth-` |
515
484
  | `allowPlainPKCE` | Permit the legacy plain PKCE method | `false` |
516
485
  | `allowImplicitFlow` | Enable implicit token responses | `false` |
517
486
  | `disallowPublicClientRegistration` | Reject public clients at DCR | `false` |
@@ -540,6 +509,7 @@ Consult the exported `OAuthProviderOptions`, `OAuthAuthorizationServerOptions`,
540
509
  Handlers receive `env.OAUTH_PROVIDER`, which implements `OAuthHelpers`. It can:
541
510
 
542
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()`).
543
513
  - Look up, create, list, update, and delete clients.
544
514
  - List and revoke grants for a user.
545
515
  - Inspect internally issued tokens with `unwrapToken()`.
@@ -439,6 +439,50 @@ declare class OAuthResourceServer<Env = Cloudflare.Env, Props = unknown> {
439
439
  fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response>;
440
440
  }
441
441
  //#endregion
442
+ //#region src/oauth-consent.d.ts
443
+ /** Remember an approval so the consent page can be skipped for requests it already covers. */
444
+ interface RememberConsentOptions {
445
+ /** HMAC key for the approvals cookie: at least 32 characters, from a Worker secret. */
446
+ secret: string;
447
+ /** How long an approval is remembered. Defaults to 30 days. */
448
+ maxAgeSeconds?: number;
449
+ /**
450
+ * The signed-in user, when you know them before consent (your own sign-in). The approval is then
451
+ * bound to them as well, so another account on the same browser is asked again. Without it an
452
+ * approval belongs to the browser, which suits a proxy server that only learns the user from the
453
+ * third party after consent.
454
+ */
455
+ subject?: string;
456
+ }
457
+ /** A consent page to render: post `handle` back to {@link approveConsent}; send `headers` with the page. */
458
+ interface ConsentTransaction {
459
+ handle: string;
460
+ headers: Headers;
461
+ }
462
+ /** The approved authorization request, with the cookies to set on the next response. */
463
+ interface ApprovedConsent {
464
+ request: AuthRequest;
465
+ headers: Headers;
466
+ }
467
+ /** Where to send the browser after the user declined, with the cookies to set on that redirect. */
468
+ interface DeniedConsent {
469
+ request: AuthRequest;
470
+ /** The client's redirect URI with `error=access_denied`, its `state`, and `iss` (RFC 9207). */
471
+ redirectTo: string;
472
+ headers: Headers;
473
+ }
474
+ /** The `state` to send to the third-party provider, with the binding cookie to set on the redirect. */
475
+ interface UpstreamTransaction {
476
+ state: string;
477
+ headers: Headers;
478
+ }
479
+ /** The authorization request and data saved by `beginUpstream()`, recovered at the callback. */
480
+ interface ResumedUpstream<Data = unknown> {
481
+ request: AuthRequest;
482
+ data: Data;
483
+ headers: Headers;
484
+ }
485
+ //#endregion
442
486
  //#region src/oauth-resource.d.ts
443
487
  /** Validate an RFC 3986-safe HTTP(S) resource identifier for RFC 8707. */
444
488
  declare function validateResourceUri(uri: string): boolean;
@@ -879,6 +923,12 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
879
923
  * Defaults to false.
880
924
  */
881
925
  clientIdMetadataDocumentEnabled?: boolean;
926
+ /**
927
+ * Prefix for the cookies the consent and upstream helpers set (`<prefix>consent`,
928
+ * `<prefix>upstream`, `<prefix>approvals`). Must start with `__Host-`, which keeps them
929
+ * `Secure`, host-only and `Path=/`. Defaults to `__Host-oauth-`.
930
+ */
931
+ cookiePrefix?: string;
882
932
  /**
883
933
  * Metadata for RFC 9728 OAuth 2.0 Protected Resource Metadata.
884
934
  * Controls the response served at /.well-known/oauth-protected-resource.
@@ -956,6 +1006,53 @@ interface OAuthHelpers {
956
1006
  completeAuthorization(options: CompleteAuthorizationOptions): Promise<{
957
1007
  redirectTo: string;
958
1008
  }>;
1009
+ /**
1010
+ * Whether an approval remembered by `approveConsent(…, { remember })` covers this request: the
1011
+ * same client, redirect URI and resource, asking for a subset of the approved scopes. Pass the
1012
+ * same `secret`, and the same `subject` if the approval was bound to a user. Don't call it to ask
1013
+ * on every authorization.
1014
+ */
1015
+ isConsentRemembered(request: Request, authRequest: AuthRequest, remember: Pick<RememberConsentOptions, 'secret' | 'subject'>): Promise<boolean>;
1016
+ /**
1017
+ * Start a consent page for a request. Render your page with `handle` in the form and send
1018
+ * `headers` with it: they bind the handle to this browser and forbid framing the page.
1019
+ */
1020
+ beginConsent(authRequest: AuthRequest): Promise<ConsentTransaction>;
1021
+ /**
1022
+ * Accept the consent form. `handle` is the value your form posted; the browser's binding cookie
1023
+ * must match it, and it works once. `scope` is what the user approved: fewer or more than the
1024
+ * client requested, each one in `scopesSupported`. `remember` stores the approval in a signed
1025
+ * cookie for `isConsentRemembered()`. Send the returned `headers` on the next response.
1026
+ * @throws AuthorizationError when the handle is missing, unbound, expired, or already used
1027
+ */
1028
+ approveConsent(request: Request, handle: string, options?: {
1029
+ scope?: string[];
1030
+ remember?: RememberConsentOptions;
1031
+ }): Promise<ApprovedConsent>;
1032
+ /**
1033
+ * Decline the consent form: consumes the handle like `approveConsent()` and returns the redirect
1034
+ * back to the client with `error=access_denied`, its `state` and `iss`. Send `headers` with the
1035
+ * redirect (they include `Location`).
1036
+ * @throws AuthorizationError when the handle is missing, unbound, expired, or already used
1037
+ */
1038
+ denyConsent(request: Request, handle: string, options?: {
1039
+ description?: string;
1040
+ }): Promise<DeniedConsent>;
1041
+ /**
1042
+ * Save an approved request before redirecting to a third-party provider, and get the `state`
1043
+ * to send it. Call only after consent. `data` is returned at the callback, e.g. a PKCE verifier.
1044
+ * Pass `headers` to add the binding cookie to headers you are already sending.
1045
+ */
1046
+ beginUpstream(authRequest: AuthRequest, options?: {
1047
+ data?: unknown;
1048
+ headers?: Headers;
1049
+ }): Promise<UpstreamTransaction>;
1050
+ /**
1051
+ * At the third-party provider's callback, recover the approved request and your `data` from the
1052
+ * `state` parameter. The browser's binding cookie must match, and it works once.
1053
+ * @throws AuthorizationError when `state` is missing, unbound, expired, or already used
1054
+ */
1055
+ finishUpstream<Data = unknown>(request: Request): Promise<ResumedUpstream<Data>>;
959
1056
  /**
960
1057
  * Creates a new OAuth client
961
1058
  * @param clientInfo - Partial client information to create the client with
@@ -1675,6 +1772,7 @@ interface OAuthErrorOptions {
1675
1772
  * async function refreshUpstream(props) {
1676
1773
  * const res = await fetch(...);
1677
1774
  * if (res.status === 401) {
1775
+ * // invalid_grant can never recover: the provider also revokes this grant and its tokens.
1678
1776
  * throw new OAuthError('invalid_grant', { description: 'upstream refresh token is invalid' });
1679
1777
  * }
1680
1778
  * if (res.status === 429) {
@@ -1796,4 +1894,4 @@ declare function getJwtCryptoAlgorithms(alg: string): {
1796
1894
  verifyAlgorithm: Parameters<SubtleCrypto['verify']>[0];
1797
1895
  };
1798
1896
  //#endregion
1799
- export { AuthRequest, AuthorizationError, type AuthorizationErrorCode, type AuthorizationErrorOptions, AuthorizationServerBinding, CimdFetchError, ClientInfo, ClientRegistrationCallbackOptions, ClientRegistrationCallbackResult, CompleteAuthorizationOptions, type EmaClaimsMapper, type EmaClaimsMapperInput, type EmaClaimsMapperResult, type EmaIdJagClaims, type EmaOptions, type EmaTrustedIssuer, type EmaTrustedIssuerResolver, type EmaTrustedIssuerResolverInput, type EmaValidationError, ExchangeTokenOptions, ExternalTokenError, ExternalTokenErrorOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthAuthorizationServer, OAuthAuthorizationServerOptions, OAuthError, OAuthErrorInternal, OAuthErrorOptions, OAuthHelpers, OAuthProtectedResourceMetadata, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, OAuthResourceAuth, OAuthResourceContext, OAuthResourceHandler, OAuthResourceMetadata, OAuthResourceServer, OAuthResourceServerOptions, OAuthResourceTokenValidation, OAuthResourceTokenValidator, OAuthTokenErrorCode, PurgeOptions, PurgeResult, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, ValidatedAccessToken, base64UrlToBytes, getJwtCryptoAlgorithms, getOAuthApi, insufficientScope, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
1897
+ export { type ApprovedConsent, AuthRequest, AuthorizationError, type AuthorizationErrorCode, type AuthorizationErrorOptions, AuthorizationServerBinding, CimdFetchError, ClientInfo, ClientRegistrationCallbackOptions, ClientRegistrationCallbackResult, CompleteAuthorizationOptions, type ConsentTransaction, type DeniedConsent, type EmaClaimsMapper, type EmaClaimsMapperInput, type EmaClaimsMapperResult, type EmaIdJagClaims, type EmaOptions, type EmaTrustedIssuer, type EmaTrustedIssuerResolver, type EmaTrustedIssuerResolverInput, type EmaValidationError, ExchangeTokenOptions, ExternalTokenError, ExternalTokenErrorOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthAuthorizationServer, OAuthAuthorizationServerOptions, OAuthError, OAuthErrorInternal, OAuthErrorOptions, OAuthHelpers, OAuthProtectedResourceMetadata, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, OAuthResourceAuth, OAuthResourceContext, OAuthResourceHandler, OAuthResourceMetadata, OAuthResourceServer, OAuthResourceServerOptions, OAuthResourceTokenValidation, OAuthResourceTokenValidator, OAuthTokenErrorCode, PurgeOptions, PurgeResult, type RememberConsentOptions, ResolveExternalTokenInput, ResolveExternalTokenResult, type ResumedUpstream, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, type UpstreamTransaction, ValidatedAccessToken, base64UrlToBytes, getJwtCryptoAlgorithms, getOAuthApi, insufficientScope, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
@@ -678,7 +678,7 @@ function emaErrorToWire(e) {
678
678
  * only through the adapters' public interfaces.
679
679
  */
680
680
  /** SHA-256 a string and return its hex digest. */
681
- async function sha256Hex(input) {
681
+ async function sha256Hex$1(input) {
682
682
  const data = new TextEncoder().encode(input);
683
683
  const buffer = await crypto.subtle.digest("SHA-256", data);
684
684
  return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -701,7 +701,7 @@ const EMA_JTI_KV_PREFIX = "enterprise-jti:";
701
701
  function createKvJtiStore() {
702
702
  return { async markUsed({ issuer, jti, exp, now, env }) {
703
703
  const ttl = Math.max(EMA_JTI_MIN_TTL_SECONDS, exp - now);
704
- const key = `${EMA_JTI_KV_PREFIX}${await sha256Hex(`${issuer}\n${jti}`)}`;
704
+ const key = `${EMA_JTI_KV_PREFIX}${await sha256Hex$1(`${issuer}\n${jti}`)}`;
705
705
  if (await env.OAUTH_KV.get(key)) return err({
706
706
  reason: "replayed",
707
707
  jti
@@ -1549,6 +1549,317 @@ function appendHeaderValue$1(headers, name, value) {
1549
1549
  headers.set(name, values.join(", "));
1550
1550
  }
1551
1551
 
1552
+ //#endregion
1553
+ //#region src/oauth-consent.ts
1554
+ /**
1555
+ * Consent and upstream-state primitives for authorization servers that sign users in through a
1556
+ * third-party OAuth provider (an MCP "proxy" server). They implement the protections the MCP
1557
+ * 2026-07-28 security best practices require under Confused Deputy → Mitigation:
1558
+ *
1559
+ * - consent per client before any redirect to the third party, with CSRF protection and anti-framing
1560
+ * headers on the consent page;
1561
+ * - remembered consent, chosen per call, in a signed `__Host-` cookie bound to the client, its redirect
1562
+ * URI and resource (and the user, when the caller knows them), reused only for a subset of the
1563
+ * approved scopes;
1564
+ * - the approved scopes chosen on the consent page, from any the server supports;
1565
+ * - a random `state` stored server-side only after consent, bound to the browser by a `__Host-` cookie,
1566
+ * single-use and short-lived.
1567
+ *
1568
+ * A transaction handle is 256 random bits. KV stores the record under the handle's SHA-256, encrypted
1569
+ * with a key derived from the handle, so KV alone reveals neither the request nor deployer data such as
1570
+ * a PKCE verifier. Each transaction has its own binding cookie, named after its hash and holding the
1571
+ * full hash, so several authorizations can be in flight in one browser (two tabs) without replacing each
1572
+ * other. Single use is `get` then `delete`, which KV cannot make atomic: two concurrent requests from the
1573
+ * same browser with the same handle could both pass; the cookie binding confines that to the browser
1574
+ * that started the transaction.
1575
+ */
1576
+ const TRANSACTION_TTL_SECONDS = 600;
1577
+ /** Default for the provider's `cookiePrefix` option. */
1578
+ const DEFAULT_COOKIE_PREFIX = "__Host-oauth-";
1579
+ const DEFAULT_REMEMBER_SECONDS = 720 * 60 * 60;
1580
+ const MIN_SECRET_LENGTH = 32;
1581
+ const MAX_APPROVALS_COOKIE_BYTES = 3800;
1582
+ /** Throws a `TypeError` unless the prefix keeps the `__Host-` guarantees the MCP best practices require. */
1583
+ function consentCookies(prefix = DEFAULT_COOKIE_PREFIX) {
1584
+ if (typeof prefix !== "string" || !prefix.startsWith("__Host-") || !/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(prefix)) throw new TypeError("cookiePrefix must start with \"__Host-\" and contain only cookie-name characters");
1585
+ return {
1586
+ consent: `${prefix}consent`,
1587
+ upstream: `${prefix}upstream`,
1588
+ approvals: `${prefix}approvals`
1589
+ };
1590
+ }
1591
+ function validateRememberConsentOptions(options) {
1592
+ if (typeof options !== "object" || options === null) throw new TypeError("remember must be an object with a secret");
1593
+ if (typeof options.secret !== "string" || options.secret.length < MIN_SECRET_LENGTH) throw new TypeError(`remember.secret must be a string of at least ${MIN_SECRET_LENGTH} characters`);
1594
+ const maxAge = options.maxAgeSeconds;
1595
+ if (maxAge !== void 0 && (!Number.isInteger(maxAge) || maxAge <= 0)) throw new TypeError("remember.maxAgeSeconds must be a positive integer");
1596
+ if (options.subject !== void 0 && (typeof options.subject !== "string" || options.subject.length === 0)) throw new TypeError("remember.subject must be a non-empty string");
1597
+ }
1598
+ /** Start a consent transaction for an authorization request that must be shown to the user. */
1599
+ async function beginConsent(kv, cookies, request) {
1600
+ const { handle, hash } = await createTransaction(kv, {
1601
+ kind: "consent",
1602
+ request
1603
+ });
1604
+ return {
1605
+ handle,
1606
+ headers: new Headers({
1607
+ "Set-Cookie": bindingCookie(transactionCookieName(cookies.consent, hash), hash, TRANSACTION_TTL_SECONDS),
1608
+ "Cache-Control": "no-store",
1609
+ "Content-Security-Policy": "frame-ancestors 'none'",
1610
+ "X-Frame-Options": "DENY"
1611
+ })
1612
+ };
1613
+ }
1614
+ /**
1615
+ * Consume a consent transaction the user approved. `scope`, when given, replaces the requested
1616
+ * scopes: the page may narrow them or offer more, but each must be one the server supports
1617
+ * (`supportedScopes`, from `scopesSupported`). `remember` stores the approval in a signed cookie.
1618
+ */
1619
+ async function approveConsent(kv, cookies, supportedScopes, request, handle, options = {}) {
1620
+ if (options.remember !== void 0) validateRememberConsentOptions(options.remember);
1621
+ const transaction = await openTransaction(kv, request, cookies.consent, handle, "consent");
1622
+ let approved = transaction.record.request;
1623
+ if (options.scope !== void 0) {
1624
+ const supported = supportedScopes ? new Set(supportedScopes) : void 0;
1625
+ const valid = (scope) => typeof scope === "string" && isValidOAuthScopeToken(scope) && (!supported || supported.has(scope));
1626
+ if (!Array.isArray(options.scope) || !options.scope.every(valid)) throw new AuthorizationError("invalid_scope", { description: "Approved scopes must be ones this server supports" });
1627
+ approved = {
1628
+ ...approved,
1629
+ scope: [...new Set(options.scope)]
1630
+ };
1631
+ }
1632
+ await transaction.consume();
1633
+ const headers = new Headers({ "Cache-Control": "no-store" });
1634
+ headers.append("Set-Cookie", clearCookie(transaction.cookieName));
1635
+ if (options.remember) headers.append("Set-Cookie", await rememberApproval(cookies, request, approved, options.remember));
1636
+ return {
1637
+ request: approved,
1638
+ headers
1639
+ };
1640
+ }
1641
+ /**
1642
+ * Consume a consent transaction the user declined, and build the OAuth error redirect back to the
1643
+ * client: `error=access_denied`, the client's `state`, and `iss`. The redirect URI comes from the
1644
+ * stored request, which `parseAuthRequest()` validated, never from the form.
1645
+ */
1646
+ async function denyConsent(kv, cookies, request, handle, options = {}) {
1647
+ const transaction = await openTransaction(kv, request, cookies.consent, handle, "consent");
1648
+ await transaction.consume();
1649
+ const record = transaction.record;
1650
+ const redirect = new URL(record.request.redirectUri);
1651
+ redirect.searchParams.set("error", "access_denied");
1652
+ if (options.description) redirect.searchParams.set("error_description", options.description);
1653
+ if (record.request.state) redirect.searchParams.set("state", record.request.state);
1654
+ if (record.request.issuer) redirect.searchParams.set("iss", record.request.issuer);
1655
+ const headers = new Headers({
1656
+ "Cache-Control": "no-store",
1657
+ Location: redirect.href
1658
+ });
1659
+ headers.append("Set-Cookie", clearCookie(transaction.cookieName));
1660
+ return {
1661
+ request: record.request,
1662
+ redirectTo: redirect.href,
1663
+ headers
1664
+ };
1665
+ }
1666
+ /** Whether a remembered approval covers this request: same client, redirect URI and resource, subset of scopes. */
1667
+ async function isConsentRemembered(cookies, request, authRequest, remember) {
1668
+ validateRememberConsentOptions(remember);
1669
+ const approvals = await readApprovals(cookies, request, remember.secret);
1670
+ const key = await approvalKey(authRequest, remember.subject);
1671
+ const now = Math.floor(Date.now() / 1e3);
1672
+ const approval = approvals.find((entry) => entry.k === key && entry.e > now);
1673
+ if (!approval) return false;
1674
+ const approvedScopes = new Set(approval.s);
1675
+ return authRequest.scope.every((scope) => approvedScopes.has(scope));
1676
+ }
1677
+ /**
1678
+ * Save an approved authorization request before redirecting to the third-party provider, and get
1679
+ * the `state` to send it. Call only after consent. Pass `headers` to add the binding cookie to
1680
+ * headers you are already sending, such as `approveConsent()`'s.
1681
+ */
1682
+ async function beginUpstream(kv, cookies, request, options = {}) {
1683
+ const { handle, hash } = await createTransaction(kv, {
1684
+ kind: "upstream",
1685
+ request,
1686
+ data: options.data
1687
+ });
1688
+ const headers = options.headers ?? new Headers();
1689
+ headers.append("Set-Cookie", bindingCookie(transactionCookieName(cookies.upstream, hash), hash, TRANSACTION_TTL_SECONDS));
1690
+ headers.set("Cache-Control", "no-store");
1691
+ return {
1692
+ state: handle,
1693
+ headers
1694
+ };
1695
+ }
1696
+ /** Recover the authorization request at the third-party provider's callback, from its `state` parameter. */
1697
+ async function finishUpstream(kv, cookies, request) {
1698
+ const state = new URL(request.url).searchParams.get("state");
1699
+ if (!state) throw new AuthorizationError("invalid_request", { description: "Missing state parameter" });
1700
+ const transaction = await openTransaction(kv, request, cookies.upstream, state, "upstream");
1701
+ await transaction.consume();
1702
+ const headers = new Headers({ "Cache-Control": "no-store" });
1703
+ headers.append("Set-Cookie", clearCookie(transaction.cookieName));
1704
+ return {
1705
+ request: transaction.record.request,
1706
+ data: transaction.record.data,
1707
+ headers
1708
+ };
1709
+ }
1710
+ async function createTransaction(kv, record) {
1711
+ const handle = base64url(crypto.getRandomValues(new Uint8Array(32)));
1712
+ const hash = await sha256Hex(handle);
1713
+ const iv = crypto.getRandomValues(new Uint8Array(12));
1714
+ const sealed = await crypto.subtle.encrypt({
1715
+ name: "AES-GCM",
1716
+ iv
1717
+ }, await transactionKey(handle), new TextEncoder().encode(JSON.stringify(record)));
1718
+ await kv.put(`transaction:${hash}`, `${base64url(iv)}.${base64url(new Uint8Array(sealed))}`, { expirationTtl: TRANSACTION_TTL_SECONDS });
1719
+ return {
1720
+ handle,
1721
+ hash
1722
+ };
1723
+ }
1724
+ async function openTransaction(kv, request, cookieBase, handle, kind) {
1725
+ if (typeof handle !== "string" || handle.length === 0) throw new AuthorizationError("invalid_request", { description: "Missing transaction handle" });
1726
+ const hash = await sha256Hex(handle);
1727
+ const cookieName = transactionCookieName(cookieBase, hash);
1728
+ const bound = readCookie(request, cookieName);
1729
+ if (!bound) throw new AuthorizationError("invalid_request", { description: "This authorization was not started in this browser; start again" });
1730
+ if (!timingSafeEqual(hash, bound)) throw new AuthorizationError("invalid_request", { description: "This authorization belongs to a different browser session; start again" });
1731
+ const key = `transaction:${hash}`;
1732
+ const expired = new AuthorizationError("invalid_request", { description: "This authorization expired or was already used; start again" });
1733
+ const stored = await kv.get(key);
1734
+ if (!stored) throw expired;
1735
+ let record;
1736
+ try {
1737
+ const [iv, sealed] = stored.split(".");
1738
+ const plain = await crypto.subtle.decrypt({
1739
+ name: "AES-GCM",
1740
+ iv: fromBase64url(iv)
1741
+ }, await transactionKey(handle), fromBase64url(sealed));
1742
+ record = JSON.parse(new TextDecoder().decode(plain));
1743
+ } catch {
1744
+ throw expired;
1745
+ }
1746
+ if (record.kind !== kind) throw expired;
1747
+ return {
1748
+ record,
1749
+ cookieName,
1750
+ consume: () => kv.delete(key)
1751
+ };
1752
+ }
1753
+ /** Only the holder of the handle can decrypt its record; KV keeps the hash, not the handle. */
1754
+ async function transactionKey(handle) {
1755
+ const material = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`oauth-transaction-key:${handle}`));
1756
+ return crypto.subtle.importKey("raw", material, "AES-GCM", false, ["encrypt", "decrypt"]);
1757
+ }
1758
+ /** One binding cookie per transaction, so concurrent authorizations in one browser don't collide. */
1759
+ function transactionCookieName(base, hash) {
1760
+ return `${base}-${hash.slice(0, 16)}`;
1761
+ }
1762
+ async function rememberApproval(cookies, request, approved, remember) {
1763
+ const maxAge = remember.maxAgeSeconds ?? DEFAULT_REMEMBER_SECONDS;
1764
+ const now = Math.floor(Date.now() / 1e3);
1765
+ const key = await approvalKey(approved, remember.subject);
1766
+ const approvals = (await readApprovals(cookies, request, remember.secret)).filter((entry) => entry.e > now && entry.k !== key);
1767
+ approvals.push({
1768
+ k: key,
1769
+ s: approved.scope,
1770
+ e: now + maxAge
1771
+ });
1772
+ let value = await signApprovals(approvals, remember.secret);
1773
+ while (value.length > MAX_APPROVALS_COOKIE_BYTES && approvals.length > 1) {
1774
+ approvals.shift();
1775
+ value = await signApprovals(approvals, remember.secret);
1776
+ }
1777
+ const cookieMaxAge = Math.max(...approvals.map((entry) => entry.e)) - now;
1778
+ return `${cookies.approvals}=${value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${cookieMaxAge}`;
1779
+ }
1780
+ async function readApprovals(cookies, request, secret) {
1781
+ const value = readCookie(request, cookies.approvals);
1782
+ if (!value) return [];
1783
+ const [payload, signature] = value.split(".");
1784
+ if (!payload || !signature) return [];
1785
+ const key = await hmacKey(secret);
1786
+ let valid = false;
1787
+ try {
1788
+ valid = await crypto.subtle.verify("HMAC", key, fromBase64url(signature), new TextEncoder().encode(payload));
1789
+ } catch {
1790
+ return [];
1791
+ }
1792
+ if (!valid) return [];
1793
+ try {
1794
+ const parsed = JSON.parse(new TextDecoder().decode(fromBase64url(payload)));
1795
+ return Array.isArray(parsed) ? parsed.filter(isApproval) : [];
1796
+ } catch {
1797
+ return [];
1798
+ }
1799
+ }
1800
+ async function signApprovals(approvals, secret) {
1801
+ const payload = base64url(new TextEncoder().encode(JSON.stringify(approvals)));
1802
+ const signature = await crypto.subtle.sign("HMAC", await hmacKey(secret), new TextEncoder().encode(payload));
1803
+ return `${payload}.${base64url(new Uint8Array(signature))}`;
1804
+ }
1805
+ function isApproval(value) {
1806
+ if (!value || typeof value !== "object") return false;
1807
+ const entry = value;
1808
+ return typeof entry.k === "string" && typeof entry.e === "number" && Array.isArray(entry.s) && entry.s.every((scope) => typeof scope === "string");
1809
+ }
1810
+ /** Approval is bound to the client, where its tokens go, which resource they are for, and the user if known. */
1811
+ async function approvalKey(request, subject) {
1812
+ const material = JSON.stringify([
1813
+ request.clientId,
1814
+ request.redirectUri,
1815
+ request.resource ?? null,
1816
+ subject ?? null
1817
+ ]);
1818
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(material));
1819
+ return base64url(new Uint8Array(digest));
1820
+ }
1821
+ function hmacKey(secret) {
1822
+ return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), {
1823
+ name: "HMAC",
1824
+ hash: "SHA-256"
1825
+ }, false, ["sign", "verify"]);
1826
+ }
1827
+ function bindingCookie(name, value, maxAge) {
1828
+ return `${name}=${value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`;
1829
+ }
1830
+ function clearCookie(name) {
1831
+ return `${name}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`;
1832
+ }
1833
+ function readCookie(request, name) {
1834
+ const header = request.headers.get("Cookie");
1835
+ if (!header) return void 0;
1836
+ for (const part of header.split(";")) {
1837
+ const separator = part.indexOf("=");
1838
+ if (separator === -1) continue;
1839
+ if (part.slice(0, separator).trim() === name) return part.slice(separator + 1).trim();
1840
+ }
1841
+ }
1842
+ async function sha256Hex(value) {
1843
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
1844
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
1845
+ }
1846
+ function timingSafeEqual(a, b) {
1847
+ if (a.length !== b.length) return false;
1848
+ let difference = 0;
1849
+ for (let index = 0; index < a.length; index++) difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
1850
+ return difference === 0;
1851
+ }
1852
+ function base64url(bytes) {
1853
+ let binary = "";
1854
+ for (const byte of bytes) binary += String.fromCharCode(byte);
1855
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1856
+ }
1857
+ function fromBase64url(value) {
1858
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
1859
+ const binary = atob(base64 + "=".repeat((4 - base64.length % 4) % 4));
1860
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
1861
+ }
1862
+
1552
1863
  //#endregion
1553
1864
  //#region src/oauth-provider.ts
1554
1865
  const PROTECTED_RESOURCE_WELL_KNOWN_PREFIX = "/.well-known/oauth-protected-resource";
@@ -1744,6 +2055,7 @@ var OAuthProviderImpl = class {
1744
2055
  };
1745
2056
  this.validateEndpoint(this.options.authorizeEndpoint, "authorizeEndpoint");
1746
2057
  this.validateEndpoint(this.options.tokenEndpoint, "tokenEndpoint");
2058
+ this.consentCookies = consentCookies(this.options.cookiePrefix);
1747
2059
  if (this.options.clientRegistrationEndpoint) this.validateEndpoint(this.options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
1748
2060
  if (roleBased) this.validateAuthorizationServerRouteIsolation();
1749
2061
  this.resourceServers = configuredResourceServers.map(({ resourceMetadata, resolveExternalToken }) => {
@@ -2460,6 +2772,24 @@ var OAuthProviderImpl = class {
2460
2772
  * token issuance or `tokenExchangeCallback`. Anything else is re-thrown so
2461
2773
  * unexpected failures still surface as 500s.
2462
2774
  */
2775
+ /**
2776
+ * Run `tokenExchangeCallback`. An `invalid_grant` it throws means the grant can never work again
2777
+ * (RFC 6749 §5.2: invalid, expired, or revoked; transient failures are `temporarily_unavailable`),
2778
+ * so the grant it ran for is revoked, with its tokens, before the error answers. A failed
2779
+ * revocation is logged and the callback's error still answers the request.
2780
+ */
2781
+ async callTokenExchangeCallback(options, env) {
2782
+ try {
2783
+ return await Promise.resolve(this.options.tokenExchangeCallback(options));
2784
+ } catch (error) {
2785
+ if (error instanceof OAuthError && error.code === "invalid_grant") try {
2786
+ await this.createOAuthHelpers(env).revokeGrant(options.grantId, options.userId);
2787
+ } catch (revokeError) {
2788
+ console.warn(`Failed to revoke grant ${options.grantId} after tokenExchangeCallback answered invalid_grant:`, revokeError);
2789
+ }
2790
+ throw error;
2791
+ }
2792
+ }
2463
2793
  createOAuthErrorResponse(error) {
2464
2794
  if (!(error instanceof OAuthError)) return void 0;
2465
2795
  return this.createErrorResponse(error.code, error.options, error.options.internal ?? {
@@ -2613,7 +2943,7 @@ var OAuthProviderImpl = class {
2613
2943
  resource: audience,
2614
2944
  props: decryptedProps
2615
2945
  };
2616
- const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
2946
+ const callbackResult = await this.callTokenExchangeCallback(callbackOptions, env);
2617
2947
  if (callbackResult) {
2618
2948
  if (callbackResult.newProps) {
2619
2949
  grantProps = callbackResult.newProps;
@@ -2760,7 +3090,7 @@ var OAuthProviderImpl = class {
2760
3090
  resource: audience,
2761
3091
  props: decryptedProps
2762
3092
  };
2763
- const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
3093
+ const callbackResult = await this.callTokenExchangeCallback(callbackOptions, env);
2764
3094
  if (callbackResult) {
2765
3095
  if (callbackResult.newProps) {
2766
3096
  grantProps = callbackResult.newProps;
@@ -2946,7 +3276,7 @@ var OAuthProviderImpl = class {
2946
3276
  resource: newAudience,
2947
3277
  props: decryptedProps
2948
3278
  };
2949
- const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
3279
+ const callbackResult = await this.callTokenExchangeCallback(callbackOptions, env);
2950
3280
  if (crossClientExchange && callbackResult?.allowCrossClientExchange !== true) throw crossClientRejection();
2951
3281
  if (callbackResult) {
2952
3282
  let accessTokenProps = decryptedProps;
@@ -4037,6 +4367,7 @@ var OAuthProviderImpl = class {
4037
4367
  * async function refreshUpstream(props) {
4038
4368
  * const res = await fetch(...);
4039
4369
  * if (res.status === 401) {
4370
+ * // invalid_grant can never recover: the provider also revokes this grant and its tokens.
4040
4371
  * throw new OAuthError('invalid_grant', { description: 'upstream refresh token is invalid' });
4041
4372
  * }
4042
4373
  * if (res.status === 429) {
@@ -4496,11 +4827,11 @@ const WRAPPING_KEY_HMAC_KEY = new Uint8Array([
4496
4827
  */
4497
4828
  async function deriveKeyFromToken(tokenStr) {
4498
4829
  const encoder = new TextEncoder();
4499
- const hmacKey = await crypto.subtle.importKey("raw", WRAPPING_KEY_HMAC_KEY, {
4830
+ const hmacKey$1 = await crypto.subtle.importKey("raw", WRAPPING_KEY_HMAC_KEY, {
4500
4831
  name: "HMAC",
4501
4832
  hash: "SHA-256"
4502
4833
  }, false, ["sign"]);
4503
- const hmacResult = await crypto.subtle.sign("HMAC", hmacKey, encoder.encode(tokenStr));
4834
+ const hmacResult = await crypto.subtle.sign("HMAC", hmacKey$1, encoder.encode(tokenStr));
4504
4835
  return await crypto.subtle.importKey("raw", hmacResult, { name: "AES-KW" }, false, ["wrapKey", "unwrapKey"]);
4505
4836
  }
4506
4837
  /**
@@ -4849,7 +5180,8 @@ var OAuthHelpersImpl = class {
4849
5180
  };
4850
5181
  if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
4851
5182
  else delete updatedClient.clientSecret;
4852
- await this.provider.putRegisteredClient(this.env, updatedClient, Math.floor(Date.now() / 1e3));
5183
+ if (client.registrationExpiresAt === void 0) await this.env.OAUTH_KV.put(`client:${updatedClient.clientId}`, JSON.stringify(updatedClient));
5184
+ else await this.provider.putRegisteredClient(this.env, updatedClient, Math.floor(Date.now() / 1e3));
4853
5185
  const response = toPublicClientInfo(updatedClient);
4854
5186
  if (!isPublicClient && originalSecret) response.clientSecret = originalSecret;
4855
5187
  return response;
@@ -4917,6 +5249,30 @@ var OAuthHelpersImpl = class {
4917
5249
  * @param userId - The ID of the user who owns the grant
4918
5250
  * @returns A Promise resolving when the revocation is confirmed.
4919
5251
  */
5252
+ /** {@inheritDoc OAuthHelpers.isConsentRemembered} */
5253
+ isConsentRemembered(request, authRequest, remember) {
5254
+ return isConsentRemembered(this.provider.consentCookies, request, authRequest, remember);
5255
+ }
5256
+ /** {@inheritDoc OAuthHelpers.beginConsent} */
5257
+ beginConsent(authRequest) {
5258
+ return beginConsent(this.env.OAUTH_KV, this.provider.consentCookies, authRequest);
5259
+ }
5260
+ /** {@inheritDoc OAuthHelpers.approveConsent} */
5261
+ approveConsent(request, handle, options) {
5262
+ return approveConsent(this.env.OAUTH_KV, this.provider.consentCookies, this.provider.options.scopesSupported, request, handle, options);
5263
+ }
5264
+ /** {@inheritDoc OAuthHelpers.denyConsent} */
5265
+ denyConsent(request, handle, options) {
5266
+ return denyConsent(this.env.OAUTH_KV, this.provider.consentCookies, request, handle, options);
5267
+ }
5268
+ /** {@inheritDoc OAuthHelpers.beginUpstream} */
5269
+ beginUpstream(authRequest, options) {
5270
+ return beginUpstream(this.env.OAUTH_KV, this.provider.consentCookies, authRequest, options);
5271
+ }
5272
+ /** {@inheritDoc OAuthHelpers.finishUpstream} */
5273
+ finishUpstream(request) {
5274
+ return finishUpstream(this.env.OAUTH_KV, this.provider.consentCookies, request);
5275
+ }
4920
5276
  async revokeGrant(grantId, userId) {
4921
5277
  const grantKey = `grant:${userId}:${grantId}`;
4922
5278
  const tokenPrefix = `token:${userId}:${grantId}:`;
@@ -220,7 +220,7 @@ onError({ code, internal }) {
220
220
 
221
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
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 }`.
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 }`. An `invalid_grant` thrown from `tokenExchangeCallback` also revokes the grant the callback ran for, with its tokens: it means the grant can never work again. Use `temporarily_unavailable` for transient upstream failures (see [upstream-sign-in.md](upstream-sign-in.md#when-the-third-party-revokes-access)).
224
224
 
225
225
  ## Token and client lifetimes
226
226
 
@@ -0,0 +1,138 @@
1
+ # Building a consent page
2
+
3
+ Your `authorizeEndpoint` is your page: the library validates the request, and you sign the user in and ask whether this client may act for them. The consent helpers (`beginConsent()`, `approveConsent()`, `denyConsent()`, `isConsentRemembered()`) make that page safe to build. A server that signs users in through another provider also needs [upstream-sign-in.md](upstream-sign-in.md).
4
+
5
+ ## What the page must show
6
+
7
+ From the MCP authorization spec and security best practices:
8
+
9
+ - **The client's name**, and **the scopes** being granted.
10
+ - **The redirect URI's hostname** (MUST): where the tokens will go.
11
+ - **A warning when that hostname is `localhost`** (SHOULD). A CIMD client's name comes from its metadata document, but anyone can present that document and listen on a local port, so the name alone doesn't prove which app is asking.
12
+ - **A CIMD client's domain**, prominently. Its `client_id` is a URL on a domain the client controls; a DCR client's name is self-asserted.
13
+ - **No framing**, and a form that can't be forged: `beginConsent()` returns the headers and the browser-bound handle that do this.
14
+
15
+ ## Escape everything that came from the client
16
+
17
+ `clientName`, `clientUri`, `logoUri` and the scope strings come from dynamic registration or a CIMD document, so an attacker chooses them. Rendered without escaping, they're script running on your authorization origin, next to your users' sessions.
18
+
19
+ ## A minimal page
20
+
21
+ ```ts
22
+ import type { AuthRequest, ClientInfo } from '@cloudflare/workers-oauth-provider';
23
+
24
+ const escape = (value: string) => value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
25
+
26
+ function consentPage(client: ClientInfo, request: AuthRequest, handle: string): string {
27
+ const name = escape(client.clientName ?? client.clientId);
28
+ const redirectHost = new URL(request.redirectUri).hostname;
29
+ const local = /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/.test(redirectHost);
30
+ const origin = client.clientId.startsWith('https://')
31
+ ? `Published by <strong>${escape(new URL(client.clientId).hostname)}</strong>.`
32
+ : 'This app registered itself; its name is not verified.';
33
+ const scopes = request.scope
34
+ .map(
35
+ (scope) => `<label><input type="checkbox" name="scope" value="${escape(scope)}" checked> ${escape(scope)}</label>`
36
+ )
37
+ .join('<br>');
38
+ return `<!doctype html>
39
+ <meta charset="utf-8">
40
+ <title>Authorize ${name}</title>
41
+ <h1>Allow ${name} to access your account?</h1>
42
+ <p>${origin} Access will be sent to <strong>${escape(redirectHost)}</strong>.</p>
43
+ ${local ? '<p><strong>This sends access to an app on your computer.</strong> Continue only if you just started signing in from it.</p>' : ''}
44
+ <form method="post">
45
+ <input type="hidden" name="handle" value="${escape(handle)}">
46
+ ${scopes}
47
+ <p><button name="decision" value="approve">Allow</button> <button name="decision" value="deny">Deny</button></p>
48
+ </form>`;
49
+ }
50
+ ```
51
+
52
+ ## Showing it, approving, declining
53
+
54
+ ```ts
55
+ const oauth = authorizationServer.getOAuthApi(env); // or env.OAUTH_PROVIDER with OAuthProvider
56
+
57
+ // GET /authorize (after signing the user in with your own session)
58
+ const request = await oauth.parseAuthRequest(req);
59
+ const client = await oauth.lookupClient(request.clientId);
60
+ const consent = await oauth.beginConsent(request);
61
+ consent.headers.set('Content-Type', 'text/html; charset=utf-8');
62
+ return new Response(consentPage(client!, request, consent.handle), { headers: consent.headers });
63
+
64
+ // POST /authorize
65
+ const form = await req.formData();
66
+ const handle = String(form.get('handle'));
67
+ if (form.get('decision') !== 'approve') {
68
+ const denied = await oauth.denyConsent(req, handle); // redirect to the client: access_denied, state, iss
69
+ return new Response(null, { status: 302, headers: denied.headers });
70
+ }
71
+ const approved = await oauth.approveConsent(req, handle, { scope: form.getAll('scope').map(String) });
72
+ const { redirectTo } = await oauth.completeAuthorization({
73
+ request: approved.request, // from storage, not from the form
74
+ userId: session.userId,
75
+ metadata: {},
76
+ scope: approved.request.scope,
77
+ props: { userId: session.userId },
78
+ });
79
+ approved.headers.set('Location', redirectTo);
80
+ return new Response(null, { status: 302, headers: approved.headers });
81
+ ```
82
+
83
+ The authorization request is kept server-side between the two requests; the form carries only the handle, which works once, for ten minutes, in the browser that opened the page. `scope` is what the user ticked: fewer or more than the client requested, each in `scopesSupported`.
84
+
85
+ ## Errors: redirect or render?
86
+
87
+ A redirect back to the client is only safe once the client and its exact redirect URI are validated. Everything else is shown on your page.
88
+
89
+ | Where it fails | What to do |
90
+ | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
91
+ | `parseAuthRequest()` throws `AuthorizationError` **with** `redirectUri` | Redirect to it with `error`, `error_description`, `state` and `iss` from the error (see the quick start) |
92
+ | `parseAuthRequest()` throws `AuthorizationError` **without** `redirectUri` | Render locally. Never redirect: the client or redirect URI isn't trusted |
93
+ | `parseAuthRequest()` / `lookupClient()` throw `CimdFetchError` | Render locally: the client's metadata document couldn't be fetched (`error.reason`, `error.detail` for your logs) |
94
+ | `approveConsent()`, `denyConsent()`, `finishUpstream()` throw `AuthorizationError` | Render locally: the page expired, was used, or was opened in another browser, or the scopes aren't supported. Offer to start again |
95
+ | Any helper throws something else (`TypeError`, a KV failure) | A bug or an outage, not the user's doing: let it surface as a 500 |
96
+ | The user clicks Deny | `denyConsent()`, then send its redirect |
97
+ | A third-party provider returns `error=` to your callback | `finishUpstream()`, then redirect to the client with `access_denied` ([upstream-sign-in.md](upstream-sign-in.md)) |
98
+ | Token endpoint errors | The library answers them; observe them with `onError` |
99
+
100
+ ```ts
101
+ try {
102
+ // …the handlers above
103
+ } catch (error) {
104
+ if (error instanceof AuthorizationError && error.redirectUri) {
105
+ const redirect = new URL(error.redirectUri);
106
+ redirect.searchParams.set('error', error.code);
107
+ redirect.searchParams.set('error_description', error.description);
108
+ if (error.state) redirect.searchParams.set('state', error.state);
109
+ if (error.issuer) redirect.searchParams.set('iss', error.issuer);
110
+ return Response.redirect(redirect.href, 302);
111
+ }
112
+ if (error instanceof AuthorizationError || error instanceof CimdFetchError) {
113
+ const message = error instanceof AuthorizationError ? error.description : 'This app could not be verified.';
114
+ return new Response(escape(message), { status: 400, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
115
+ }
116
+ throw error;
117
+ }
118
+ ```
119
+
120
+ ## Remembering consent
121
+
122
+ By default the page appears on every authorization, which also lets users re-authorize with different scopes. To skip it for clients a user already approved, pass `remember` when approving and check before showing the page:
123
+
124
+ ```ts
125
+ const remember = { secret: env.CONSENT_SECRET, subject: session.userId }; // secret: 32+ chars, from a Worker secret
126
+
127
+ if (await oauth.isConsentRemembered(req, request, remember)) {
128
+ // skip the page: complete the authorization (or start the third-party sign-in) directly
129
+ }
130
+ // …when approving:
131
+ await oauth.approveConsent(req, handle, { scope, remember }); // maxAgeSeconds defaults to 30 days
132
+ ```
133
+
134
+ Approvals live in a signed `__Host-` cookie, bound to the client ID, its redirect URI and the resource, and they cover only the scopes that were approved: asking for more brings the page back. Pass `subject` (the signed-in user) whenever you know it, so another account on the same browser is asked again. Without it an approval belongs to the browser, which is what a proxy server gets, since it learns the user from the third party only after consent.
135
+
136
+ ## Cookie names
137
+
138
+ Each consent page and each third-party redirect gets its own short-lived cookie, `__Host-oauth-consent-…` or `__Host-oauth-upstream-…` (so two tabs can authorize at once), and remembered approvals live in `__Host-oauth-approvals`. Change the prefix with the `cookiePrefix` option if those collide with yours; it must start with `__Host-`.
@@ -0,0 +1,93 @@
1
+ # Signing in through another provider
2
+
3
+ Many MCP servers don't have their own users: they sign people in with GitHub, Sentry, Google, or another OAuth provider, and call that provider's API with the user's token. The MCP server is still the authorization server for MCP clients, but its `/authorize` page hands the user to the third party and finishes when the third party redirects back.
4
+
5
+ Every MCP client then reaches the third party through your one OAuth app. MCP's security best practices call this the [confused deputy problem](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices#confused-deputy-problem) and require consent per client before the redirect, a consent page that can't be framed or forged, and a `state` bound to the user's browser. The helpers below implement those requirements; the consent page itself is yours.
6
+
7
+ ## The flow
8
+
9
+ ```ts
10
+ const oauth = authorizationServer.getOAuthApi(env); // or env.OAUTH_PROVIDER with OAuthProvider
11
+
12
+ // GET /authorize: parse, then ask for consent.
13
+ const request = await oauth.parseAuthRequest(req);
14
+ const client = await oauth.lookupClient(request.clientId);
15
+ const consent = await oauth.beginConsent(request);
16
+ return new Response(renderConsentPage({ client, request, handle: consent.handle }), {
17
+ headers: consent.headers, // binding cookie, no framing, no caching
18
+ });
19
+
20
+ // POST /authorize: the user approved. Now, and only now, start the third-party redirect.
21
+ const form = await req.formData();
22
+ const approved = await oauth.approveConsent(req, String(form.get('handle')), {
23
+ scope: form.getAll('scope').map(String), // optional: the scopes the user ticked, from any in scopesSupported
24
+ });
25
+ const verifier = crypto.randomUUID() + crypto.randomUUID();
26
+ const { state, headers } = await oauth.beginUpstream(approved.request, {
27
+ data: { verifier }, // returned at the callback; never sent to the third party
28
+ headers: approved.headers,
29
+ });
30
+ headers.set('Location', githubAuthorizeUrl({ state, codeChallenge: await s256(verifier) }));
31
+ return new Response(null, { status: 302, headers });
32
+
33
+ // GET /callback: recover the request, exchange the third party's code, finish.
34
+ const { request: original, data, headers: clear } = await oauth.finishUpstream<{ verifier: string }>(req);
35
+ const upstream = await exchangeGithubCode(new URL(req.url).searchParams.get('code')!, data.verifier);
36
+ const { redirectTo } = await oauth.completeAuthorization({
37
+ request: original,
38
+ userId: upstream.user.id,
39
+ metadata: {},
40
+ scope: original.scope,
41
+ props: { githubToken: upstream.accessToken }, // encrypted; handlers get it in ctx.props
42
+ });
43
+ clear.set('Location', redirectTo);
44
+ return new Response(null, { status: 302, headers: clear });
45
+ ```
46
+
47
+ Build the consent page itself, and the Deny path (`denyConsent()`), as in [consent-page.md](consent-page.md), which also covers which errors to redirect and which to render. Validation failures throw `AuthorizationError` without a `redirectUri`: render them locally. A `TypeError` (bad options) or a storage failure is a bug or an outage, not the user's doing.
48
+
49
+ `beginUpstream()` doesn't check consent itself: call it only after `approveConsent()`, or when `isConsentRemembered()` says yes. (A server whose clients are all pre-registered, with no dynamic registration, isn't required to ask per client and can call it directly.)
50
+
51
+ If the third party sends the user back with an error (they declined there, or it failed), `finishUpstream()` still returns the original request, so answer the client with it:
52
+
53
+ ```ts
54
+ const { request: original, headers } = await oauth.finishUpstream(req);
55
+ const error = new URL(req.url).searchParams.get('error');
56
+ if (error) {
57
+ const redirect = new URL(original.redirectUri);
58
+ redirect.searchParams.set('error', 'access_denied');
59
+ redirect.searchParams.set('state', original.state);
60
+ if (original.issuer) redirect.searchParams.set('iss', original.issuer);
61
+ headers.set('Location', redirect.href);
62
+ return new Response(null, { status: 302, headers });
63
+ }
64
+ ```
65
+
66
+ ## What the helpers guarantee
67
+
68
+ - **The consent page can't be forged or framed.** `beginConsent()` binds the handle to the browser with a `__Host-` cookie (`Secure`, `HttpOnly`, `SameSite=Lax`, ten minutes) and returns `Content-Security-Policy: frame-ancestors 'none'` and `X-Frame-Options: DENY`. A post from another site has the handle but not the cookie, and is refused.
69
+ - **`state` exists only after consent.** `beginUpstream()` creates it, stores the approved request server-side, and binds it to the browser. The callback is refused without the matching cookie, so a stolen third-party code can't be replayed in another browser.
70
+ - **Single use, ten minutes, several at once.** Each handle and `state` works once, and each has its own binding cookie, so two tabs can authorize at the same time. KV keys hold only the SHA-256 of the handle, and the record, including your `data` (a PKCE verifier, say), is encrypted with a key only the handle derives: reading KV alone reveals nothing. KV can't make `get`-then-`delete` atomic, so two simultaneous requests from the _same_ browser with the same handle could both pass; the cookie binding rules out anyone else.
71
+ - **Nothing trusted comes from the form.** The authorization request is recovered from storage, not from hidden fields, so the page can't be made to approve a different client or redirect URI. `scope` is the page's to choose, fewer or more than the client requested, but only from `scopesSupported`.
72
+
73
+ ## Remembering consent
74
+
75
+ Pass `remember` to `approveConsent()` and check `isConsentRemembered()` before showing the page; when it's remembered, go straight to `beginUpstream()`. See [consent-page.md](consent-page.md#remembering-consent), which also covers the cookie names.
76
+
77
+ ## When the third party revokes access
78
+
79
+ Store the third party's refresh token in `props` and refresh it in `tokenExchangeCallback`. When it answers `invalid_grant`, the user has revoked your app or the token is gone for good. Throw `invalid_grant` too: the library revokes this grant, with its tokens, so the MCP client re-authorizes instead of retrying a grant that can never work. For a transient failure (the provider is down, rate limited), throw `temporarily_unavailable` instead, which leaves the grant for the retry.
80
+
81
+ ```ts
82
+ tokenExchangeCallback: async ({ grantType, props }) => {
83
+ if (grantType !== 'refresh_token') return;
84
+ const upstream = await refreshGithubToken(props.githubRefreshToken);
85
+ if (upstream.error === 'bad_refresh_token') {
86
+ throw new OAuthError('invalid_grant', { description: 'GitHub access was revoked' }); // revokes this grant
87
+ }
88
+ if (!upstream.ok) {
89
+ throw new OAuthError('temporarily_unavailable', { description: 'GitHub is unavailable', statusCode: 503 });
90
+ }
91
+ return { newProps: { ...props, githubToken: upstream.accessToken, githubRefreshToken: upstream.refreshToken } };
92
+ },
93
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "OAuth provider for Cloudflare Workers",
5
5
  "main": "dist/oauth-provider.js",
6
6
  "types": "dist/oauth-provider.d.ts",