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