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