@cloudflare/workers-oauth-provider 1.1.0 → 1.2.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 +35 -484
- package/dist/oauth-provider.d.ts +294 -179
- package/dist/oauth-provider.js +717 -501
- package/docs/advanced-configuration.md +14 -7
- package/docs/authorization-server.md +232 -0
- package/docs/consent-page.md +14 -19
- package/docs/mcp-discovery.md +78 -0
- package/docs/migration-1.0.md +248 -43
- package/docs/resource-servers.md +27 -4
- package/docs/upstream-sign-in.md +2 -7
- package/package.json +3 -1
- package/skills/migrate-to-1.0/SKILL.md +38 -24
package/docs/migration-1.0.md
CHANGED
|
@@ -1,17 +1,35 @@
|
|
|
1
|
-
# Migrating
|
|
1
|
+
# Migrating to 1.x
|
|
2
2
|
|
|
3
|
-
1.0
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
##
|
|
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
|
|
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
|
|
47
|
+
Construction validates the rest of the configuration against it:
|
|
30
48
|
|
|
31
|
-
- Every `apiRoute`
|
|
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
|
|
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
|
-
|
|
54
|
+
### Removed: `resourceMatchOriginOnly` (1.0)
|
|
37
55
|
|
|
38
|
-
A configuration that still sets it
|
|
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
|
-
|
|
58
|
+
### `resolveExternalToken` names its audience (1.0)
|
|
41
59
|
|
|
42
|
-
`ResolveExternalTokenResult.audience` is required
|
|
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
|
-
|
|
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
|
-
|
|
73
|
+
### `requiredScopes` replaces `resourceMetadata.scopes_supported` (1.2)
|
|
56
74
|
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
+
### Removed: the implicit grant and plain PKCE (1.2)
|
|
66
89
|
|
|
67
|
-
|
|
90
|
+
OAuth 2.1 and MCP use the authorization code flow with S256 PKCE only.
|
|
68
91
|
|
|
69
|
-
|
|
92
|
+
```diff
|
|
93
|
+
new OAuthProvider<Env>({
|
|
94
|
+
// …
|
|
95
|
+
- allowImplicitFlow: true,
|
|
96
|
+
- allowPlainPKCE: true,
|
|
97
|
+
});
|
|
98
|
+
```
|
|
70
99
|
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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. The rule applies at dynamic registration, in `createClient()` and `updateClient()`, and on every authorization request. A CIMD document may list other redirect URIs too, since it's shared by every server the client uses; a request that uses one of them is refused, and the rest of the document keeps working.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
package/docs/resource-servers.md
CHANGED
|
@@ -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]
|
|
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 `
|
|
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`.
|
package/docs/upstream-sign-in.md
CHANGED
|
@@ -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
|
-
|
|
56
|
-
|
|
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.
|
|
3
|
+
"version": "1.2.0",
|
|
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:
|
|
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
|
-
#
|
|
6
|
+
# Upgrade @cloudflare/workers-oauth-provider to the latest 1.x
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
`node_modules/@cloudflare/workers-oauth-provider/
|
|
10
|
-
|
|
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.
|
|
16
|
-
2.
|
|
17
|
-
3.
|
|
18
|
-
4.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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`.
|