@cloudflare/workers-oauth-provider 0.2.4 → 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 +50 -4
- package/dist/oauth-provider.d.ts +14 -0
- package/dist/oauth-provider.js +38 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -100,6 +100,22 @@ export default new OAuthProvider({
|
|
|
100
100
|
// Set to 0 to disable refresh tokens (only access tokens will be issued).
|
|
101
101
|
// For example: 3600 = 1 hour, 86400 = 1 day, 2592000 = 30 days
|
|
102
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,
|
|
103
119
|
});
|
|
104
120
|
|
|
105
121
|
// The default handler object - the OAuthProvider will pass through HTTP requests to this object's fetch method
|
|
@@ -310,6 +326,23 @@ new OAuthProvider({
|
|
|
310
326
|
|
|
311
327
|
By default, the `onError` callback is set to ``({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`)``.
|
|
312
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
|
+
|
|
313
346
|
## Standards Compliance
|
|
314
347
|
|
|
315
348
|
This library implements the following OAuth and MCP specifications:
|
|
@@ -343,11 +376,22 @@ This library implements a compromise: At any particular time, a grant may have t
|
|
|
343
376
|
|
|
344
377
|
## Client ID Metadata Document (CIMD) Support
|
|
345
378
|
|
|
346
|
-
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.
|
|
347
380
|
|
|
348
381
|
### Enabling CIMD
|
|
349
382
|
|
|
350
|
-
|
|
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`:
|
|
351
395
|
|
|
352
396
|
```jsonc
|
|
353
397
|
{
|
|
@@ -355,9 +399,11 @@ To enable CIMD support, you must add the `global_fetch_strictly_public` compatib
|
|
|
355
399
|
}
|
|
356
400
|
```
|
|
357
401
|
|
|
358
|
-
|
|
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.
|
|
359
405
|
|
|
360
|
-
|
|
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.
|
|
361
407
|
|
|
362
408
|
## Written using Claude
|
|
363
409
|
|
package/dist/oauth-provider.d.ts
CHANGED
|
@@ -246,6 +246,13 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
|
|
|
246
246
|
status: number;
|
|
247
247
|
headers: Record<string, string>;
|
|
248
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;
|
|
249
256
|
/**
|
|
250
257
|
* Optional metadata for RFC 9728 OAuth 2.0 Protected Resource Metadata.
|
|
251
258
|
* Controls the response served at /.well-known/oauth-protected-resource.
|
|
@@ -510,6 +517,13 @@ interface CompleteAuthorizationOptions {
|
|
|
510
517
|
* authorized by this grant
|
|
511
518
|
*/
|
|
512
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;
|
|
513
527
|
}
|
|
514
528
|
/**
|
|
515
529
|
* Authorization grant record
|
package/dist/oauth-provider.js
CHANGED
|
@@ -374,7 +374,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
374
374
|
],
|
|
375
375
|
revocation_endpoint: tokenEndpoint,
|
|
376
376
|
code_challenge_methods_supported: this.options.allowPlainPKCE !== false ? ["plain", "S256"] : ["S256"],
|
|
377
|
-
client_id_metadata_document_supported: this.hasGlobalFetchStrictlyPublic()
|
|
377
|
+
client_id_metadata_document_supported: !!this.options.clientIdMetadataDocumentEnabled && this.hasGlobalFetchStrictlyPublic()
|
|
378
378
|
};
|
|
379
379
|
return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
|
|
380
380
|
}
|
|
@@ -431,7 +431,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
431
431
|
const grantKey = `grant:${userId}:${grantId}`;
|
|
432
432
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
433
433
|
if (!grantData) return this.createErrorResponse("invalid_grant", "Grant not found or authorization code expired");
|
|
434
|
-
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
|
+
}
|
|
435
440
|
if (await hashSecret(code) !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", "Invalid authorization code");
|
|
436
441
|
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", "Client ID mismatch");
|
|
437
442
|
const isPkceEnabled = !!grantData.codeChallenge;
|
|
@@ -932,7 +937,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
932
937
|
registration_client_uri: `${this.options.clientRegistrationEndpoint}/${clientId}`,
|
|
933
938
|
client_id_issued_at: clientInfo.registrationDate
|
|
934
939
|
};
|
|
935
|
-
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
|
+
}
|
|
936
945
|
return new Response(JSON.stringify(response), {
|
|
937
946
|
status: 201,
|
|
938
947
|
headers: { "Content-Type": "application/json" }
|
|
@@ -1026,8 +1035,17 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1026
1035
|
*/
|
|
1027
1036
|
async getClient(env, clientId) {
|
|
1028
1037
|
if (this.isClientMetadataUrl(clientId)) {
|
|
1029
|
-
if (!this.
|
|
1030
|
-
|
|
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
|
+
}
|
|
1031
1049
|
}
|
|
1032
1050
|
const clientKey = `client:${clientId}`;
|
|
1033
1051
|
return env.OAUTH_KV.get(clientKey, { type: "json" });
|
|
@@ -1610,6 +1628,15 @@ var OAuthHelpersImpl = class {
|
|
|
1610
1628
|
if (!clientId || !redirectUri) throw new Error("Client ID and Redirect URI are required in the authorization request.");
|
|
1611
1629
|
const clientInfo = await this.lookupClient(clientId);
|
|
1612
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
|
+
}
|
|
1613
1640
|
const grantId = generateRandomString(16);
|
|
1614
1641
|
const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
|
|
1615
1642
|
const now = Math.floor(Date.now() / 1e3);
|
|
@@ -1658,6 +1685,9 @@ var OAuthHelpersImpl = class {
|
|
|
1658
1685
|
fragment.set("scope", options.scope.join(" "));
|
|
1659
1686
|
if (options.request.state) fragment.set("state", options.request.state);
|
|
1660
1687
|
redirectUrl.hash = fragment.toString();
|
|
1688
|
+
try {
|
|
1689
|
+
await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
|
|
1690
|
+
} catch {}
|
|
1661
1691
|
return { redirectTo: redirectUrl.toString() };
|
|
1662
1692
|
} else {
|
|
1663
1693
|
const authCodeSecret = generateRandomString(32);
|
|
@@ -1683,6 +1713,9 @@ var OAuthHelpersImpl = class {
|
|
|
1683
1713
|
const redirectUrl = new URL(options.request.redirectUri);
|
|
1684
1714
|
redirectUrl.searchParams.set("code", authCode);
|
|
1685
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 {}
|
|
1686
1719
|
return { redirectTo: redirectUrl.toString() };
|
|
1687
1720
|
}
|
|
1688
1721
|
}
|