@cloudflare/workers-oauth-provider 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,35 @@
1
- # Migrating from 0.x to 1.0
1
+ # Migrating to 1.x
2
2
 
3
- 1.0 binds every grant and access token to one canonical resource (RFC 8707 / RFC 9728) and adds role classes for multi-Worker deployments. Stored 0.x data keeps working — there is no KV migration. For most deployments the code diff is one field.
3
+ This guide takes a Worker from 0.10.x, 1.0 or 1.1 to the latest 1.x release. Each change is tagged with the version that introduced it, so skip the ones older than the version you're on. Stored grants, tokens and clients keep working throughout: there is no KV migration.
4
+
5
+ Coding agents can follow [`skills/migrate-to-1.0/SKILL.md`](../skills/migrate-to-1.0/SKILL.md), which ships in the npm package and points back at the sections below.
4
6
 
5
7
  ## Am I affected?
6
8
 
7
- - **Single Worker on `OAuthProvider`** (the common shape): add `resourceMetadata: { resource }` if you don't have it. Usually that is the whole migration.
8
- - **You set `resourceMatchOriginOnly`**: remove it; construction now rejects it.
9
- - **You use `resolveExternalToken`**: return `audience` (now required).
10
- - **You registered clients over DCR with an explicit narrow `grant_types`**: registered grant types are now enforced at the token endpoint.
11
- - **Your clients send a different `redirect_uri` at code exchange than at authorization**: OAuth 2.1 §4.1.3 is now enforced.
12
- - **Nothing else**: existing grants, refresh tokens, access tokens and authorization codes keep working under the legacy rules below.
9
+ Search your code for each of these. Anything you don't use needs no change.
10
+
11
+ | You use | Since | Change |
12
+ | ------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------------------------------------------------------------------------------------------- |
13
+ | `new OAuthProvider(` without `resourceMetadata` | 1.0 | [Add the canonical resource](#the-canonical-resource-is-required-10) |
14
+ | `resourceMatchOriginOnly` | 1.0 | [Delete it](#removed-resourcematchoriginonly-10) |
15
+ | `resolveExternalToken` | 1.0 | [Return `audience`](#resolveexternaltoken-names-its-audience-10) |
16
+ | `resourceMetadata.scopes_supported` | 1.2 | [Move it to `requiredScopes`](#requiredscopes-replaces-resourcemetadatascopes_supported-12) |
17
+ | `allowImplicitFlow` or `allowPlainPKCE` | 1.2 | [Delete them](#removed-the-implicit-grant-and-plain-pkce-12) |
18
+ | Clients with remote `http` or `com.example.app:/` redirect URIs | 1.2 | [Redirect URI policy](#redirect-uris-must-be-https-or-loopback-http-12) |
19
+ | `clientRegistrationTTL: 0`, or any lifetime under 60 seconds | 1.2 | [Lifetimes are validated](#lifetimes-are-validated-at-construction-12) |
20
+ | User IDs containing `:` | 1.2 | [Encode them](#user-ids-cannot-contain--12) |
21
+ | `revokeExistingGrantsBatchSize` | 1.2 | [Delete it](#removed-revokeexistinggrantsbatchsize-12) |
22
+ | `resourceMatches`, `validateResourceUri`, `isValidOAuthScopeToken`, `base64UrlToBytes`, `parseJwtJsonPart`, `getJwtCryptoAlgorithms` | 1.2 | [No longer exported](#internal-helpers-are-no-longer-exported-12) |
23
+ | `createClient()` or `updateClient()` | 1.2 | [Client helpers validate](#client-helpers-validate-what-they-store-12) |
24
+ | `tokenExchangeCallback` | 1.1 | [It can revoke grants, and lifetimes changed](#tokenexchangecallback-11-12) |
25
+ | `purgeExpiredData()` on a schedule | 1.2 | [Persist the cursor](#purgeexpireddata-resumes-from-a-cursor-12) |
26
+ | `OAuthAuthorizationServer` (1.0 or 1.1) | 1.2 | [Endpoints have defaults](#oauthauthorizationserver-endpoints-have-defaults-12) |
27
+
28
+ Construction errors name the rule that failed, so a Worker that starts and passes its tests has cleared most of these. The redirect URI policy and the user ID rule act on stored data and live requests instead: check those by hand.
13
29
 
14
- ## The canonical resource is required
30
+ ## Required changes
31
+
32
+ ### The canonical resource is required (1.0)
15
33
 
16
34
  ```diff
17
35
  export default new OAuthProvider<Env>({
@@ -24,22 +42,22 @@
24
42
  });
25
43
  ```
26
44
 
27
- `resource` is the URL your MCP clients connect to: an absolute HTTPS URI with lowercase scheme and host, no fragment, userinfo, default port, or dot segments. A bare origin (`https://mcp.example.com`) is allowed and covers every path. `http` is accepted only on loopback hosts, so `wrangler dev` works at `http://localhost:8787`.
45
+ `resource` is the URL your MCP clients connect to, and it becomes every token's audience. Use an absolute HTTPS URI with a lowercase scheme and host, and no fragment, userinfo, default port or dot segments. A bare origin (`https://mcp.example.com`) is allowed and covers every path. `http` is accepted only on loopback hosts, so `wrangler dev` works at `http://localhost:8787`.
28
46
 
29
- Construction validates the whole configuration against it and throws with a named rule when something is off:
47
+ Construction validates the rest of the configuration against it:
30
48
 
31
- - Every `apiRoute` / `apiHandlers` key must be the resource's path or a path-boundary descendant (`/mcp` covers `/mcp` and `/mcp/tools`, not `/mcp-other`); absolute routes must be on the resource's origin.
49
+ - Every `apiRoute` and `apiHandlers` key must be the resource's path or a path-boundary descendant: `/mcp` covers `/mcp` and `/mcp/tools`, not `/mcp-other`. Absolute routes must be on the resource's origin.
32
50
  - The resource must not sit inside `/.well-known/oauth-protected-resource`.
33
51
 
34
- In 0.x the metadata document was derived per request when `resourceMetadata` was omitted; in 1.0 the configured resource is the identity every token is bound to, so it cannot be implicit.
52
+ In 0.x the metadata document was derived per request when `resourceMetadata` was omitted. In 1.x the resource is the identity every token is bound to, so it can't be implicit.
35
53
 
36
- ## Removed: `resourceMatchOriginOnly`
54
+ ### Removed: `resourceMatchOriginOnly` (1.0)
37
55
 
38
- A configuration that still sets it fails at construction. Audiences are now compared exactly against the canonical resource, with two tolerances: ASCII case in scheme and host is folded, and an empty path equals `/` (RFC 3986 §6.2.3). Port, path, query, and trailing slash stay strict.
56
+ Delete the option. A configuration that still sets it throws `resourceMatchOriginOnly was removed in 1.0`. Audiences are compared exactly against the canonical resource, with two tolerances: ASCII case in the scheme and host is folded, and an empty path equals `/` (RFC 3986 §6.2.3). Port, path, query and trailing slash stay strict.
39
57
 
40
- ## `resolveExternalToken` names its audience
58
+ ### `resolveExternalToken` names its audience (1.0)
41
59
 
42
- `ResolveExternalTokenResult.audience` is required and a single string, and must be the configured canonical resource — a token accepted for another audience is a 401:
60
+ `ResolveExternalTokenResult.audience` is required, a single string, and must be the configured resource. A token accepted for any other audience is a `401`.
43
61
 
44
62
  ```diff
45
63
  resolveExternalToken: async ({ token }) => {
@@ -50,48 +68,235 @@ A configuration that still sets it fails at construction. Audiences are now comp
50
68
  },
51
69
  ```
52
70
 
53
- ## Registered grant types are enforced
71
+ Since 1.2, a token in this library's own format that isn't in storage (an expired access token, say) is answered `invalid_token` without calling `resolveExternalToken`. Your resolver no longer sees them, so it no longer forwards them upstream.
54
72
 
55
- A token request for a grant type the client did not register fails with `unauthorized_client`. `refresh_token` is implied by `authorization_code`; token exchange must be registered explicitly (and enabled with `allowTokenExchangeGrant`). This is a data-side change: clients registered under 0.x with a deliberately narrow `grant_types` array may now be refused where 0.x ignored the field.
73
+ ### `requiredScopes` replaces `resourceMetadata.scopes_supported` (1.2)
56
74
 
57
- ## Token exchange
75
+ A resource's required scopes, the ones any access needs, have their own option on `OAuthProvider` and `OAuthResourceServer`. The wire is unchanged: they're still published as the protected resource metadata's `scopes_supported` and named in the `401` challenge.
58
76
 
59
- - A subject token is exchanged by the client its grant was issued to. The cross-client case requires `tokenExchangeCallback` to return `allowCrossClientExchange: true`; the callback receives `subjectClientId` to decide.
60
- - Subject-token failures return `invalid_request`.
61
- - The exchange cannot retarget the resource: the subject token's audience and any explicit `resource` parameter must resolve to the same registered value.
77
+ ```diff
78
+ new OAuthProvider<Env>({
79
+ // …
80
+ - resourceMetadata: { resource: 'https://mcp.example.com/mcp', scopes_supported: ['mcp:read'] },
81
+ + resourceMetadata: { resource: 'https://mcp.example.com/mcp' },
82
+ + requiredScopes: ['mcp:read'],
83
+ });
84
+ ```
62
85
 
63
- ## `redirect_uri` is bound to the authorization request
86
+ The old field still works but is deprecated, and setting both throws `Set requiredScopes only: resourceMetadata.scopes_supported is deprecated in its favour`. Don't confuse it with `scopesSupported`, the authorization server's catalogue of everything it can grant. See [Scopes and step-up authorization](authorization-server.md#scopes-and-step-up-authorization).
64
87
 
65
- OAuth 2.1 §4.1.3: the `redirect_uri` presented at code exchange must equal the one used in the authorization request (0.x accepted any registered URI). Without PKCE, `redirect_uri` is required at exchange.
88
+ ### Removed: the implicit grant and plain PKCE (1.2)
66
89
 
67
- ## One grant per user, client, and resource
90
+ OAuth 2.1 and MCP use the authorization code flow with S256 PKCE only.
68
91
 
69
- Completing a new authorization replaces the user and client's earlier grant _for the same resource_ only. In 0.x replacement was per user and client, so a central server issuing tokens for several resources no longer drops one resource's grant when the user authorizes another.
92
+ ```diff
93
+ new OAuthProvider<Env>({
94
+ // …
95
+ - allowImplicitFlow: true,
96
+ - allowPlainPKCE: true,
97
+ });
98
+ ```
70
99
 
71
- ## Existing stored data — nothing to do
100
+ Passing either as `true` throws at construction. At runtime, `response_type=token` is answered `unsupported_response_type`, and `code_challenge_method=plain` is refused with `invalid_request`. An authorization code issued with a plain challenge before the upgrade fails at the token endpoint with `invalid_grant`, so the client authorizes again. Codes live ten minutes, so this only touches authorizations in flight during the deploy.
72
101
 
73
- - An access token stored without an audience keeps working until it expires. It is treated as bound to the migration resource: the sole configured resource, or `legacyGrantResource` on a multi-resource server.
74
- - Refresh binds the grant to that resource and returns a bound replacement token.
75
- - A stored 0.x audience array resolves to the registered resource it contains.
76
- - A grant bound only to unregistered values fails refresh with `invalid_grant`; conformant clients (Claude, the MCP SDKs) answer that by starting a fresh authorization.
77
- - A multi-resource `OAuthAuthorizationServer` without `legacyGrantResource` has no safe destination for unbound records and rejects them; set it for the migration window and keep it fixed.
78
- - Authorization codes issued by 0.x redeem under the same rules.
102
+ If you have your own clients on either flow, move them to the authorization code flow with `code_challenge_method=S256` before upgrading.
103
+
104
+ ### Redirect URIs must be HTTPS or loopback HTTP (1.2)
105
+
106
+ Redirect URIs must use `https`, or `http` on `localhost`, `127.0.0.0/8` or `::1`, with no userinfo or fragment. That's what MCP and OAuth 2.1 require. Every authorization request is held to it. A registration (dynamic registration, `createClient()`, `updateClient()`) needs at least one redirect URI that follows it, and may list others next to it, as Cursor lists `cursor://…` beside its https and loopback callbacks; so may a CIMD document. A request that uses one of those is refused. Registrations still refuse dangerous schemes such as `javascript:`, fragments and userinfo.
107
+
108
+ Because it applies at authorization too, clients registered before 1.2 are held to it. A client with a remote `http` redirect URI gets a locally rendered `invalid_request` ("Invalid redirect URI") and is never redirected. It has to register a compliant URI.
109
+
110
+ Native apps that use RFC 8252 private-use schemes (`com.example.app:/oauth/callback`) keep working if you opt in:
111
+
112
+ ```ts
113
+ new OAuthProvider<Env>({
114
+ // …
115
+ allowPrivateUseRedirectUris: true, // native apps only; leave off for MCP servers
116
+ });
117
+ ```
118
+
119
+ Remote `http` is never accepted.
120
+
121
+ ### Lifetimes are validated at construction (1.2)
122
+
123
+ `accessTokenTTL`, `refreshTokenTTL`, `refreshTokenIdleTTL` and `clientRegistrationTTL` are checked when the provider is created, instead of failing every code exchange or registration at runtime. Cloudflare KV can't expire anything sooner than 60 seconds, so:
124
+
125
+ | Option | Accepts |
126
+ | ----------------------- | -------------------------------------------------------- |
127
+ | `accessTokenTTL` | an integer of at least 60 |
128
+ | `refreshTokenTTL` | `0` (no refresh tokens), `undefined` (no expiry), or 60+ |
129
+ | `refreshTokenIdleTTL` | an integer of at least 60 |
130
+ | `clientRegistrationTTL` | `undefined` (no expiry), or 60+ |
131
+
132
+ `clientRegistrationTTL: 0` is no longer accepted. In 0.x it meant "no expiry" in one code path and a zero TTL in another. Write `undefined` for registrations that never expire:
133
+
134
+ ```diff
135
+ - clientRegistrationTTL: 0,
136
+ + clientRegistrationTTL: undefined, // never expire; omit the option for the 90-day default
137
+ ```
138
+
139
+ ### User IDs cannot contain `:` (1.2)
140
+
141
+ `completeAuthorization()` throws `userId must be a non-empty string without ":"`. The colon separates the parts of issued tokens and KV keys, so such a user's tokens could never be validated anyway. Encode composite IDs:
142
+
143
+ ```diff
144
+ await oauth.completeAuthorization({
145
+ request: authRequest,
146
+ - userId: `${tenant}:${user}`,
147
+ + userId: encodeURIComponent(`${tenant}:${user}`),
148
+ // …
149
+ });
150
+ ```
151
+
152
+ If you look grants up with `listUserGrants()` or `revokeGrant()`, pass the same encoded ID.
153
+
154
+ ### Removed: `revokeExistingGrantsBatchSize` (1.2)
155
+
156
+ Delete it from `completeAuthorization()`. Every grant written by 1.0 or later carries KV key metadata, so earlier grants are found without reading them. The few pre-1.0 grants that still need reading are read 50 at a time.
157
+
158
+ ```diff
159
+ await oauth.completeAuthorization({
160
+ request: authRequest,
161
+ userId,
162
+ metadata: {},
163
+ scope: authRequest.scope,
164
+ props,
165
+ - revokeExistingGrantsBatchSize: 100,
166
+ });
167
+ ```
168
+
169
+ ### Internal helpers are no longer exported (1.2)
79
170
 
80
- ## Type-level changes
171
+ These were exported by accident and never documented: `resourceMatches`, `validateResourceUri`, `isValidOAuthScopeToken`, `base64UrlToBytes`, `parseJwtJsonPart` and `getJwtCryptoAlgorithms`. Importing one is now a build error. There's no replacement export; copy the logic you need. For scope tokens, RFC 6749 §3.3 allows `/^[\x21\x23-\x5B\x5D-\x7E]+$/`.
81
172
 
82
- | 0.x | 1.0 |
173
+ ### Client helpers validate what they store (1.2)
174
+
175
+ `createClient()` and `updateClient()` now apply the checks dynamic registration always did:
176
+
177
+ - Grant and response types must be ones the server implements, so `grantTypes: ['implicit']` throws `Unsupported grant_type: implicit`.
178
+ - Redirect URIs follow the [redirect URI policy](#redirect-uris-must-be-https-or-loopback-http-12).
179
+ - `updateClient()` refuses a Client ID Metadata Document client while CIMD is enabled (`Client ID Metadata Document clients are updated by changing their document`). Its metadata lives in its document.
180
+
181
+ Since 1.1, `updateClient()` also leaves clients created with `createClient()` permanent. It used to give them `clientRegistrationTTL`.
182
+
183
+ ### Type changes (1.0)
184
+
185
+ | 0.x | 1.x |
83
186
  | --------------------------------------------------- | ------------------- |
84
187
  | `ExchangeTokenOptions.aud?: string \| string[]` | `aud?: string` |
85
188
  | `AuthRequest.resource?: string \| string[]` | `resource?: string` |
86
189
  | `TokenExchangeCallbackOptions.resource` (array-ish) | single `string` |
87
190
  | `ResolveExternalTokenResult.audience?` (optional) | required `string` |
88
191
 
89
- ## New in 1.0, adopt when useful
192
+ ## Behavior your clients may notice
193
+
194
+ These need no code change, but responses differ.
195
+
196
+ - **Registered grant types are enforced (1.0).** A token request for a grant type the client didn't register fails with `unauthorized_client`. `refresh_token` is implied by `authorization_code`; token exchange must be registered explicitly and enabled with `allowTokenExchangeGrant`. Clients registered over DCR in 0.x with a deliberately narrow `grant_types` may now be refused.
197
+ - **`redirect_uri` is bound to the authorization request (1.0).** The `redirect_uri` at code exchange must equal the one in the authorization request (OAuth 2.1 §4.1.3). 0.x accepted any registered URI. Without PKCE, it's required at exchange.
198
+ - **One grant per user, client and resource (1.0).** A new authorization replaces the user and client's earlier grant _for the same resource_ only. In 0.x it replaced every grant for that user and client.
199
+ - **Token exchange (1.0, 1.2).** A subject token is exchanged by the client its grant was issued to, unless `tokenExchangeCallback` returns `allowCrossClientExchange: true` (it gets `subjectClientId` to decide). Subject-token failures return `invalid_request`, and the exchange can't change the resource. Since 1.2 an allowed cross-client token is issued to the requesting client: `ctx.auth.clientId` and `unwrapToken()` name it, and it can revoke the token.
200
+ - **Scope requests that match nothing (1.2).** A token request whose `scope` names only scopes the grant doesn't hold is refused with `invalid_scope`. Naming at least one granted scope still narrows silently, as before.
201
+ - **Public clients under `disallowPublicClientRegistration` (1.2).** A registration that prefers `none` but also supports a secret method is registered with the secret method instead of being refused.
202
+ - **Dynamic client registration (1.0, 1.2).** A registration in use renews itself: a successful token request in the second half of `clientRegistrationTTL` extends it. Bodies over 1 MiB are refused even without `Content-Length`, and a throwing `clientRegistrationCallback` gets a fixed `Client registration callback failed` description on the wire (`onError` still sees the error).
203
+ - **Grants without a refresh token expire (1.2).** With `refreshTokenTTL: 0`, a grant now expires with its access token instead of staying in KV for good.
204
+ - **CORS headers from your handler are kept (1.2).** `Access-Control-Allow-*` headers an API handler sets are no longer overwritten, so it can narrow its own policy.
205
+ - **`deleteClient()` (1.2).** The client is deleted first, so it stops working even if revoking its grants fails partway; calling it again finishes the job.
206
+
207
+ ## `tokenExchangeCallback` (1.1, 1.2)
208
+
209
+ **`invalid_grant` revokes the grant (1.1).** Throwing `OAuthError('invalid_grant')` now revokes the grant the callback ran for, with its access tokens, and the client authorizes again. Use it when the upstream says the grant is gone for good, and throw `temporarily_unavailable` for failures worth retrying:
210
+
211
+ ```ts
212
+ tokenExchangeCallback: async ({ grantType, props, env }) => {
213
+ if (grantType !== 'refresh_token') return;
214
+ const upstream = await refreshUpstream(props.upstreamRefreshToken, env.UPSTREAM_CLIENT_SECRET);
215
+ if (upstream.error === 'invalid_grant') {
216
+ throw new OAuthError('invalid_grant', { description: 'Upstream access was revoked' }); // revokes this grant
217
+ }
218
+ if (!upstream.ok) {
219
+ throw new OAuthError('temporarily_unavailable', { description: 'Upstream unavailable', statusCode: 503 });
220
+ }
221
+ return { newProps: { ...props, upstreamRefreshToken: upstream.refreshToken } };
222
+ },
223
+ ```
90
224
 
91
- None of these require changes to a migrated 0.x deployment:
225
+ **It receives `env` (1.2)**, as in the example above, so it can reach secrets and bindings without rebuilding the provider per request.
226
+
227
+ **Lifetimes (1.2).** `refreshTokenTTL: undefined` in a result now keeps the provider's lifetime; it used to make the grant never expire, which is what passing through an upstream's missing `refresh_expires_in` did. `refreshTokenTTL` applies at code exchange only and `refreshTokenIdleTTL` at refresh only; returned anywhere else they're ignored rather than failing the request after your side effects ran. Values are validated like the options above.
228
+
229
+ ## `purgeExpiredData()` resumes from a cursor (1.2)
230
+
231
+ Each call now returns a `cursor` when the sweep isn't finished. Before 1.2 every call restarted from the first grant, so a scheduled sweep over more than `batchSize` grants never reached the rest. Store the cursor between runs:
232
+
233
+ ```ts
234
+ async scheduled(_event, env) {
235
+ const cursor = (await env.OAUTH_KV.get('purge-cursor')) ?? undefined;
236
+ const result = await provider.purgeExpiredData(env, { batchSize: 100, cursor });
237
+ if (result.cursor) await env.OAUTH_KV.put('purge-cursor', result.cursor);
238
+ else await env.OAUTH_KV.delete('purge-cursor'); // done: the next run starts a new sweep
239
+ },
240
+ ```
241
+
242
+ ## `OAuthAuthorizationServer` endpoints have defaults (1.2)
243
+
244
+ If you adopted `OAuthAuthorizationServer` in 1.0 or 1.1, `authorizeEndpoint` and `tokenEndpoint` are now optional. They default to `${issuer}/authorize` and `${issuer}/oauth/token`, under the issuer's path if it has one. Delete them when they match:
245
+
246
+ ```diff
247
+ const authorizationServer = new OAuthAuthorizationServer<Env>({
248
+ issuer: 'https://auth.example.com',
249
+ resources: ['https://mcp.example.com/mcp'],
250
+ - authorizeEndpoint: '/authorize',
251
+ - tokenEndpoint: '/oauth/token',
252
+ });
253
+ ```
254
+
255
+ You still route the authorization endpoint yourself, before `authorizationServer.fetch()`. `parseAuthRequest()` rejects a request that arrives anywhere else, so a route on the wrong path fails on its first request. Construction also rejects an endpoint that another would claim, such as one on the metadata path behind a query. `OAuthProvider` still requires both options.
256
+
257
+ ## Existing stored data: nothing to do
258
+
259
+ - An access token stored without an audience keeps working until it expires. It's treated as bound to the migration resource: the sole configured resource, or `legacyGrantResource` on a multi-resource server.
260
+ - Refresh binds the grant to that resource and returns a bound replacement token.
261
+ - A stored 0.x audience array resolves to the registered resource it contains.
262
+ - A grant bound only to unregistered values fails refresh with `invalid_grant`; conformant clients (Claude, the MCP SDKs) answer by starting a fresh authorization.
263
+ - A multi-resource `OAuthAuthorizationServer` without `legacyGrantResource` has no safe destination for unbound records and rejects them. Set it for the migration window and keep it fixed.
264
+ - Authorization codes issued by 0.x redeem under the same rules, except plain-PKCE ones ([above](#removed-the-implicit-grant-and-plain-pkce-12)).
265
+ - Grants written before 1.0 have no KV key metadata; each refresh adds it.
266
+
267
+ ## New, adopt when useful
268
+
269
+ None of these are needed to upgrade.
270
+
271
+ **Role classes (1.0).** `OAuthAuthorizationServer` runs one authorization server for several resources, and `OAuthResourceServer` hosts a resource in the same Worker or its own, validating over a Service Binding. See [resource-servers.md](resource-servers.md).
272
+
273
+ **`ctx.auth` and `insufficientScope()` (1.0).** Handlers see the verified token beside `ctx.props`, and answer a missing scope with the MCP step-up challenge:
274
+
275
+ ```ts
276
+ if (!ctx.auth.scope.includes('mcp:write')) return insufficientScope(ctx.auth, ['mcp:read', 'mcp:write']);
277
+ ```
278
+
279
+ **`onError.internal` (1.0).** Every library error carries a stable `{ category, reason }` naming the check that failed. The wire response stays generic.
280
+
281
+ ```ts
282
+ onError: ({ status, code, internal }) => console.warn({ status, code, ...internal }),
283
+ ```
284
+
285
+ **`refreshTokenIdleTTL` (1.0).** Sliding expiry: each refresh moves the grant's expiry that far ahead.
286
+
287
+ **Consent and third-party sign-in helpers (1.1).** `beginConsent()`, `approveConsent()`, `denyConsent()`, `isConsentRemembered()`, `beginUpstream()` and `finishUpstream()`, with `cookiePrefix`. See [consent-page.md](consent-page.md) and [upstream-sign-in.md](upstream-sign-in.md).
288
+
289
+ **Consent page facts and error redirects (1.2).** `describeConsent()` returns what a consent page must show. `AuthorizationError.redirectTo` is the ready-made error redirect back to the client, and `authorizationErrorRedirect()` builds one for an error you decide on:
290
+
291
+ ```ts
292
+ try {
293
+ authRequest = await oauth.parseAuthRequest(request);
294
+ } catch (error) {
295
+ if (error instanceof AuthorizationError && error.redirectTo) return Response.redirect(error.redirectTo, 302);
296
+ throw error;
297
+ }
298
+ // …the user declined:
299
+ return Response.redirect(authorizationErrorRedirect(authRequest, 'access_denied'), 302);
300
+ ```
92
301
 
93
- - **Role classes** — `OAuthAuthorizationServer` (one AS, many resources) and `OAuthResourceServer` (host a resource in the AS Worker or its own, validating over a Service Binding). See [resource-servers.md](resource-servers.md).
94
- - **`ctx.auth` and `insufficientScope()`** — handlers see the verified token facts beside `ctx.props` and answer scope shortfalls with the MCP `403` challenge. See the README's scopes section.
95
- - **`onError.internal`** — every library error carries a stable `{ category, reason }` for logs and alerting; the wire stays generic.
96
- - **`refreshTokenIdleTTL`** — opt-in sliding refresh-token expiry.
97
- - Dynamically registered clients in active use renew automatically; grant listing and revocation are KV-bounded.
302
+ **`OAuthError` from `validateToken` (1.2).** An `OAuthResourceServer`'s validator can throw `OAuthError` to choose the response, such as a `429` with `Retry-After`. See [resource-servers.md](resource-servers.md#alongside-your-own-tokens).
@@ -4,14 +4,15 @@ An `OAuthAuthorizationServer` issues tokens; a resource server accepts them. Eve
4
4
 
5
5
  ```ts
6
6
  new OAuthResourceServer<Env, Props>({
7
- resourceMetadata: { resource, authorization_servers: [issuer], scopes_supported: ['calendar:read'] },
7
+ resourceMetadata: { resource, authorization_servers: [issuer] },
8
+ requiredScopes: ['calendar:read'], // needed for any access; advertised, checked by your handler
8
9
  validateToken: (env, request) => (resource, token) =>
9
10
  Promise<{ props; audience; expiresAt?; scope?; userId?; clientId? } | null>,
10
11
  handler: { fetch(request, env, ctx) {} }, // ctx.props: Props, ctx.auth: OAuthResourceAuth
11
12
  });
12
13
  ```
13
14
 
14
- The host publishes RFC 9728 metadata at `/.well-known/oauth-protected-resource<path>`, answers unauthenticated requests with a Bearer challenge that names it and the `scopes_supported` to ask for, calls your validator with its own canonical resource and the presented token, refuses a result whose `audience` is not that resource, and answers `503` when the validator throws. Only `validateToken` changes between the topologies below.
15
+ The host publishes RFC 9728 metadata at `/.well-known/oauth-protected-resource<path>`, answers unauthenticated requests with a Bearer challenge that names it and the `requiredScopes` to ask for (published as `scopes_supported`), calls your validator with its own canonical resource and the presented token, refuses a result whose `audience` is not that resource, and answers `503` when the validator throws. Only `validateToken` changes between the topologies below.
15
16
 
16
17
  ## What the handler sees
17
18
 
@@ -38,8 +39,6 @@ A `WorkerEntrypoint` handler reads the same fields from `this.ctx`; declare it a
38
39
  const authorizationServer = new OAuthAuthorizationServer<Env>({
39
40
  issuer: 'https://auth.example.com',
40
41
  resources: ['https://calendar.example.com/mcp', 'https://drive.example.com/mcp'],
41
- authorizeEndpoint: '/authorize',
42
- tokenEndpoint: '/oauth/token',
43
42
  });
44
43
 
45
44
  const local = (env: Env) => (resource: string, token: string) =>
@@ -151,3 +150,27 @@ validateToken: (env) => async (resource, token) => {
151
150
  ```
152
151
 
153
152
  The host still enforces the audience and expiry it is given, and it fails closed on a malformed `scope`, `userId` or `clientId`. Everything else about that issuer's tokens is between you and it. MCP's security guidance is blunt on the point that a resource server must accept only tokens issued for it; keep `audience` honest.
153
+
154
+ ### Alongside your own tokens
155
+
156
+ One resource can also accept both this authorization server's tokens and an upstream API's own credentials, such as a proxy that lets users present their API token directly. Recognise the upstream's tokens by shape, and validate them yourself. Check the upstream first, and only by a shape the upstream uses, so an expired token of your own is never sent to a third party:
157
+
158
+ ```ts
159
+ validateToken: (env) => async (resource, token) => {
160
+ if (token.startsWith('cfut_')) {
161
+ const upstream = await fetch('https://api.example.com/user', { headers: { Authorization: `Bearer ${token}` } });
162
+ if (upstream.status === 429) {
163
+ throw new OAuthError('temporarily_unavailable', {
164
+ description: 'Upstream rate limited',
165
+ statusCode: 429,
166
+ headers: { 'Retry-After': upstream.headers.get('Retry-After') ?? '30' },
167
+ });
168
+ }
169
+ if (!upstream.ok) return null; // 401 invalid_token
170
+ return { props: await upstream.json(), audience: resource };
171
+ }
172
+ return env.AUTH_SERVER.validateToken(resource, token);
173
+ },
174
+ ```
175
+
176
+ Return `null` for a token that isn't valid here, which gets the `401` challenge. Throw `OAuthError` for a specific answer: `invalid_token` becomes a `401` and `insufficient_scope` a `403` with its challenge (naming `requiredScopes`, or the resource's), each with a Bearer challenge; any other code keeps its status and headers. Anything else thrown is a `503`. Throw it in this Worker's validator: an `OAuthError` thrown in another Worker arrives over RPC as a plain `Error`. With the combined `OAuthProvider`, the same job is `resolveExternalToken`, which throws `ExternalTokenError`.
@@ -52,13 +52,8 @@ If the third party sends the user back with an error (they declined there, or it
52
52
 
53
53
  ```ts
54
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);
55
+ if (new URL(req.url).searchParams.get('error')) {
56
+ headers.set('Location', authorizationErrorRedirect(original, 'access_denied')); // state and iss included
62
57
  return new Response(null, { status: 302, headers });
63
58
  }
64
59
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "OAuth provider for Cloudflare Workers",
5
5
  "main": "dist/oauth-provider.js",
6
6
  "types": "dist/oauth-provider.d.ts",
@@ -23,6 +23,7 @@
23
23
  "typecheck": "tsc",
24
24
  "test": "vitest run",
25
25
  "test:conformance": "vitest run conformance",
26
+ "test:conformance:upstream": "node conformance/upstream/run.mjs",
26
27
  "test:watch": "vitest",
27
28
  "prepublishOnly": "npm run build",
28
29
  "prettier": "prettier -w ."
@@ -31,6 +32,7 @@
31
32
  "@changesets/changelog-github": "^0.5.2",
32
33
  "@changesets/cli": "^2.29.8",
33
34
  "@cloudflare/workers-types": "^5.20260730.1",
35
+ "@modelcontextprotocol/conformance": "0.2.0-alpha.11",
34
36
  "pkg-pr-new": "^0.0.62",
35
37
  "prettier": "^3.7.4",
36
38
  "tsdown": "^0.18.1",
@@ -1,35 +1,49 @@
1
1
  ---
2
2
  name: workers-oauth-provider-migrate-1.0
3
- description: Migrate a Cloudflare Worker from @cloudflare/workers-oauth-provider 0.x to 1.0. Use when upgrading that dependency, when OAuthProvider construction throws about resourceMetadata.resource or resourceMatchOriginOnly, or when asked to adopt the 1.0 role-based API (OAuthAuthorizationServer / OAuthResourceServer).
3
+ description: Upgrade a Cloudflare Worker's @cloudflare/workers-oauth-provider from 0.x, 1.0 or 1.1 to the latest 1.x. Use when bumping that dependency, or when OAuthProvider / OAuthAuthorizationServer / OAuthResourceServer construction throws after an upgrade.
4
4
  ---
5
5
 
6
- # Migrate @cloudflare/workers-oauth-provider 0.x → 1.0
6
+ # Upgrade @cloudflare/workers-oauth-provider to the latest 1.x
7
7
 
8
- The single source of truth for every change is the migration guide shipped with the package:
9
- `node_modules/@cloudflare/workers-oauth-provider/docs/migration-1.0.md`
10
- (also at https://github.com/cloudflare/workers-oauth-provider/blob/main/docs/migration-1.0.md).
11
- Read it fully before editing. This skill is the procedure around it; do not work from memory of 0.x or from this file alone.
8
+ Reference (read the section for every hit): `node_modules/@cloudflare/workers-oauth-provider/docs/migration-1.0.md`.
9
+ Types and JSDoc: `node_modules/@cloudflare/workers-oauth-provider/dist/oauth-provider.d.ts`.
10
+ No KV migration exists or is needed; never edit stored data.
12
11
 
13
12
  ## Procedure
14
13
 
15
- 1. **Detect the shape.** Find `new OAuthProvider(` and read its options. The common shape is one Worker acting as authorization server and resource server; that shape stays on `OAuthProvider` in 1.0. Do not introduce `OAuthAuthorizationServer`/`OAuthResourceServer` unless the user asks for a multi-Worker or multi-resource topology.
16
- 2. **Choose the canonical resource — ask the user.** `resourceMetadata: { resource }` is required in 1.0. The value is the URL MCP clients connect to (often an existing `apiRoute` on the Worker's public origin, e.g. `https://mcp.example.com/mcp`). Infer a candidate from `wrangler.jsonc` routes/custom domains plus `apiRoute`, present it, and get confirmation — it becomes the token audience, so it must be right.
17
- 3. **Apply the guide's changes** that match the code: add `resourceMetadata.resource`; delete `resourceMatchOriginOnly`; make `resolveExternalToken` return the canonical `audience`; single-string `resource`/`aud` types; check `apiRoute`s are the resource path or descendants.
18
- 4. **Bump the dependency** to `^1.0.0` and install.
19
- 5. **Verify** (below), then walk the user through the guide's "Existing stored data" section so they know what their live clients will experience (nothing, in the common case).
20
-
21
- ## Stop and ask the user
22
-
23
- - The canonical `resource` value (step 2). Never guess silently.
24
- - On a multi-resource `OAuthAuthorizationServer`: which resource is `legacyGrantResource` (the migration destination for pre-1.0 grants). Omitting it makes old grants reauthorize.
25
- - Any DCR client base registered with narrow `grant_types`: 1.0 enforces them; confirm the registered types cover what clients actually send before deploying.
26
- - Adopting new 1.0 surface (role classes, `ctx.auth`, `insufficientScope`, `onError.internal`) is optional — offer, don't do unasked.
14
+ 1. Note the installed version (`package.json`). Skip guide sections tagged older than it.
15
+ 2. Search the project for each pattern below and apply the fix.
16
+ 3. Bump to the latest `^1`, install, typecheck, run tests. Construction errors quote the rule; grep the guide for the message.
17
+ 4. Keep `OAuthProvider` if the project uses it. Don't move to `OAuthAuthorizationServer`/`OAuthResourceServer` unless asked.
18
+
19
+ | Pattern in the project | Fix | Guide section |
20
+ | ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
21
+ | `new OAuthProvider(` without `resourceMetadata` | add `resourceMetadata: { resource }` (ask the user for the value) | The canonical resource is required |
22
+ | `resourceMatchOriginOnly` | delete | Removed: `resourceMatchOriginOnly` |
23
+ | `resolveExternalToken` | return `audience: <resource>` | `resolveExternalToken` names its audience |
24
+ | `scopes_supported` inside `resourceMetadata` | move to top-level `requiredScopes` | `requiredScopes` replaces `resourceMetadata.scopes_supported` |
25
+ | `allowImplicitFlow`, `allowPlainPKCE` | delete; own clients must use code + S256 | Removed: the implicit grant and plain PKCE |
26
+ | `redirectUris` / redirect URIs with `http://` non-loopback or a custom scheme | https or loopback; native apps: `allowPrivateUseRedirectUris: true` | Redirect URIs must be HTTPS or loopback HTTP |
27
+ | `clientRegistrationTTL: 0`; any `*TTL` below 60 | `0` → `undefined`; raise to ≥ 60 | Lifetimes are validated at construction |
28
+ | `userId:` built with `:` (e.g. `` `${a}:${b}` ``) | `encodeURIComponent(...)`, same value in `listUserGrants`/`revokeGrant` | User IDs cannot contain `:` |
29
+ | `revokeExistingGrantsBatchSize` | delete | Removed: `revokeExistingGrantsBatchSize` |
30
+ | imports of `resourceMatches`, `validateResourceUri`, `isValidOAuthScopeToken`, `base64UrlToBytes`, `parseJwtJsonPart`, `getJwtCryptoAlgorithms` | inline the logic; no replacement export | Internal helpers are no longer exported |
31
+ | `createClient(` / `updateClient(` | only `authorization_code`/`refresh_token`/enabled grants, `code` responses; don't update CIMD clients | Client helpers validate what they store |
32
+ | `tokenExchangeCallback` | `OAuthError('invalid_grant')` now revokes the grant; `env` is available; check returned TTLs | `tokenExchangeCallback` |
33
+ | `purgeExpiredData(` | persist `result.cursor`, pass it back as `cursor` | `purgeExpiredData()` resumes from a cursor |
34
+ | `new OAuthAuthorizationServer(` | drop `authorizeEndpoint`/`tokenEndpoint` if they equal `${issuer}/authorize`, `${issuer}/oauth/token` | `OAuthAuthorizationServer` endpoints have defaults |
35
+ | `ExchangeTokenOptions.aud`, `AuthRequest.resource` as arrays | single strings | Type changes |
36
+
37
+ ## Ask the user, don't guess
38
+
39
+ - The canonical `resource` (the URL MCP clients connect to; becomes the token audience).
40
+ - On a multi-resource `OAuthAuthorizationServer`: `legacyGrantResource`, the destination for pre-1.0 grants. Fixed once deployed.
41
+ - Whether registered clients use remote `http` or custom-scheme redirect URIs (they stop authorizing), and whether any user IDs contain `:`. Neither shows up in a typecheck.
42
+ - Whether DCR clients registered narrow `grant_types` (now enforced).
43
+ - Adopting optional features (role classes, `ctx.auth`, `insufficientScope`, consent helpers, `refreshTokenIdleTTL`): offer, don't do unasked.
27
44
 
28
45
  ## Verify
29
46
 
30
- 1. `tsc`/typecheck and the project's tests pass.
31
- 2. `wrangler dev`, then:
32
- - `curl -i http://localhost:8787<api route>` → 401 whose `WWW-Authenticate` names `resource_metadata="…/.well-known/oauth-protected-resource<resource path>"`. This works locally whatever the configured resource's origin.
33
- - The metadata document itself is origin-strict (RFC 9728 §3): its well-known URL is `<resource origin>/.well-known/oauth-protected-resource<resource path>`. When the dev config's resource is on the loopback origin (e.g. `http://localhost:8787/mcp`), `curl http://localhost:8787/.well-known/oauth-protected-resource/mcp` → 200 with the exact `resource`. A production resource origin serves its document only there — after deploy: `curl https://<host>/.well-known/oauth-protected-resource<resource path>`.
34
- - Construction errors surface on the first request and name the violated rule; fix per the guide.
35
- 3. If the deployment has live users, re-read "Existing stored data — nothing to do" in the guide and confirm no step you took contradicts it (no KV edits, no `legacyGrantResource` changes after rollout).
47
+ - `wrangler dev`, then `curl -i http://localhost:8787<api route>`: `401` whose `WWW-Authenticate` has `resource_metadata="…/.well-known/oauth-protected-resource<resource path>"`.
48
+ - With a loopback dev resource, `curl http://localhost:8787/.well-known/oauth-protected-resource<resource path>`: `200` with the exact `resource`. Production serves it only on the resource's own origin.
49
+ - `curl http://localhost:8787/.well-known/oauth-authorization-server<issuer path>`: `authorization_endpoint` and `token_endpoint` unchanged from before the upgrade. `OAuthProvider` serves it at the origin root; an `OAuthAuthorizationServer` issuer's path goes after the prefix (RFC 8414 §3.1), e.g. `…/oauth-authorization-server/tenant` for `https://example.com/tenant`.