@cloudflare/workers-oauth-provider 0.2.3 → 0.3.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 +68 -4
- package/dist/oauth-provider.d.ts +67 -10
- package/dist/oauth-provider.js +118 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -81,6 +81,12 @@ export default new OAuthProvider({
|
|
|
81
81
|
// Defaults to false.
|
|
82
82
|
allowImplicitFlow: false,
|
|
83
83
|
|
|
84
|
+
// Optional: Controls whether the plain PKCE code_challenge_method is allowed.
|
|
85
|
+
// OAuth 2.1 recommends using S256 exclusively as plain offers no cryptographic protection.
|
|
86
|
+
// When false, only S256 is accepted and advertised in the metadata endpoint.
|
|
87
|
+
// Defaults to true for backward compatibility.
|
|
88
|
+
allowPlainPKCE: true,
|
|
89
|
+
|
|
84
90
|
// Optional: Controls whether public clients (clients without a secret, like SPAs)
|
|
85
91
|
// can register via the dynamic client registration endpoint.
|
|
86
92
|
// When true, only confidential clients can register.
|
|
@@ -94,6 +100,22 @@ export default new OAuthProvider({
|
|
|
94
100
|
// Set to 0 to disable refresh tokens (only access tokens will be issued).
|
|
95
101
|
// For example: 3600 = 1 hour, 86400 = 1 day, 2592000 = 30 days
|
|
96
102
|
refreshTokenTTL: 2592000, // 30 days
|
|
103
|
+
|
|
104
|
+
// Optional: Time-to-live for access tokens in seconds.
|
|
105
|
+
// Defaults to 1 hour (3600 seconds) if not specified.
|
|
106
|
+
accessTokenTTL: 3600,
|
|
107
|
+
|
|
108
|
+
// Optional: Controls whether OAuth 2.0 Token Exchange (RFC 8693) is allowed.
|
|
109
|
+
// When false, the token exchange grant type will not be advertised in metadata
|
|
110
|
+
// and token exchange requests will be rejected.
|
|
111
|
+
// Defaults to false.
|
|
112
|
+
allowTokenExchangeGrant: false,
|
|
113
|
+
|
|
114
|
+
// Optional: Explicitly enable Client ID Metadata Document (CIMD) support.
|
|
115
|
+
// When true, URL-formatted client_ids will be fetched as metadata documents.
|
|
116
|
+
// Requires the 'global_fetch_strictly_public' compatibility flag.
|
|
117
|
+
// See the CIMD section below for details. Defaults to false.
|
|
118
|
+
clientIdMetadataDocumentEnabled: false,
|
|
97
119
|
});
|
|
98
120
|
|
|
99
121
|
// The default handler object - the OAuthProvider will pass through HTTP requests to this object's fetch method
|
|
@@ -304,6 +326,35 @@ new OAuthProvider({
|
|
|
304
326
|
|
|
305
327
|
By default, the `onError` callback is set to ``({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`)``.
|
|
306
328
|
|
|
329
|
+
## Protected Resource Metadata (RFC 9728)
|
|
330
|
+
|
|
331
|
+
The library automatically serves a `/.well-known/oauth-protected-resource` endpoint. By default, it uses the request origin as the resource identifier and the token endpoint's origin as the authorization server. You can customize this with the `resourceMetadata` option:
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
new OAuthProvider({
|
|
335
|
+
// ... other options ...
|
|
336
|
+
resourceMetadata: {
|
|
337
|
+
resource: 'https://api.example.com',
|
|
338
|
+
authorization_servers: ['https://auth.example.com'],
|
|
339
|
+
scopes_supported: ['read', 'write'],
|
|
340
|
+
bearer_methods_supported: ['header'],
|
|
341
|
+
resource_name: 'My API',
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
## Standards Compliance
|
|
347
|
+
|
|
348
|
+
This library implements the following OAuth and MCP specifications:
|
|
349
|
+
|
|
350
|
+
- [OAuth 2.1 (draft-ietf-oauth-v2-1-13)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13) — Core authorization framework with PKCE
|
|
351
|
+
- [OAuth 2.0 Authorization Server Metadata (RFC 8414)](https://datatracker.ietf.org/doc/html/rfc8414) — `/.well-known/oauth-authorization-server` discovery endpoint
|
|
352
|
+
- [OAuth 2.0 Protected Resource Metadata (RFC 9728)](https://datatracker.ietf.org/doc/html/rfc9728) — `/.well-known/oauth-protected-resource` discovery endpoint
|
|
353
|
+
- [OAuth 2.0 Dynamic Client Registration (RFC 7591)](https://datatracker.ietf.org/doc/html/rfc7591) — Dynamic client registration endpoint
|
|
354
|
+
- [OAuth Client ID Metadata Documents (draft-ietf-oauth-client-id-metadata-document)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document) — HTTPS URLs as client IDs
|
|
355
|
+
|
|
356
|
+
These are the specifications required by the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
|
|
357
|
+
|
|
307
358
|
## Implementation Notes
|
|
308
359
|
|
|
309
360
|
### End-to-end encryption
|
|
@@ -325,11 +376,22 @@ This library implements a compromise: At any particular time, a grant may have t
|
|
|
325
376
|
|
|
326
377
|
## Client ID Metadata Document (CIMD) Support
|
|
327
378
|
|
|
328
|
-
This library supports [Client ID Metadata Documents](https://
|
|
379
|
+
This library supports [Client ID Metadata Documents](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document), which allow clients to use HTTPS URLs as their `client_id`. When a client presents an HTTPS URL with a non-root path as its `client_id`, the library will fetch and validate the metadata document from that URL.
|
|
329
380
|
|
|
330
381
|
### Enabling CIMD
|
|
331
382
|
|
|
332
|
-
|
|
383
|
+
CIMD support is opt-in and requires two things:
|
|
384
|
+
|
|
385
|
+
1. Set `clientIdMetadataDocumentEnabled: true` in your OAuthProvider options:
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
new OAuthProvider({
|
|
389
|
+
// ... other options ...
|
|
390
|
+
clientIdMetadataDocumentEnabled: true,
|
|
391
|
+
});
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
2. Add the `global_fetch_strictly_public` compatibility flag to your `wrangler.jsonc`:
|
|
333
395
|
|
|
334
396
|
```jsonc
|
|
335
397
|
{
|
|
@@ -337,9 +399,11 @@ To enable CIMD support, you must add the `global_fetch_strictly_public` compatib
|
|
|
337
399
|
}
|
|
338
400
|
```
|
|
339
401
|
|
|
340
|
-
|
|
402
|
+
The compatibility flag is required for SSRF (Server-Side Request Forgery) protection. Due to a legacy quirk, `fetch()` requests to URLs within your zone's domain are sent directly to the origin server, bypassing Cloudflare. The `global_fetch_strictly_public` flag disables this behavior. See [Cloudflare's documentation](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public) for more details.
|
|
403
|
+
|
|
404
|
+
When CIMD is not enabled (the default), URL-formatted `client_id` values fall through to standard KV lookup. When enabled, if fetching the metadata document fails, the library logs a warning and returns an `invalid_client` error, allowing MCP clients to recover by falling back to Dynamic Client Registration.
|
|
341
405
|
|
|
342
|
-
|
|
406
|
+
The OAuth metadata endpoint reports `client_id_metadata_document_supported: true` only when both the option is enabled and the compatibility flag is present.
|
|
343
407
|
|
|
344
408
|
## Written using Claude
|
|
345
409
|
|
package/dist/oauth-provider.d.ts
CHANGED
|
@@ -13,8 +13,10 @@ declare enum GrantType {
|
|
|
13
13
|
/**
|
|
14
14
|
* Aliases for either type of Handler that makes .fetch required
|
|
15
15
|
*/
|
|
16
|
-
type ExportedHandlerWithFetch = ExportedHandler & Pick<Required<ExportedHandler
|
|
17
|
-
type WorkerEntrypointWithFetch = WorkerEntrypoint &
|
|
16
|
+
type ExportedHandlerWithFetch<Env = Cloudflare.Env> = ExportedHandler<Env> & Pick<Required<ExportedHandler<Env>>, 'fetch'>;
|
|
17
|
+
type WorkerEntrypointWithFetch<Env = Cloudflare.Env> = WorkerEntrypoint<Env> & {
|
|
18
|
+
fetch: NonNullable<WorkerEntrypoint['fetch']>;
|
|
19
|
+
};
|
|
18
20
|
/**
|
|
19
21
|
* Configuration options for the OAuth Provider
|
|
20
22
|
*/
|
|
@@ -119,7 +121,7 @@ interface ResolveExternalTokenResult {
|
|
|
119
121
|
*/
|
|
120
122
|
audience?: string | string[];
|
|
121
123
|
}
|
|
122
|
-
interface OAuthProviderOptions {
|
|
124
|
+
interface OAuthProviderOptions<Env = Cloudflare.Env> {
|
|
123
125
|
/**
|
|
124
126
|
* URL(s) for API routes. Requests with URLs starting with any of these prefixes
|
|
125
127
|
* will be treated as API requests and require a valid access token.
|
|
@@ -137,7 +139,7 @@ interface OAuthProviderOptions {
|
|
|
137
139
|
* Used with `apiRoute` for the single-handler configuration. This is incompatible with
|
|
138
140
|
* the `apiHandlers` property. You must use either `apiRoute` + `apiHandler` OR `apiHandlers`, not both.
|
|
139
141
|
*/
|
|
140
|
-
apiHandler?: ExportedHandlerWithFetch | (new (ctx: ExecutionContext, env:
|
|
142
|
+
apiHandler?: ExportedHandlerWithFetch<Env> | (new (ctx: ExecutionContext, env: Env) => WorkerEntrypointWithFetch<Env>);
|
|
141
143
|
/**
|
|
142
144
|
* Map of API routes to their corresponding handlers for the multi-handler configuration.
|
|
143
145
|
* The keys are the API routes (strings only, not arrays), and the values are the handlers.
|
|
@@ -148,12 +150,12 @@ interface OAuthProviderOptions {
|
|
|
148
150
|
* `apiRoute` + `apiHandler` (single-handler configuration) OR `apiHandlers` (multi-handler
|
|
149
151
|
* configuration), not both.
|
|
150
152
|
*/
|
|
151
|
-
apiHandlers?: Record<string, ExportedHandlerWithFetch | (new (ctx: ExecutionContext, env:
|
|
153
|
+
apiHandlers?: Record<string, ExportedHandlerWithFetch<Env> | (new (ctx: ExecutionContext, env: Env) => WorkerEntrypointWithFetch<Env>)>;
|
|
152
154
|
/**
|
|
153
155
|
* Handler for all non-API requests or API requests without a valid token.
|
|
154
156
|
* Can be either an ExportedHandler object with a fetch method or a class extending WorkerEntrypoint.
|
|
155
157
|
*/
|
|
156
|
-
defaultHandler: ExportedHandler | (new (ctx: ExecutionContext, env:
|
|
158
|
+
defaultHandler: ExportedHandler<Env> | (new (ctx: ExecutionContext, env: Env) => WorkerEntrypointWithFetch<Env>);
|
|
157
159
|
/**
|
|
158
160
|
* URL of the OAuth authorization endpoint where users can grant permissions.
|
|
159
161
|
* This URL is used in OAuth metadata and is not handled by the provider itself.
|
|
@@ -191,6 +193,13 @@ interface OAuthProviderOptions {
|
|
|
191
193
|
* Defaults to false.
|
|
192
194
|
*/
|
|
193
195
|
allowImplicitFlow?: boolean;
|
|
196
|
+
/**
|
|
197
|
+
* Controls whether the plain PKCE method is allowed.
|
|
198
|
+
* OAuth 2.1 recommends using S256 exclusively as plain offers no cryptographic protection.
|
|
199
|
+
* When set to false, only the S256 code_challenge_method will be accepted.
|
|
200
|
+
* Defaults to true for backward compatibility.
|
|
201
|
+
*/
|
|
202
|
+
allowPlainPKCE?: boolean;
|
|
194
203
|
/**
|
|
195
204
|
* Controls whether OAuth 2.0 Token Exchange (RFC 8693) is allowed.
|
|
196
205
|
* When false, the token exchange grant type will not be advertised in metadata
|
|
@@ -237,6 +246,47 @@ interface OAuthProviderOptions {
|
|
|
237
246
|
status: number;
|
|
238
247
|
headers: Record<string, string>;
|
|
239
248
|
}) => Response | void;
|
|
249
|
+
/**
|
|
250
|
+
* Explicitly enable Client ID Metadata Document (CIMD) support.
|
|
251
|
+
* When true, URL-formatted client_ids will be fetched as metadata documents.
|
|
252
|
+
* Requires the 'global_fetch_strictly_public' compatibility flag.
|
|
253
|
+
* Defaults to false.
|
|
254
|
+
*/
|
|
255
|
+
clientIdMetadataDocumentEnabled?: boolean;
|
|
256
|
+
/**
|
|
257
|
+
* Optional metadata for RFC 9728 OAuth 2.0 Protected Resource Metadata.
|
|
258
|
+
* Controls the response served at /.well-known/oauth-protected-resource.
|
|
259
|
+
*
|
|
260
|
+
* If not provided, the endpoint will be automatically generated using the request origin
|
|
261
|
+
* as the resource identifier, and the token endpoint's origin as the authorization server.
|
|
262
|
+
*/
|
|
263
|
+
resourceMetadata?: {
|
|
264
|
+
/**
|
|
265
|
+
* The protected resource identifier URL (RFC 9728 `resource` field).
|
|
266
|
+
* If not set, defaults to the request URL's origin.
|
|
267
|
+
*/
|
|
268
|
+
resource?: string;
|
|
269
|
+
/**
|
|
270
|
+
* List of authorization server issuer URLs that can issue tokens for this resource.
|
|
271
|
+
* If not set, defaults to the token endpoint's origin (consistent with the issuer
|
|
272
|
+
* in authorization server metadata).
|
|
273
|
+
*/
|
|
274
|
+
authorization_servers?: string[];
|
|
275
|
+
/**
|
|
276
|
+
* Scopes supported by this protected resource.
|
|
277
|
+
* If not set, falls back to the top-level scopesSupported option.
|
|
278
|
+
*/
|
|
279
|
+
scopes_supported?: string[];
|
|
280
|
+
/**
|
|
281
|
+
* Methods by which bearer tokens can be presented to this resource.
|
|
282
|
+
* Defaults to ["header"].
|
|
283
|
+
*/
|
|
284
|
+
bearer_methods_supported?: string[];
|
|
285
|
+
/**
|
|
286
|
+
* Human-readable name for this resource.
|
|
287
|
+
*/
|
|
288
|
+
resource_name?: string;
|
|
289
|
+
};
|
|
240
290
|
}
|
|
241
291
|
/**
|
|
242
292
|
* Helper methods for OAuth operations provided to handler functions
|
|
@@ -467,6 +517,13 @@ interface CompleteAuthorizationOptions {
|
|
|
467
517
|
* authorized by this grant
|
|
468
518
|
*/
|
|
469
519
|
props: any;
|
|
520
|
+
/**
|
|
521
|
+
* Revokes all existing grants for this user+client combination
|
|
522
|
+
* after storing the new grant. Defaults to true. This prevents stale
|
|
523
|
+
* tokens from causing infinite re-auth loops when props change.
|
|
524
|
+
* Set to false to allow multiple concurrent grants per user+client.
|
|
525
|
+
*/
|
|
526
|
+
revokeExistingGrants?: boolean;
|
|
470
527
|
}
|
|
471
528
|
/**
|
|
472
529
|
* Authorization grant record
|
|
@@ -714,13 +771,13 @@ interface GrantSummary {
|
|
|
714
771
|
* Implements authorization code flow with support for refresh tokens
|
|
715
772
|
* and dynamic client registration.
|
|
716
773
|
*/
|
|
717
|
-
declare class OAuthProvider {
|
|
774
|
+
declare class OAuthProvider<Env = Cloudflare.Env> {
|
|
718
775
|
#private;
|
|
719
776
|
/**
|
|
720
777
|
* Creates a new OAuth provider instance
|
|
721
778
|
* @param options - Configuration options for the provider
|
|
722
779
|
*/
|
|
723
|
-
constructor(options: OAuthProviderOptions);
|
|
780
|
+
constructor(options: OAuthProviderOptions<Env>);
|
|
724
781
|
/**
|
|
725
782
|
* Main fetch handler for the Worker
|
|
726
783
|
* Routes requests to the appropriate handler based on the URL
|
|
@@ -729,7 +786,7 @@ declare class OAuthProvider {
|
|
|
729
786
|
* @param ctx - Cloudflare Worker execution context
|
|
730
787
|
* @returns A Promise resolving to an HTTP Response
|
|
731
788
|
*/
|
|
732
|
-
fetch(request: Request, env:
|
|
789
|
+
fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response>;
|
|
733
790
|
}
|
|
734
791
|
/**
|
|
735
792
|
* Gets OAuthHelpers for the given environment
|
|
@@ -737,6 +794,6 @@ declare class OAuthProvider {
|
|
|
737
794
|
* @param env - Cloudflare Worker environment variables
|
|
738
795
|
* @returns An instance of OAuthHelpers
|
|
739
796
|
*/
|
|
740
|
-
declare function getOAuthApi(options: OAuthProviderOptions
|
|
797
|
+
declare function getOAuthApi<Env = Cloudflare.Env>(options: OAuthProviderOptions<Env>, env: Env): OAuthHelpers;
|
|
741
798
|
//#endregion
|
|
742
799
|
export { AuthRequest, ClientInfo, CompleteAuthorizationOptions, ExchangeTokenOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, getOAuthApi };
|
package/dist/oauth-provider.js
CHANGED
|
@@ -141,7 +141,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
141
141
|
async fetch(request, env, ctx) {
|
|
142
142
|
const url = new URL(request.url);
|
|
143
143
|
if (request.method === "OPTIONS") {
|
|
144
|
-
if (this.isApiRequest(url) || url.pathname === "/.well-known/oauth-authorization-server" || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return this.addCorsHeaders(new Response(null, {
|
|
144
|
+
if (this.isApiRequest(url) || url.pathname === "/.well-known/oauth-authorization-server" || url.pathname === "/.well-known/oauth-protected-resource" || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return this.addCorsHeaders(new Response(null, {
|
|
145
145
|
status: 204,
|
|
146
146
|
headers: { "Content-Length": "0" }
|
|
147
147
|
}), request);
|
|
@@ -150,6 +150,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
150
150
|
const response = await this.handleMetadataDiscovery(url);
|
|
151
151
|
return this.addCorsHeaders(response, request);
|
|
152
152
|
}
|
|
153
|
+
if (url.pathname === "/.well-known/oauth-protected-resource") {
|
|
154
|
+
const response = this.handleProtectedResourceMetadata(url);
|
|
155
|
+
return this.addCorsHeaders(response, request);
|
|
156
|
+
}
|
|
153
157
|
if (this.isTokenEndpoint(url)) {
|
|
154
158
|
const parsed = await this.parseTokenEndpointRequest(request, env);
|
|
155
159
|
if (parsed instanceof Response) return this.addCorsHeaders(parsed, request);
|
|
@@ -287,8 +291,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
287
291
|
* @returns True if the URL matches the API route
|
|
288
292
|
*/
|
|
289
293
|
matchApiRoute(url, route) {
|
|
290
|
-
if (this.isPath(route))
|
|
291
|
-
|
|
294
|
+
if (this.isPath(route)) {
|
|
295
|
+
if (route === "/") return url.pathname === "/";
|
|
296
|
+
return url.pathname.startsWith(route);
|
|
297
|
+
} else {
|
|
292
298
|
const apiUrl = new URL(route);
|
|
293
299
|
return url.hostname === apiUrl.hostname && url.pathname.startsWith(apiUrl.pathname);
|
|
294
300
|
}
|
|
@@ -367,9 +373,28 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
367
373
|
"none"
|
|
368
374
|
],
|
|
369
375
|
revocation_endpoint: tokenEndpoint,
|
|
370
|
-
code_challenge_methods_supported: ["plain", "S256"],
|
|
371
|
-
client_id_metadata_document_supported: this.hasGlobalFetchStrictlyPublic()
|
|
376
|
+
code_challenge_methods_supported: this.options.allowPlainPKCE !== false ? ["plain", "S256"] : ["S256"],
|
|
377
|
+
client_id_metadata_document_supported: !!this.options.clientIdMetadataDocumentEnabled && this.hasGlobalFetchStrictlyPublic()
|
|
378
|
+
};
|
|
379
|
+
return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Handles the OAuth Protected Resource Metadata endpoint
|
|
383
|
+
* Implements RFC 9728 for OAuth Protected Resource Metadata
|
|
384
|
+
* @param requestUrl - The URL of the incoming request
|
|
385
|
+
* @returns Response with protected resource metadata
|
|
386
|
+
*/
|
|
387
|
+
handleProtectedResourceMetadata(requestUrl) {
|
|
388
|
+
const rm = this.options.resourceMetadata;
|
|
389
|
+
const tokenEndpointUrl = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
|
|
390
|
+
const authServerOrigin = new URL(tokenEndpointUrl).origin;
|
|
391
|
+
const metadata = {
|
|
392
|
+
resource: rm?.resource ?? requestUrl.origin,
|
|
393
|
+
authorization_servers: rm?.authorization_servers ?? [authServerOrigin],
|
|
394
|
+
scopes_supported: rm?.scopes_supported ?? this.options.scopesSupported,
|
|
395
|
+
bearer_methods_supported: rm?.bearer_methods_supported ?? ["header"]
|
|
372
396
|
};
|
|
397
|
+
if (rm?.resource_name) metadata.resource_name = rm.resource_name;
|
|
373
398
|
return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
|
|
374
399
|
}
|
|
375
400
|
/**
|
|
@@ -406,12 +431,17 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
406
431
|
const grantKey = `grant:${userId}:${grantId}`;
|
|
407
432
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
408
433
|
if (!grantData) return this.createErrorResponse("invalid_grant", "Grant not found or authorization code expired");
|
|
409
|
-
if (!grantData.authCodeId)
|
|
434
|
+
if (!grantData.authCodeId) {
|
|
435
|
+
try {
|
|
436
|
+
await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
|
|
437
|
+
} catch {}
|
|
438
|
+
return this.createErrorResponse("invalid_grant", "Authorization code already used");
|
|
439
|
+
}
|
|
410
440
|
if (await hashSecret(code) !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", "Invalid authorization code");
|
|
411
441
|
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", "Client ID mismatch");
|
|
412
442
|
const isPkceEnabled = !!grantData.codeChallenge;
|
|
413
443
|
if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", "redirect_uri is required when not using PKCE");
|
|
414
|
-
if (redirectUri && !clientInfo.redirectUris
|
|
444
|
+
if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", "Invalid redirect URI");
|
|
415
445
|
if (!isPkceEnabled && codeVerifier) return this.createErrorResponse("invalid_request", "code_verifier provided for a flow that did not use PKCE");
|
|
416
446
|
if (isPkceEnabled) {
|
|
417
447
|
if (!codeVerifier) return this.createErrorResponse("invalid_request", "code_verifier is required for PKCE");
|
|
@@ -907,7 +937,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
907
937
|
registration_client_uri: `${this.options.clientRegistrationEndpoint}/${clientId}`,
|
|
908
938
|
client_id_issued_at: clientInfo.registrationDate
|
|
909
939
|
};
|
|
910
|
-
if (clientSecret)
|
|
940
|
+
if (clientSecret) {
|
|
941
|
+
response.client_secret = clientSecret;
|
|
942
|
+
response.client_secret_expires_at = 0;
|
|
943
|
+
response.client_secret_issued_at = clientInfo.registrationDate;
|
|
944
|
+
}
|
|
911
945
|
return new Response(JSON.stringify(response), {
|
|
912
946
|
status: 201,
|
|
913
947
|
headers: { "Content-Type": "application/json" }
|
|
@@ -921,8 +955,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
921
955
|
* @returns Response from the API handler or error
|
|
922
956
|
*/
|
|
923
957
|
async handleApiRequest(request, env, ctx) {
|
|
958
|
+
const url = new URL(request.url);
|
|
959
|
+
const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
|
|
924
960
|
const authHeader = request.headers.get("Authorization");
|
|
925
|
-
if (!authHeader || !authHeader.startsWith("Bearer ")) return this.createErrorResponse("invalid_token", "Missing or invalid access token", 401, { "WWW-Authenticate":
|
|
961
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) return this.createErrorResponse("invalid_token", "Missing or invalid access token", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Missing or invalid access token") });
|
|
926
962
|
const accessToken = authHeader.substring(7);
|
|
927
963
|
const parts = accessToken.split(":");
|
|
928
964
|
const isPossiblyInternalFormat = parts.length === 3;
|
|
@@ -934,14 +970,14 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
934
970
|
const id = await generateTokenId(accessToken);
|
|
935
971
|
tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
|
|
936
972
|
}
|
|
937
|
-
if (!tokenData && !this.options.resolveExternalToken) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate":
|
|
973
|
+
if (!tokenData && !this.options.resolveExternalToken) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") });
|
|
938
974
|
if (tokenData) {
|
|
939
975
|
const now = Math.floor(Date.now() / 1e3);
|
|
940
|
-
if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", "Access token expired", 401, { "WWW-Authenticate":
|
|
976
|
+
if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", "Access token expired", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") });
|
|
941
977
|
if (tokenData.audience) {
|
|
942
978
|
const requestUrl = new URL(request.url);
|
|
943
979
|
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
|
|
944
|
-
if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, { "WWW-Authenticate":
|
|
980
|
+
if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") });
|
|
945
981
|
}
|
|
946
982
|
ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
|
|
947
983
|
} else if (this.options.resolveExternalToken) {
|
|
@@ -950,16 +986,15 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
950
986
|
request,
|
|
951
987
|
env
|
|
952
988
|
});
|
|
953
|
-
if (!ext) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate":
|
|
989
|
+
if (!ext) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") });
|
|
954
990
|
if (ext.audience) {
|
|
955
991
|
const requestUrl = new URL(request.url);
|
|
956
992
|
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
|
|
957
|
-
if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, { "WWW-Authenticate":
|
|
993
|
+
if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") });
|
|
958
994
|
}
|
|
959
995
|
ctx.props = ext.props;
|
|
960
996
|
}
|
|
961
997
|
if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
|
|
962
|
-
const url = new URL(request.url);
|
|
963
998
|
const apiHandler = this.findApiHandlerForUrl(url);
|
|
964
999
|
if (!apiHandler) return this.createErrorResponse("invalid_request", "No handler found for API route", 404);
|
|
965
1000
|
if (apiHandler.type === HandlerType.EXPORTED_HANDLER) return apiHandler.handler.fetch(request, env, ctx);
|
|
@@ -1000,8 +1035,17 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1000
1035
|
*/
|
|
1001
1036
|
async getClient(env, clientId) {
|
|
1002
1037
|
if (this.isClientMetadataUrl(clientId)) {
|
|
1003
|
-
if (!this.
|
|
1004
|
-
|
|
1038
|
+
if (!this.options.clientIdMetadataDocumentEnabled) {
|
|
1039
|
+
const clientKey$1 = `client:${clientId}`;
|
|
1040
|
+
return env.OAUTH_KV.get(clientKey$1, { type: "json" });
|
|
1041
|
+
}
|
|
1042
|
+
if (!this.hasGlobalFetchStrictlyPublic()) throw new Error(`CIMD is enabled but 'global_fetch_strictly_public' compatibility flag is not set.`);
|
|
1043
|
+
try {
|
|
1044
|
+
return await this.fetchClientMetadataDocument(clientId);
|
|
1045
|
+
} catch (error) {
|
|
1046
|
+
console.warn(`CIMD fetch failed for ${clientId}:`, error instanceof Error ? error.message : error);
|
|
1047
|
+
return null;
|
|
1048
|
+
}
|
|
1005
1049
|
}
|
|
1006
1050
|
const clientKey = `client:${clientId}`;
|
|
1007
1051
|
return env.OAUTH_KV.get(clientKey, { type: "json" });
|
|
@@ -1183,6 +1227,14 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1183
1227
|
return JSON.parse(text);
|
|
1184
1228
|
}
|
|
1185
1229
|
/**
|
|
1230
|
+
* Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
|
|
1231
|
+
*/
|
|
1232
|
+
buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription) {
|
|
1233
|
+
let header = `Bearer realm="OAuth", resource_metadata="${resourceMetadataUrl}", error="${error}"`;
|
|
1234
|
+
if (errorDescription) header += `, error_description="${errorDescription}"`;
|
|
1235
|
+
return header;
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1186
1238
|
* Helper function to create OAuth error responses
|
|
1187
1239
|
* @param code - OAuth error code (e.g., 'invalid_request', 'invalid_token')
|
|
1188
1240
|
* @param description - Human-readable error description
|
|
@@ -1338,6 +1390,37 @@ function validateRedirectUriScheme(redirectUri) {
|
|
|
1338
1390
|
for (const dangerousScheme of dangerousSchemes) if (scheme === dangerousScheme) throw new Error("Invalid redirect URI");
|
|
1339
1391
|
}
|
|
1340
1392
|
/**
|
|
1393
|
+
* Checks if a URI is a loopback redirect URI (127.0.0.0/8 or ::1)
|
|
1394
|
+
* Per RFC 8252 Section 7.3, these get special port handling
|
|
1395
|
+
*/
|
|
1396
|
+
function isLoopbackUri(uri) {
|
|
1397
|
+
try {
|
|
1398
|
+
const host = new URL(uri).hostname;
|
|
1399
|
+
if (host.match(/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) return true;
|
|
1400
|
+
if (host === "::1" || host === "[::1]") return true;
|
|
1401
|
+
return false;
|
|
1402
|
+
} catch {
|
|
1403
|
+
return false;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Validates a redirect URI against registered URIs with RFC 8252 loopback support.
|
|
1408
|
+
* For loopback URIs (127.x.x.x, ::1), any port is allowed as long as scheme, host, path, and query match.
|
|
1409
|
+
* For non-loopback URIs, exact match is required.
|
|
1410
|
+
*/
|
|
1411
|
+
function isValidRedirectUri(requestUri, registeredUris) {
|
|
1412
|
+
return registeredUris.some((registered) => {
|
|
1413
|
+
if (isLoopbackUri(requestUri) && isLoopbackUri(registered)) try {
|
|
1414
|
+
const reqUrl = new URL(requestUri);
|
|
1415
|
+
const regUrl = new URL(registered);
|
|
1416
|
+
return reqUrl.protocol === regUrl.protocol && reqUrl.hostname === regUrl.hostname && reqUrl.pathname === regUrl.pathname && reqUrl.search === regUrl.search;
|
|
1417
|
+
} catch {
|
|
1418
|
+
return false;
|
|
1419
|
+
}
|
|
1420
|
+
return requestUri === registered;
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1341
1424
|
* Encodes a string as base64url (URL-safe base64)
|
|
1342
1425
|
* @param str - The string to encode
|
|
1343
1426
|
* @returns The base64url encoded string
|
|
@@ -1506,11 +1589,12 @@ var OAuthHelpersImpl = class {
|
|
|
1506
1589
|
const resource = parseResourceParameter(resourceParam);
|
|
1507
1590
|
if (resourceParam && !resource) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
|
|
1508
1591
|
if (responseType === "token" && !this.provider.options.allowImplicitFlow) throw new Error("The implicit grant flow is not enabled for this provider");
|
|
1592
|
+
if (codeChallengeMethod === "plain" && this.provider.options.allowPlainPKCE === false) throw new Error("The plain PKCE method is not allowed. Use S256 instead.");
|
|
1509
1593
|
if (clientId) {
|
|
1510
1594
|
const clientInfo = await this.lookupClient(clientId);
|
|
1511
1595
|
if (!clientInfo) throw new Error(`Invalid client. The clientId provided does not match to this client.`);
|
|
1512
1596
|
if (clientInfo && redirectUri) {
|
|
1513
|
-
if (!clientInfo.redirectUris
|
|
1597
|
+
if (!isValidRedirectUri(redirectUri, clientInfo.redirectUris)) throw new Error(`Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.`);
|
|
1514
1598
|
}
|
|
1515
1599
|
}
|
|
1516
1600
|
return {
|
|
@@ -1543,7 +1627,16 @@ var OAuthHelpersImpl = class {
|
|
|
1543
1627
|
const { clientId, redirectUri } = options.request;
|
|
1544
1628
|
if (!clientId || !redirectUri) throw new Error("Client ID and Redirect URI are required in the authorization request.");
|
|
1545
1629
|
const clientInfo = await this.lookupClient(clientId);
|
|
1546
|
-
if (!clientInfo || !clientInfo.redirectUris
|
|
1630
|
+
if (!clientInfo || !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) throw new Error("Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.");
|
|
1631
|
+
let grantsToRevoke = [];
|
|
1632
|
+
if (options.revokeExistingGrants !== false) {
|
|
1633
|
+
let cursor;
|
|
1634
|
+
do {
|
|
1635
|
+
const page = await this.listUserGrants(options.userId, { cursor });
|
|
1636
|
+
for (const grant of page.items) if (grant.clientId === clientId) grantsToRevoke.push(grant.id);
|
|
1637
|
+
cursor = page.cursor;
|
|
1638
|
+
} while (cursor);
|
|
1639
|
+
}
|
|
1547
1640
|
const grantId = generateRandomString(16);
|
|
1548
1641
|
const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
|
|
1549
1642
|
const now = Math.floor(Date.now() / 1e3);
|
|
@@ -1592,6 +1685,9 @@ var OAuthHelpersImpl = class {
|
|
|
1592
1685
|
fragment.set("scope", options.scope.join(" "));
|
|
1593
1686
|
if (options.request.state) fragment.set("state", options.request.state);
|
|
1594
1687
|
redirectUrl.hash = fragment.toString();
|
|
1688
|
+
try {
|
|
1689
|
+
await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
|
|
1690
|
+
} catch {}
|
|
1595
1691
|
return { redirectTo: redirectUrl.toString() };
|
|
1596
1692
|
} else {
|
|
1597
1693
|
const authCodeSecret = generateRandomString(32);
|
|
@@ -1617,6 +1713,9 @@ var OAuthHelpersImpl = class {
|
|
|
1617
1713
|
const redirectUrl = new URL(options.request.redirectUri);
|
|
1618
1714
|
redirectUrl.searchParams.set("code", authCode);
|
|
1619
1715
|
if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
|
|
1716
|
+
try {
|
|
1717
|
+
await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
|
|
1718
|
+
} catch {}
|
|
1620
1719
|
return { redirectTo: redirectUrl.toString() };
|
|
1621
1720
|
}
|
|
1622
1721
|
}
|