@cloudflare/workers-oauth-provider 0.3.3 → 0.5.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 +39 -3
- package/dist/oauth-provider.d.ts +96 -2
- package/dist/oauth-provider.js +180 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -96,15 +96,21 @@ export default new OAuthProvider({
|
|
|
96
96
|
disallowPublicClientRegistration: false,
|
|
97
97
|
|
|
98
98
|
// Optional: Time-to-live for refresh tokens in seconds.
|
|
99
|
-
//
|
|
99
|
+
// Defaults to 30 days (2,592,000 seconds).
|
|
100
100
|
// Set to 0 to disable refresh tokens (only access tokens will be issued).
|
|
101
|
-
//
|
|
102
|
-
refreshTokenTTL: 2592000, // 30 days
|
|
101
|
+
// Set to `undefined` explicitly for refresh tokens that never expire.
|
|
102
|
+
refreshTokenTTL: 2592000, // 30 days (the default)
|
|
103
103
|
|
|
104
104
|
// Optional: Time-to-live for access tokens in seconds.
|
|
105
105
|
// Defaults to 1 hour (3600 seconds) if not specified.
|
|
106
106
|
accessTokenTTL: 3600,
|
|
107
107
|
|
|
108
|
+
// Optional: Time-to-live for dynamically registered clients in seconds.
|
|
109
|
+
// Defaults to 90 days (7,776,000 seconds).
|
|
110
|
+
// Clients created via OAuthHelpers.createClient() are not affected.
|
|
111
|
+
// Set to `undefined` explicitly for clients that never expire.
|
|
112
|
+
clientRegistrationTTL: 7776000, // 90 days (the default)
|
|
113
|
+
|
|
108
114
|
// Optional: Controls whether OAuth 2.0 Token Exchange (RFC 8693) is allowed.
|
|
109
115
|
// When false, the token exchange grant type will not be advertised in metadata
|
|
110
116
|
// and token exchange requests will be rejected.
|
|
@@ -226,6 +232,9 @@ The `env.OAUTH_PROVIDER` object available to the fetch handlers provides some me
|
|
|
226
232
|
- Create, list, modify, and delete client_id registrations (in addition to `lookupClient()`, already shown in the example code).
|
|
227
233
|
- List all active authorization grants for a particular user.
|
|
228
234
|
- Revoke (delete) an authorization grant.
|
|
235
|
+
- Purge expired and orphaned data from the KV namespace.
|
|
236
|
+
|
|
237
|
+
Note that `deleteClient()` cascades: it revokes all grants (and their associated tokens) for the deleted client across all users.
|
|
229
238
|
|
|
230
239
|
See the `OAuthHelpers` interface definition for full API details.
|
|
231
240
|
|
|
@@ -326,6 +335,33 @@ new OAuthProvider({
|
|
|
326
335
|
|
|
327
336
|
By default, the `onError` callback is set to ``({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`)``.
|
|
328
337
|
|
|
338
|
+
## KV Namespace Cleanup
|
|
339
|
+
|
|
340
|
+
The library uses KV TTLs to automatically expire access tokens, refresh tokens (grants), and dynamically registered clients. As defense-in-depth, the library also provides a `purgeExpiredData()` method that cleans up orphaned and expired records. This is designed to be called from a [Cron Trigger](https://developers.cloudflare.com/workers/configuration/cron-triggers/) (scheduled handler):
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
const oauthProvider = new OAuthProvider({
|
|
344
|
+
// ... options ...
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
export default {
|
|
348
|
+
fetch(request, env, ctx) {
|
|
349
|
+
return oauthProvider.fetch(request, env, ctx);
|
|
350
|
+
},
|
|
351
|
+
async scheduled(event, env, ctx) {
|
|
352
|
+
const result = await oauthProvider.purgeExpiredData(env, { batchSize: 100 });
|
|
353
|
+
console.log(`Checked ${result.grantsChecked} grants, purged ${result.grantsPurged}`);
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
The method processes records in configurable batches (default: 50) to stay within Cloudflare's subrequest limits. It performs two sweep phases:
|
|
359
|
+
|
|
360
|
+
1. **Grant sweep**: Removes orphaned grants (whose client no longer exists) and expired grants.
|
|
361
|
+
2. **Token sweep**: Removes orphaned tokens (whose grant no longer exists).
|
|
362
|
+
|
|
363
|
+
Call it repeatedly via a cron trigger — deleted records disappear from KV, so subsequent invocations naturally process fresh records without needing a persisted cursor. The `result.done` field indicates whether the full key space was scanned in this invocation.
|
|
364
|
+
|
|
329
365
|
## Protected Resource Metadata (RFC 9728)
|
|
330
366
|
|
|
331
367
|
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:
|
package/dist/oauth-provider.d.ts
CHANGED
|
@@ -178,10 +178,20 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
|
|
|
178
178
|
accessTokenTTL?: number;
|
|
179
179
|
/**
|
|
180
180
|
* Time-to-live for refresh tokens in seconds.
|
|
181
|
-
*
|
|
181
|
+
* Defaults to 30 days (2,592,000 seconds).
|
|
182
|
+
* Set to 0 to disable refresh tokens entirely.
|
|
183
|
+
* Set to `undefined` explicitly for refresh tokens that never expire.
|
|
182
184
|
* For example: 3600 = 1 hour, 2592000 = 30 days
|
|
183
185
|
*/
|
|
184
186
|
refreshTokenTTL?: number;
|
|
187
|
+
/**
|
|
188
|
+
* Time-to-live for dynamically registered clients in seconds.
|
|
189
|
+
* Defaults to 90 days (7,776,000 seconds).
|
|
190
|
+
* Clients created via the DCR endpoint will automatically expire after this duration.
|
|
191
|
+
* Clients created via `OAuthHelpers.createClient()` are not affected by this setting.
|
|
192
|
+
* Set to `undefined` explicitly for clients that never expire.
|
|
193
|
+
*/
|
|
194
|
+
clientRegistrationTTL?: number;
|
|
185
195
|
/**
|
|
186
196
|
* List of scopes supported by this OAuth provider.
|
|
187
197
|
* If not provided, the 'scopes_supported' field will be omitted from the OAuth metadata.
|
|
@@ -253,6 +263,16 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
|
|
|
253
263
|
* Defaults to false.
|
|
254
264
|
*/
|
|
255
265
|
clientIdMetadataDocumentEnabled?: boolean;
|
|
266
|
+
/**
|
|
267
|
+
* When true, resource validation during token exchange compares origins only
|
|
268
|
+
* (scheme + host + port) instead of exact URI matching. This allows grants issued
|
|
269
|
+
* with an origin-only resource (e.g. `https://server.com`) to be used with
|
|
270
|
+
* path-aware resource requests (e.g. `https://server.com/mcp`), enabling seamless
|
|
271
|
+
* migration from pre-0.4.0 versions that stored origin-only resource URIs.
|
|
272
|
+
*
|
|
273
|
+
* Defaults to false (strict exact matching per RFC 8707).
|
|
274
|
+
*/
|
|
275
|
+
resourceMatchOriginOnly?: boolean;
|
|
256
276
|
/**
|
|
257
277
|
* Optional metadata for RFC 9728 OAuth 2.0 Protected Resource Metadata.
|
|
258
278
|
* Controls the response served at /.well-known/oauth-protected-resource.
|
|
@@ -365,6 +385,22 @@ interface OAuthHelpers {
|
|
|
365
385
|
* @returns Promise resolving to token response with new access token
|
|
366
386
|
*/
|
|
367
387
|
exchangeToken(options: ExchangeTokenOptions): Promise<TokenResponse>;
|
|
388
|
+
/**
|
|
389
|
+
* Purges expired and orphaned data from the KV namespace.
|
|
390
|
+
* Designed to be called from a scheduled handler (Cron Trigger) for periodic cleanup.
|
|
391
|
+
* Processes records in configurable batches to stay within Cloudflare's subrequest limits.
|
|
392
|
+
*
|
|
393
|
+
* Performs two sweep phases:
|
|
394
|
+
* 1. Grant sweep: removes orphaned grants (client deleted) and expired grants (defense-in-depth for KV TTL)
|
|
395
|
+
* 2. Token sweep: removes orphaned tokens (grant deleted) as defense-in-depth
|
|
396
|
+
*
|
|
397
|
+
* Safe to call repeatedly — deleted records disappear from KV, so subsequent invocations
|
|
398
|
+
* naturally process fresh records without needing a persisted cursor.
|
|
399
|
+
*
|
|
400
|
+
* @param options - Optional configuration for batch size and which purge types to enable
|
|
401
|
+
* @returns Statistics about what was checked and purged, and whether the full scan completed
|
|
402
|
+
*/
|
|
403
|
+
purgeExpiredData(options?: PurgeOptions): Promise<PurgeResult>;
|
|
368
404
|
}
|
|
369
405
|
/**
|
|
370
406
|
* Options for token exchange operations (RFC 8693)
|
|
@@ -732,6 +768,55 @@ interface ListResult<T> {
|
|
|
732
768
|
*/
|
|
733
769
|
cursor?: string;
|
|
734
770
|
}
|
|
771
|
+
/**
|
|
772
|
+
* Options for the purgeExpiredData garbage collection method
|
|
773
|
+
*/
|
|
774
|
+
interface PurgeOptions {
|
|
775
|
+
/**
|
|
776
|
+
* Maximum number of KV keys to check per phase (grants and tokens) per invocation.
|
|
777
|
+
* Each phase (grant sweep, token sweep) gets its own budget of this size.
|
|
778
|
+
* Keep this conservative to stay within Cloudflare's 1000 subrequest limit per invocation,
|
|
779
|
+
* since each checked key requires at least one KV read, and orphaned grants trigger
|
|
780
|
+
* additional KV operations via revokeGrant().
|
|
781
|
+
* Defaults to 50.
|
|
782
|
+
*/
|
|
783
|
+
batchSize?: number;
|
|
784
|
+
/**
|
|
785
|
+
* Whether to purge orphaned grants whose client no longer exists in KV.
|
|
786
|
+
* Grants for CIMD (Client ID Metadata Document) clients are always skipped
|
|
787
|
+
* since those clients are not stored in KV.
|
|
788
|
+
* Defaults to true.
|
|
789
|
+
*/
|
|
790
|
+
purgeOrphanedGrants?: boolean;
|
|
791
|
+
/**
|
|
792
|
+
* Whether to purge expired grants as defense-in-depth for KV TTL.
|
|
793
|
+
* Normally KV auto-deletes expired entries, but this catches any stragglers.
|
|
794
|
+
* Defaults to true.
|
|
795
|
+
*/
|
|
796
|
+
purgeExpiredGrants?: boolean;
|
|
797
|
+
/**
|
|
798
|
+
* Whether to purge orphaned tokens whose grant no longer exists.
|
|
799
|
+
* Tokens already auto-expire via KV TTL (default 1 hour), so this is
|
|
800
|
+
* defense-in-depth for partial revokeGrant() failures.
|
|
801
|
+
* Defaults to true.
|
|
802
|
+
*/
|
|
803
|
+
purgeOrphanedTokens?: boolean;
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Result of a purgeExpiredData garbage collection invocation
|
|
807
|
+
*/
|
|
808
|
+
interface PurgeResult {
|
|
809
|
+
/** Number of grant records checked in this invocation */
|
|
810
|
+
grantsChecked: number;
|
|
811
|
+
/** Number of grant records purged (orphaned or expired) */
|
|
812
|
+
grantsPurged: number;
|
|
813
|
+
/** Number of token records checked in this invocation */
|
|
814
|
+
tokensChecked: number;
|
|
815
|
+
/** Number of token records purged (orphaned) */
|
|
816
|
+
tokensPurged: number;
|
|
817
|
+
/** True if the full key space was scanned in this invocation (both grants and tokens) */
|
|
818
|
+
done: boolean;
|
|
819
|
+
}
|
|
735
820
|
/**
|
|
736
821
|
* Public representation of a grant, with sensitive data removed
|
|
737
822
|
* Used for list operations where the complete grant data isn't needed
|
|
@@ -787,6 +872,15 @@ declare class OAuthProvider<Env = Cloudflare.Env> {
|
|
|
787
872
|
* @returns A Promise resolving to an HTTP Response
|
|
788
873
|
*/
|
|
789
874
|
fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response>;
|
|
875
|
+
/**
|
|
876
|
+
* Purges expired and orphaned data from the KV namespace.
|
|
877
|
+
* Can be called directly from a scheduled handler without needing a request context.
|
|
878
|
+
*
|
|
879
|
+
* @param env - Cloudflare Worker environment variables (must include OAUTH_KV binding)
|
|
880
|
+
* @param options - Optional configuration for batch size and which purge types to enable
|
|
881
|
+
* @returns Statistics about what was checked and purged
|
|
882
|
+
*/
|
|
883
|
+
purgeExpiredData(env: Env, options?: PurgeOptions): Promise<PurgeResult>;
|
|
790
884
|
}
|
|
791
885
|
/**
|
|
792
886
|
* Gets OAuthHelpers for the given environment
|
|
@@ -796,4 +890,4 @@ declare class OAuthProvider<Env = Cloudflare.Env> {
|
|
|
796
890
|
*/
|
|
797
891
|
declare function getOAuthApi<Env = Cloudflare.Env>(options: OAuthProviderOptions<Env>, env: Env): OAuthHelpers;
|
|
798
892
|
//#endregion
|
|
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 };
|
|
893
|
+
export { AuthRequest, ClientInfo, CompleteAuthorizationOptions, ExchangeTokenOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, PurgeOptions, PurgeResult, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, getOAuthApi };
|
package/dist/oauth-provider.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
2
2
|
|
|
3
3
|
//#region src/oauth-provider.ts
|
|
4
|
+
const PROTECTED_RESOURCE_WELL_KNOWN_PREFIX = "/.well-known/oauth-protected-resource";
|
|
4
5
|
if (!(typeof Cloudflare !== "undefined" && Cloudflare.compatibilityFlags?.global_fetch_strictly_public === true)) console.warn("CIMD (Client ID Metadata Document) is disabled: add '\"compatibility_flags\": [\"global_fetch_strictly_public\"]' to your wrangler.jsonc to enable. See: https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public");
|
|
5
6
|
/**
|
|
6
7
|
* Enum representing the type of handler (ExportedHandler or WorkerEntrypoint)
|
|
@@ -44,6 +45,17 @@ var OAuthProvider = class {
|
|
|
44
45
|
fetch(request, env, ctx) {
|
|
45
46
|
return this.#impl.fetch(request, env, ctx);
|
|
46
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Purges expired and orphaned data from the KV namespace.
|
|
50
|
+
* Can be called directly from a scheduled handler without needing a request context.
|
|
51
|
+
*
|
|
52
|
+
* @param env - Cloudflare Worker environment variables (must include OAUTH_KV binding)
|
|
53
|
+
* @param options - Optional configuration for batch size and which purge types to enable
|
|
54
|
+
* @returns Statistics about what was checked and purged
|
|
55
|
+
*/
|
|
56
|
+
purgeExpiredData(env, options) {
|
|
57
|
+
return this.#impl.createOAuthHelpers(env).purgeExpiredData(options);
|
|
58
|
+
}
|
|
47
59
|
};
|
|
48
60
|
/**
|
|
49
61
|
* Gets OAuthHelpers for the given environment
|
|
@@ -93,6 +105,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
93
105
|
if (options.clientRegistrationEndpoint) this.validateEndpoint(options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
|
|
94
106
|
this.options = {
|
|
95
107
|
accessTokenTTL: DEFAULT_ACCESS_TOKEN_TTL,
|
|
108
|
+
refreshTokenTTL: DEFAULT_REFRESH_TOKEN_TTL,
|
|
109
|
+
clientRegistrationTTL: DEFAULT_CLIENT_REGISTRATION_TTL,
|
|
96
110
|
onError: ({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`),
|
|
97
111
|
...options
|
|
98
112
|
};
|
|
@@ -141,7 +155,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
141
155
|
async fetch(request, env, ctx) {
|
|
142
156
|
const url = new URL(request.url);
|
|
143
157
|
if (request.method === "OPTIONS") {
|
|
144
|
-
if (this.isApiRequest(url) || url.pathname === "/.well-known/oauth-authorization-server" || url
|
|
158
|
+
if (this.isApiRequest(url) || url.pathname === "/.well-known/oauth-authorization-server" || this.isProtectedResourceMetadataRequest(url) || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return this.addCorsHeaders(new Response(null, {
|
|
145
159
|
status: 204,
|
|
146
160
|
headers: { "Content-Length": "0" }
|
|
147
161
|
}), request);
|
|
@@ -150,7 +164,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
150
164
|
const response = await this.handleMetadataDiscovery(url);
|
|
151
165
|
return this.addCorsHeaders(response, request);
|
|
152
166
|
}
|
|
153
|
-
if (url
|
|
167
|
+
if (this.isProtectedResourceMetadataRequest(url)) {
|
|
154
168
|
const response = this.handleProtectedResourceMetadata(url);
|
|
155
169
|
return this.addCorsHeaders(response, request);
|
|
156
170
|
}
|
|
@@ -245,6 +259,27 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
245
259
|
return this.matchEndpoint(url, this.options.clientRegistrationEndpoint);
|
|
246
260
|
}
|
|
247
261
|
/**
|
|
262
|
+
* Checks if a URL is a request for OAuth Protected Resource Metadata (RFC 9728).
|
|
263
|
+
* Matches both the root well-known path and path-suffixed variants per RFC 9728 §3.1.
|
|
264
|
+
*/
|
|
265
|
+
isProtectedResourceMetadataRequest(url) {
|
|
266
|
+
return url.pathname === PROTECTED_RESOURCE_WELL_KNOWN_PREFIX || url.pathname.startsWith(PROTECTED_RESOURCE_WELL_KNOWN_PREFIX + "/");
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Derives the resource identifier from a protected resource metadata well-known URL.
|
|
270
|
+
* Per RFC 9728 §3.1, the well-known URI is inserted after the authority and before the path,
|
|
271
|
+
* so the resource identifier is reconstructed by removing the well-known prefix.
|
|
272
|
+
*
|
|
273
|
+
* Examples:
|
|
274
|
+
* /.well-known/oauth-protected-resource → origin (e.g. https://example.com)
|
|
275
|
+
* /.well-known/oauth-protected-resource/mcp → origin + /mcp (e.g. https://example.com/mcp)
|
|
276
|
+
*/
|
|
277
|
+
deriveResourceIdentifier(requestUrl) {
|
|
278
|
+
const suffix = requestUrl.pathname.slice(37);
|
|
279
|
+
if (!suffix || suffix === "/") return requestUrl.origin;
|
|
280
|
+
return `${requestUrl.origin}${suffix}`;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
248
283
|
* Parses and validates a token endpoint request (used for both token exchange and revocation)
|
|
249
284
|
* @param request - The HTTP request to parse
|
|
250
285
|
* @returns Promise with parsed body and client info, or error response
|
|
@@ -389,7 +424,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
389
424
|
const tokenEndpointUrl = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
|
|
390
425
|
const authServerOrigin = new URL(tokenEndpointUrl).origin;
|
|
391
426
|
const metadata = {
|
|
392
|
-
resource: rm?.resource ?? requestUrl
|
|
427
|
+
resource: rm?.resource ?? this.deriveResourceIdentifier(requestUrl),
|
|
393
428
|
authorization_servers: rm?.authorization_servers ?? [authServerOrigin],
|
|
394
429
|
scopes_supported: rm?.scopes_supported ?? this.options.scopesSupported,
|
|
395
430
|
bearer_methods_supported: rm?.bearer_methods_supported ?? ["header"]
|
|
@@ -515,10 +550,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
515
550
|
grantData.expiresAt = expiresAt;
|
|
516
551
|
}
|
|
517
552
|
await this.saveGrantWithTTL(env, grantKey, grantData, now);
|
|
553
|
+
const originOnly = !!this.options.resourceMatchOriginOnly;
|
|
518
554
|
if (body.resource && grantData.resource) {
|
|
519
555
|
const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
|
|
520
556
|
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
521
|
-
for (const requested of requestedResources) if (!grantedResources.
|
|
557
|
+
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", "Requested resource was not included in the authorization request");
|
|
522
558
|
}
|
|
523
559
|
const audience = parseResourceParameter(body.resource || grantData.resource);
|
|
524
560
|
if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
|
|
@@ -635,10 +671,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
635
671
|
grantData.refreshTokenId = newRefreshTokenId;
|
|
636
672
|
grantData.refreshTokenWrappedKey = newRefreshTokenWrappedKey;
|
|
637
673
|
await this.saveGrantWithTTL(env, grantKey, grantData, now);
|
|
674
|
+
const originOnly = !!this.options.resourceMatchOriginOnly;
|
|
638
675
|
if (body.resource && grantData.resource) {
|
|
639
676
|
const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
|
|
640
677
|
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
641
|
-
for (const requested of requestedResources) if (!grantedResources.
|
|
678
|
+
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", "Requested resource was not included in the authorization request");
|
|
642
679
|
}
|
|
643
680
|
const audience = parseResourceParameter(body.resource || grantData.resource);
|
|
644
681
|
if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
|
|
@@ -690,12 +727,13 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
690
727
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
691
728
|
if (!grantData) throw new OAuthError("invalid_grant", "Grant not found");
|
|
692
729
|
let tokenScopes = this.downscope(requestedScopes, grantData.scope);
|
|
730
|
+
const originOnly = !!this.options.resourceMatchOriginOnly;
|
|
693
731
|
let newAudience = tokenSummary.audience;
|
|
694
732
|
if (requestedResource) {
|
|
695
733
|
if (grantData.resource) {
|
|
696
734
|
const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
|
|
697
735
|
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
698
|
-
for (const requested of requestedResources) if (!grantedResources.
|
|
736
|
+
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) throw new OAuthError("invalid_target", "Requested resource was not included in the authorization request");
|
|
699
737
|
}
|
|
700
738
|
const parsedResource = parseResourceParameter(requestedResource);
|
|
701
739
|
if (!parsedResource) throw new OAuthError("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
|
|
@@ -920,7 +958,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
920
958
|
} catch (error) {
|
|
921
959
|
return this.createErrorResponse("invalid_client_metadata", error instanceof Error ? error.message : "Invalid client metadata");
|
|
922
960
|
}
|
|
923
|
-
|
|
961
|
+
const clientKvOptions = {};
|
|
962
|
+
if (this.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.options.clientRegistrationTTL;
|
|
963
|
+
await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(clientInfo), clientKvOptions);
|
|
924
964
|
const response = {
|
|
925
965
|
client_id: clientInfo.clientId,
|
|
926
966
|
redirect_uris: clientInfo.redirectUris,
|
|
@@ -939,7 +979,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
939
979
|
};
|
|
940
980
|
if (clientSecret) {
|
|
941
981
|
response.client_secret = clientSecret;
|
|
942
|
-
response.client_secret_expires_at = 0;
|
|
982
|
+
response.client_secret_expires_at = this.options.clientRegistrationTTL && clientInfo.registrationDate ? clientInfo.registrationDate + this.options.clientRegistrationTTL : 0;
|
|
943
983
|
response.client_secret_issued_at = clientInfo.registrationDate;
|
|
944
984
|
}
|
|
945
985
|
return new Response(JSON.stringify(response), {
|
|
@@ -956,7 +996,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
956
996
|
*/
|
|
957
997
|
async handleApiRequest(request, env, ctx) {
|
|
958
998
|
const url = new URL(request.url);
|
|
959
|
-
const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
|
|
999
|
+
const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource${url.pathname}`;
|
|
960
1000
|
const authHeader = request.headers.get("Authorization");
|
|
961
1001
|
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") });
|
|
962
1002
|
const accessToken = authHeader.substring(7);
|
|
@@ -1098,7 +1138,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1098
1138
|
return !!(typeof Cloudflare !== "undefined" && Cloudflare.compatibilityFlags ? Cloudflare.compatibilityFlags : null)?.global_fetch_strictly_public;
|
|
1099
1139
|
}
|
|
1100
1140
|
/**
|
|
1101
|
-
* Checks if a client_id is a CIMD URL (HTTPS with non-root path)
|
|
1141
|
+
* Checks if a client_id is a CIMD URL (HTTPS with non-root path).
|
|
1142
|
+
* Not private because OAuthHelpersImpl needs access for purgeExpiredData.
|
|
1102
1143
|
*/
|
|
1103
1144
|
isClientMetadataUrl(clientId) {
|
|
1104
1145
|
try {
|
|
@@ -1279,6 +1320,19 @@ var OAuthError = class extends Error {
|
|
|
1279
1320
|
*/
|
|
1280
1321
|
const DEFAULT_ACCESS_TOKEN_TTL = 3600;
|
|
1281
1322
|
/**
|
|
1323
|
+
* Default expiration time for refresh tokens (30 days in seconds)
|
|
1324
|
+
*/
|
|
1325
|
+
const DEFAULT_REFRESH_TOKEN_TTL = 720 * 60 * 60;
|
|
1326
|
+
/**
|
|
1327
|
+
* Default expiration time for dynamically registered clients (90 days in seconds)
|
|
1328
|
+
*/
|
|
1329
|
+
const DEFAULT_CLIENT_REGISTRATION_TTL = 2160 * 60 * 60;
|
|
1330
|
+
/**
|
|
1331
|
+
* Default batch size for purgeExpiredData. Conservative to stay within
|
|
1332
|
+
* Cloudflare's 1000 subrequest limit per invocation.
|
|
1333
|
+
*/
|
|
1334
|
+
const DEFAULT_PURGE_BATCH_SIZE = 50;
|
|
1335
|
+
/**
|
|
1282
1336
|
* Length of generated token strings
|
|
1283
1337
|
*/
|
|
1284
1338
|
const TOKEN_LENGTH = 32;
|
|
@@ -1331,6 +1385,19 @@ function parseResourceParameter(value) {
|
|
|
1331
1385
|
return value;
|
|
1332
1386
|
}
|
|
1333
1387
|
/**
|
|
1388
|
+
* Checks if a requested resource matches a granted resource.
|
|
1389
|
+
* When originOnly is true, compares only the origin (scheme + host + port),
|
|
1390
|
+
* allowing path-aware resources to match origin-only grants.
|
|
1391
|
+
*/
|
|
1392
|
+
function resourceMatches(requested, granted, originOnly) {
|
|
1393
|
+
if (!originOnly) return requested === granted;
|
|
1394
|
+
try {
|
|
1395
|
+
return new URL(requested).origin === new URL(granted).origin;
|
|
1396
|
+
} catch {
|
|
1397
|
+
return requested === granted;
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1334
1401
|
* Hashes a secret value using SHA-256
|
|
1335
1402
|
* @param secret - The secret value to hash
|
|
1336
1403
|
* @returns A hex string representation of the hash
|
|
@@ -1808,17 +1875,32 @@ var OAuthHelpersImpl = class {
|
|
|
1808
1875
|
};
|
|
1809
1876
|
if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
|
|
1810
1877
|
else delete updatedClient.clientSecret;
|
|
1811
|
-
|
|
1878
|
+
const clientKvOptions = {};
|
|
1879
|
+
if (this.provider.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.provider.options.clientRegistrationTTL;
|
|
1880
|
+
await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(updatedClient), clientKvOptions);
|
|
1812
1881
|
const response = { ...updatedClient };
|
|
1813
1882
|
if (!isPublicClient && originalSecret) response.clientSecret = originalSecret;
|
|
1814
1883
|
return response;
|
|
1815
1884
|
}
|
|
1816
1885
|
/**
|
|
1817
|
-
* Deletes an OAuth client
|
|
1886
|
+
* Deletes an OAuth client and revokes all associated grants across all users.
|
|
1818
1887
|
* @param clientId - The ID of the client to delete
|
|
1819
1888
|
* @returns A Promise resolving when the deletion is confirmed.
|
|
1820
1889
|
*/
|
|
1821
1890
|
async deleteClient(clientId) {
|
|
1891
|
+
let cursor;
|
|
1892
|
+
let allProcessed = false;
|
|
1893
|
+
while (!allProcessed) {
|
|
1894
|
+
const listOptions = { prefix: "grant:" };
|
|
1895
|
+
if (cursor) listOptions.cursor = cursor;
|
|
1896
|
+
const result = await this.env.OAUTH_KV.list(listOptions);
|
|
1897
|
+
for (const key of result.keys) {
|
|
1898
|
+
const grantData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
|
|
1899
|
+
if (grantData && grantData.clientId === clientId) await this.revokeGrant(grantData.id, grantData.userId);
|
|
1900
|
+
}
|
|
1901
|
+
if (result.list_complete) allProcessed = true;
|
|
1902
|
+
else cursor = result.cursor;
|
|
1903
|
+
}
|
|
1822
1904
|
await this.env.OAUTH_KV.delete(`client:${clientId}`);
|
|
1823
1905
|
}
|
|
1824
1906
|
/**
|
|
@@ -1899,6 +1981,92 @@ var OAuthHelpersImpl = class {
|
|
|
1899
1981
|
if (!clientInfo) throw new Error("Client not found");
|
|
1900
1982
|
return await this.provider.exchangeToken(options.subjectToken, options.scope, options.aud, options.expiresIn, clientInfo, this.env);
|
|
1901
1983
|
}
|
|
1984
|
+
async purgeExpiredData(options) {
|
|
1985
|
+
const batchSize = options?.batchSize ?? DEFAULT_PURGE_BATCH_SIZE;
|
|
1986
|
+
const purgeOrphanedGrants = options?.purgeOrphanedGrants !== false;
|
|
1987
|
+
const purgeExpiredGrants = options?.purgeExpiredGrants !== false;
|
|
1988
|
+
const purgeOrphanedTokens = options?.purgeOrphanedTokens !== false;
|
|
1989
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1990
|
+
const result = {
|
|
1991
|
+
grantsChecked: 0,
|
|
1992
|
+
grantsPurged: 0,
|
|
1993
|
+
tokensChecked: 0,
|
|
1994
|
+
tokensPurged: 0,
|
|
1995
|
+
done: false
|
|
1996
|
+
};
|
|
1997
|
+
if (purgeOrphanedGrants || purgeExpiredGrants) {
|
|
1998
|
+
const knownGoodClients = /* @__PURE__ */ new Set();
|
|
1999
|
+
const knownMissingClients = /* @__PURE__ */ new Set();
|
|
2000
|
+
let grantCursor;
|
|
2001
|
+
let grantsDone = false;
|
|
2002
|
+
while (!grantsDone && result.grantsChecked < batchSize) {
|
|
2003
|
+
const listOptions = {
|
|
2004
|
+
prefix: "grant:",
|
|
2005
|
+
limit: Math.min(1e3, batchSize - result.grantsChecked)
|
|
2006
|
+
};
|
|
2007
|
+
if (grantCursor) listOptions.cursor = grantCursor;
|
|
2008
|
+
const page = await this.env.OAUTH_KV.list(listOptions);
|
|
2009
|
+
for (const key of page.keys) {
|
|
2010
|
+
if (result.grantsChecked >= batchSize) break;
|
|
2011
|
+
result.grantsChecked++;
|
|
2012
|
+
const grantData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
|
|
2013
|
+
if (!grantData) continue;
|
|
2014
|
+
let shouldPurge = false;
|
|
2015
|
+
if (purgeExpiredGrants && grantData.expiresAt !== void 0 && now >= grantData.expiresAt) shouldPurge = true;
|
|
2016
|
+
if (!shouldPurge && purgeOrphanedGrants && !this.provider.isClientMetadataUrl(grantData.clientId)) {
|
|
2017
|
+
if (knownMissingClients.has(grantData.clientId)) shouldPurge = true;
|
|
2018
|
+
else if (!knownGoodClients.has(grantData.clientId)) if (await this.env.OAUTH_KV.get(`client:${grantData.clientId}`, { type: "json" })) knownGoodClients.add(grantData.clientId);
|
|
2019
|
+
else {
|
|
2020
|
+
knownMissingClients.add(grantData.clientId);
|
|
2021
|
+
shouldPurge = true;
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
if (shouldPurge) {
|
|
2025
|
+
await this.revokeGrant(grantData.id, grantData.userId);
|
|
2026
|
+
result.grantsPurged++;
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
if (page.list_complete) grantsDone = true;
|
|
2030
|
+
else grantCursor = page.cursor;
|
|
2031
|
+
}
|
|
2032
|
+
if (!grantsDone) return result;
|
|
2033
|
+
}
|
|
2034
|
+
if (purgeOrphanedTokens) {
|
|
2035
|
+
const knownGoodGrants = /* @__PURE__ */ new Set();
|
|
2036
|
+
const knownMissingGrants = /* @__PURE__ */ new Set();
|
|
2037
|
+
let tokenCursor;
|
|
2038
|
+
let tokensDone = false;
|
|
2039
|
+
while (!tokensDone && result.tokensChecked < batchSize) {
|
|
2040
|
+
const listOptions = {
|
|
2041
|
+
prefix: "token:",
|
|
2042
|
+
limit: Math.min(1e3, batchSize - result.tokensChecked)
|
|
2043
|
+
};
|
|
2044
|
+
if (tokenCursor) listOptions.cursor = tokenCursor;
|
|
2045
|
+
const page = await this.env.OAUTH_KV.list(listOptions);
|
|
2046
|
+
for (const key of page.keys) {
|
|
2047
|
+
if (result.tokensChecked >= batchSize) break;
|
|
2048
|
+
result.tokensChecked++;
|
|
2049
|
+
const tokenData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
|
|
2050
|
+
if (!tokenData) continue;
|
|
2051
|
+
const grantKey = `grant:${tokenData.userId}:${tokenData.grantId}`;
|
|
2052
|
+
if (knownMissingGrants.has(grantKey)) {
|
|
2053
|
+
await this.env.OAUTH_KV.delete(key.name);
|
|
2054
|
+
result.tokensPurged++;
|
|
2055
|
+
} else if (!knownGoodGrants.has(grantKey)) if (await this.env.OAUTH_KV.get(grantKey)) knownGoodGrants.add(grantKey);
|
|
2056
|
+
else {
|
|
2057
|
+
knownMissingGrants.add(grantKey);
|
|
2058
|
+
await this.env.OAUTH_KV.delete(key.name);
|
|
2059
|
+
result.tokensPurged++;
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
if (page.list_complete) tokensDone = true;
|
|
2063
|
+
else tokenCursor = page.cursor;
|
|
2064
|
+
}
|
|
2065
|
+
if (!tokensDone) return result;
|
|
2066
|
+
}
|
|
2067
|
+
result.done = true;
|
|
2068
|
+
return result;
|
|
2069
|
+
}
|
|
1902
2070
|
};
|
|
1903
2071
|
/**
|
|
1904
2072
|
* Default export of the OAuth provider
|