@cloudflare/workers-oauth-provider 0.7.1 → 0.8.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 +22 -0
- package/dist/oauth-provider.d.ts +76 -3
- package/dist/oauth-provider.js +133 -61
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -229,6 +229,15 @@ class ApiHandler extends WorkerEntrypoint {
|
|
|
229
229
|
}
|
|
230
230
|
```
|
|
231
231
|
|
|
232
|
+
By default, `completeAuthorization()` revokes existing grants for the same user and client after storing the new
|
|
233
|
+
grant. This prevents stale tokens from continuing to use old `props` after a user re-authorizes. Set
|
|
234
|
+
`revokeExistingGrants: false` only if your application intentionally allows multiple concurrent grants for the same
|
|
235
|
+
user and client.
|
|
236
|
+
|
|
237
|
+
For users with many grants, `revokeExistingGrantsBatchSize` controls the KV page size used while scanning existing
|
|
238
|
+
grants for revocation. It defaults to `50`, must be a positive integer, and is capped at Cloudflare KV's maximum page
|
|
239
|
+
size of `1000`.
|
|
240
|
+
|
|
232
241
|
This implementation requires that your worker is configured with a Workers KV namespace binding called `OAUTH_KV`, which is used to store token information. See the file `storage-schema.md` for details on the schema of this namespace.
|
|
233
242
|
|
|
234
243
|
The `env.OAUTH_PROVIDER` object available to the fetch handlers provides some methods to query the storage, including:
|
|
@@ -390,6 +399,19 @@ Setup:
|
|
|
390
399
|
|
|
391
400
|
The AS enforces `resolved.issuer === iss` (confused-deputy guard) and validates ID-JAG `typ`, signature, audience, client binding, resource, `exp` / `iat` / `nbf`, max lifetime, and `jti` replay. Refresh tokens are not issued for this grant — the ID-JAG itself is the renewable assertion.
|
|
392
401
|
|
|
402
|
+
### Public clients
|
|
403
|
+
|
|
404
|
+
By default the EMA grant requires client authentication, so public clients (`token_endpoint_auth_method: 'none'`) are rejected. Set `allowPublicClients: true` to also accept them:
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
enterpriseManagedAuthorization: {
|
|
408
|
+
allowPublicClients: true,
|
|
409
|
+
// ... trustedIssuers, mapClaims ...
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
This is useful for clients registered via a [Client ID Metadata Document (CIMD)](https://modelcontextprotocol.io/), which are always public and therefore cannot present a client secret. With this enabled, trust rests on the IdP-issued, signature-verified, short-lived, single-use ID-JAG assertion (audience-, resource-, and client-bound) rather than on a separately presented client secret. Leave it unset (default `false`) to keep the spec-default behavior of requiring client authentication.
|
|
414
|
+
|
|
393
415
|
Experimental — the MCP extension is still a draft.
|
|
394
416
|
|
|
395
417
|
## Custom Error Responses
|
package/dist/oauth-provider.d.ts
CHANGED
|
@@ -269,6 +269,22 @@ interface EmaOptions<Env = Cloudflare.Env> {
|
|
|
269
269
|
clockSkewSeconds?: number;
|
|
270
270
|
/** Maximum accepted assertion lifetime in seconds. Defaults to 300 seconds. */
|
|
271
271
|
maxAssertionLifetimeSeconds?: number;
|
|
272
|
+
/**
|
|
273
|
+
* Allow public clients (`token_endpoint_auth_method: 'none'`) to use the
|
|
274
|
+
* enterprise-managed authorization (ID-JAG) grant.
|
|
275
|
+
*
|
|
276
|
+
* Defaults to `false`. By default the EMA grant requires client
|
|
277
|
+
* authentication, matching the MCP enterprise-managed-authorization draft.
|
|
278
|
+
*
|
|
279
|
+
* Set to `true` to also accept public clients on this grant — for example
|
|
280
|
+
* clients registered via a Client ID Metadata Document (CIMD), which are
|
|
281
|
+
* always public (`none`) and therefore cannot present a client secret. The
|
|
282
|
+
* security trade-off is documented in the README: the trust then rests on
|
|
283
|
+
* the IdP-issued, signature-verified, short-lived, single-use ID-JAG
|
|
284
|
+
* assertion (audience-, resource-, and client-bound) rather than on a
|
|
285
|
+
* separately presented client secret.
|
|
286
|
+
*/
|
|
287
|
+
allowPublicClients?: boolean;
|
|
272
288
|
}
|
|
273
289
|
//#endregion
|
|
274
290
|
//#region src/oauth-provider.d.ts
|
|
@@ -377,6 +393,48 @@ interface TokenExchangeCallbackOptions {
|
|
|
377
393
|
*/
|
|
378
394
|
props: any;
|
|
379
395
|
}
|
|
396
|
+
/**
|
|
397
|
+
* Options for the client registration callback (RFC 7591).
|
|
398
|
+
*/
|
|
399
|
+
interface ClientRegistrationCallbackOptions {
|
|
400
|
+
/**
|
|
401
|
+
* Parsed client metadata from the registration request body.
|
|
402
|
+
*
|
|
403
|
+
* Note: This is the raw JSON body. RFC 7591 §3.1.1 `software_statement` claims
|
|
404
|
+
* are NOT currently merged in by the library — if `software_statement` is present
|
|
405
|
+
* the callback is responsible for verifying the JWT and applying its claims.
|
|
406
|
+
*/
|
|
407
|
+
clientMetadata: Record<string, unknown>;
|
|
408
|
+
/**
|
|
409
|
+
* A clone of the registration HTTP request. The body has not been consumed,
|
|
410
|
+
* so the callback may call `request.text()` / `request.json()` if needed
|
|
411
|
+
* (e.g. to validate a signature over the raw body).
|
|
412
|
+
*/
|
|
413
|
+
request: Request;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Result of the client registration callback.
|
|
417
|
+
*
|
|
418
|
+
* Return `undefined`/nothing to allow registration. Return an object to reject
|
|
419
|
+
* registration. By default, rejection follows RFC 7591 §3.2.2:
|
|
420
|
+
* `invalid_client_metadata` with HTTP 400.
|
|
421
|
+
*/
|
|
422
|
+
interface ClientRegistrationCallbackResult {
|
|
423
|
+
/**
|
|
424
|
+
* OAuth error code when rejecting. Defaults to `invalid_client_metadata`.
|
|
425
|
+
* For non-metadata rejections (e.g. missing initial access token, untrusted
|
|
426
|
+
* origin), set this to a more specific code such as `access_denied` or
|
|
427
|
+
* `invalid_token`.
|
|
428
|
+
*/
|
|
429
|
+
code?: string;
|
|
430
|
+
/** Error description when rejecting. */
|
|
431
|
+
description?: string;
|
|
432
|
+
/**
|
|
433
|
+
* HTTP status code when rejecting. Defaults to 400. Override for auth-style
|
|
434
|
+
* failures (e.g. 401 for missing IAT, 403 for policy denial).
|
|
435
|
+
*/
|
|
436
|
+
status?: number;
|
|
437
|
+
}
|
|
380
438
|
/**
|
|
381
439
|
* Input parameters for the resolveExternalToken callback function
|
|
382
440
|
*/
|
|
@@ -522,6 +580,11 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
|
|
|
522
580
|
* Defaults to false.
|
|
523
581
|
*/
|
|
524
582
|
disallowPublicClientRegistration?: boolean;
|
|
583
|
+
/**
|
|
584
|
+
* Called during DCR (RFC 7591) before the client is stored. Return void/undefined
|
|
585
|
+
* to allow registration, or return an object to reject it.
|
|
586
|
+
*/
|
|
587
|
+
clientRegistrationCallback?: (options: ClientRegistrationCallbackOptions) => Promise<ClientRegistrationCallbackResult | void> | ClientRegistrationCallbackResult | void;
|
|
525
588
|
/**
|
|
526
589
|
* Optional callback function that is called during token exchange.
|
|
527
590
|
* This allows updating the props stored in both the access token and the grant.
|
|
@@ -880,6 +943,13 @@ interface CompleteAuthorizationOptions {
|
|
|
880
943
|
* Set to false to allow multiple concurrent grants per user+client.
|
|
881
944
|
*/
|
|
882
945
|
revokeExistingGrants?: boolean;
|
|
946
|
+
/**
|
|
947
|
+
* Maximum number of grants to fetch per page when revoking existing
|
|
948
|
+
* grants. Only used when revokeExistingGrants is not false.
|
|
949
|
+
* Must be a positive integer. Values above Cloudflare KV's 1000-key page
|
|
950
|
+
* limit are clamped to 1000. Defaults to 50.
|
|
951
|
+
*/
|
|
952
|
+
revokeExistingGrantsBatchSize?: number;
|
|
883
953
|
}
|
|
884
954
|
/**
|
|
885
955
|
* Authorization grant record
|
|
@@ -936,12 +1006,15 @@ interface Grant {
|
|
|
936
1006
|
previousRefreshTokenWrappedKey?: string;
|
|
937
1007
|
/**
|
|
938
1008
|
* The hash of the authorization code associated with this grant
|
|
939
|
-
*
|
|
1009
|
+
* Retained after exchange so that a replay of the same code can be
|
|
1010
|
+
* verified before any action is taken on the grant. The code is
|
|
1011
|
+
* considered already exchanged once authCodeWrappedKey is removed.
|
|
940
1012
|
*/
|
|
941
1013
|
authCodeId?: string;
|
|
942
1014
|
/**
|
|
943
1015
|
* Wrapped encryption key for the authorization code
|
|
944
|
-
*
|
|
1016
|
+
* Present only until the authorization code is exchanged; its absence
|
|
1017
|
+
* (with authCodeId still set) marks the code as already used.
|
|
945
1018
|
*/
|
|
946
1019
|
authCodeWrappedKey?: string;
|
|
947
1020
|
/**
|
|
@@ -1319,4 +1392,4 @@ declare function getJwtCryptoAlgorithms(alg: string): {
|
|
|
1319
1392
|
verifyAlgorithm: Parameters<SubtleCrypto['verify']>[0];
|
|
1320
1393
|
};
|
|
1321
1394
|
//#endregion
|
|
1322
|
-
export { AuthRequest, ClientInfo, CompleteAuthorizationOptions, type EmaClaimsMapper, type EmaClaimsMapperInput, type EmaClaimsMapperResult, type EmaIdJagClaims, type EmaOptions, type EmaTrustedIssuer, type EmaTrustedIssuerResolver, type EmaTrustedIssuerResolverInput, type EmaValidationError, ExchangeTokenOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthError, OAuthErrorOptions, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, OAuthTokenErrorCode, PurgeOptions, PurgeResult, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, base64UrlToBytes, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
|
|
1395
|
+
export { AuthRequest, ClientInfo, ClientRegistrationCallbackOptions, ClientRegistrationCallbackResult, CompleteAuthorizationOptions, type EmaClaimsMapper, type EmaClaimsMapperInput, type EmaClaimsMapperResult, type EmaIdJagClaims, type EmaOptions, type EmaTrustedIssuer, type EmaTrustedIssuerResolver, type EmaTrustedIssuerResolverInput, type EmaValidationError, ExchangeTokenOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthError, OAuthErrorOptions, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, OAuthTokenErrorCode, PurgeOptions, PurgeResult, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, base64UrlToBytes, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
|
package/dist/oauth-provider.js
CHANGED
|
@@ -672,6 +672,10 @@ function readNumericDateClaim(claims, claimName) {
|
|
|
672
672
|
//#endregion
|
|
673
673
|
//#region src/oauth-provider.ts
|
|
674
674
|
const PROTECTED_RESOURCE_WELL_KNOWN_PREFIX = "/.well-known/oauth-protected-resource";
|
|
675
|
+
const NO_CACHE_HEADERS = {
|
|
676
|
+
"Cache-Control": "no-store",
|
|
677
|
+
Pragma: "no-cache"
|
|
678
|
+
};
|
|
675
679
|
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");
|
|
676
680
|
/**
|
|
677
681
|
* Enum representing the type of handler (ExportedHandler or WorkerEntrypoint)
|
|
@@ -865,7 +869,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
865
869
|
const parsed = await this.parseTokenEndpointRequest(request, env);
|
|
866
870
|
if (parsed instanceof Response) return this.addCorsHeaders(parsed, request);
|
|
867
871
|
let response;
|
|
868
|
-
if (parsed.isRevocationRequest) response = await this.handleRevocationRequest(parsed.body, env);
|
|
872
|
+
if (parsed.isRevocationRequest) response = await this.handleRevocationRequest(parsed.body, parsed.clientInfo, env);
|
|
869
873
|
else response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env, url, request);
|
|
870
874
|
return this.addCorsHeaders(response, request);
|
|
871
875
|
}
|
|
@@ -989,17 +993,35 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
989
993
|
statusCode: 400
|
|
990
994
|
});
|
|
991
995
|
const formData = await request.formData();
|
|
996
|
+
const processedKeys = /* @__PURE__ */ new Set();
|
|
992
997
|
for (const [key, value] of formData.entries()) {
|
|
998
|
+
if (processedKeys.has(key)) continue;
|
|
999
|
+
processedKeys.add(key);
|
|
993
1000
|
const allValues = formData.getAll(key);
|
|
1001
|
+
if (key !== "resource" && allValues.length > 1) return this.createErrorResponse("invalid_request", {
|
|
1002
|
+
description: `Request parameter "${key}" must not be repeated`,
|
|
1003
|
+
statusCode: 400
|
|
1004
|
+
});
|
|
994
1005
|
body[key] = allValues.length > 1 ? allValues : value;
|
|
995
1006
|
}
|
|
996
1007
|
const authHeader = request.headers.get("Authorization");
|
|
997
1008
|
let clientId = "";
|
|
998
1009
|
let clientSecret = "";
|
|
999
1010
|
if (authHeader && authHeader.startsWith("Basic ")) {
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1011
|
+
if (body.client_id || body.client_secret) return this.createErrorResponse("invalid_request", {
|
|
1012
|
+
description: "Client must not use multiple authentication methods",
|
|
1013
|
+
statusCode: 400
|
|
1014
|
+
});
|
|
1015
|
+
const credentials = atob(authHeader.substring(6));
|
|
1016
|
+
const separatorIndex = credentials.indexOf(":");
|
|
1017
|
+
if (separatorIndex === -1) return this.createErrorResponse("invalid_client", {
|
|
1018
|
+
description: "Client authentication failed: invalid Basic credentials",
|
|
1019
|
+
statusCode: 401
|
|
1020
|
+
});
|
|
1021
|
+
const id = credentials.substring(0, separatorIndex);
|
|
1022
|
+
const secret = credentials.substring(separatorIndex + 1);
|
|
1023
|
+
clientId = decodeFormUrlEncodedComponent(id);
|
|
1024
|
+
clientSecret = decodeFormUrlEncodedComponent(secret);
|
|
1003
1025
|
} else {
|
|
1004
1026
|
clientId = body.client_id;
|
|
1005
1027
|
clientSecret = body.client_secret || "";
|
|
@@ -1126,7 +1148,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1126
1148
|
registration_endpoint: registrationEndpoint,
|
|
1127
1149
|
scopes_supported: this.options.scopesSupported,
|
|
1128
1150
|
response_types_supported: responseTypesSupported,
|
|
1129
|
-
response_modes_supported: ["query"],
|
|
1151
|
+
response_modes_supported: this.options.allowImplicitFlow ? ["query", "fragment"] : ["query"],
|
|
1130
1152
|
grant_types_supported: grantTypesSupported,
|
|
1131
1153
|
...authorizationGrantProfilesSupported.length > 0 ? { authorization_grant_profiles_supported: authorizationGrantProfilesSupported } : {},
|
|
1132
1154
|
token_endpoint_auth_methods_supported: [
|
|
@@ -1214,14 +1236,15 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1214
1236
|
const grantKey = `grant:${userId}:${grantId}`;
|
|
1215
1237
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
1216
1238
|
if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found or authorization code expired" });
|
|
1217
|
-
|
|
1239
|
+
const codeHash = await hashSecret(code);
|
|
1240
|
+
if (!grantData.authCodeId || codeHash !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code" });
|
|
1241
|
+
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
|
|
1242
|
+
if (!grantData.authCodeWrappedKey) {
|
|
1218
1243
|
try {
|
|
1219
1244
|
await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
|
|
1220
1245
|
} catch {}
|
|
1221
1246
|
return this.createErrorResponse("invalid_grant", { description: "Authorization code already used" });
|
|
1222
1247
|
}
|
|
1223
|
-
if (await hashSecret(code) !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code" });
|
|
1224
|
-
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
|
|
1225
1248
|
const isPkceEnabled = !!grantData.codeChallenge;
|
|
1226
1249
|
if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" });
|
|
1227
1250
|
if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", { description: "Invalid redirect URI" });
|
|
@@ -1282,7 +1305,6 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1282
1305
|
}
|
|
1283
1306
|
const now = Math.floor(Date.now() / 1e3);
|
|
1284
1307
|
const useRefreshToken = refreshTokenTTL !== 0;
|
|
1285
|
-
delete grantData.authCodeId;
|
|
1286
1308
|
delete grantData.codeChallenge;
|
|
1287
1309
|
delete grantData.codeChallengeMethod;
|
|
1288
1310
|
delete grantData.authCodeWrappedKey;
|
|
@@ -1325,7 +1347,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1325
1347
|
};
|
|
1326
1348
|
if (refreshToken) tokenResponse.refresh_token = refreshToken;
|
|
1327
1349
|
if (audience) tokenResponse.resource = audience;
|
|
1328
|
-
return new Response(JSON.stringify(tokenResponse), { headers: {
|
|
1350
|
+
return new Response(JSON.stringify(tokenResponse), { headers: {
|
|
1351
|
+
"Content-Type": "application/json",
|
|
1352
|
+
...NO_CACHE_HEADERS
|
|
1353
|
+
} });
|
|
1329
1354
|
}
|
|
1330
1355
|
/**
|
|
1331
1356
|
* Handles the refresh token grant type
|
|
@@ -1458,7 +1483,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1458
1483
|
scope: tokenScopes.join(" ")
|
|
1459
1484
|
};
|
|
1460
1485
|
if (audience) tokenResponse.resource = audience;
|
|
1461
|
-
return new Response(JSON.stringify(tokenResponse), { headers: {
|
|
1486
|
+
return new Response(JSON.stringify(tokenResponse), { headers: {
|
|
1487
|
+
"Content-Type": "application/json",
|
|
1488
|
+
...NO_CACHE_HEADERS
|
|
1489
|
+
} });
|
|
1462
1490
|
}
|
|
1463
1491
|
/**
|
|
1464
1492
|
* Core token exchange logic (RFC 8693)
|
|
@@ -1583,7 +1611,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1583
1611
|
}
|
|
1584
1612
|
try {
|
|
1585
1613
|
const tokenResponse = await this.exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env);
|
|
1586
|
-
return new Response(JSON.stringify(tokenResponse), { headers: {
|
|
1614
|
+
return new Response(JSON.stringify(tokenResponse), { headers: {
|
|
1615
|
+
"Content-Type": "application/json",
|
|
1616
|
+
...NO_CACHE_HEADERS
|
|
1617
|
+
} });
|
|
1587
1618
|
} catch (error) {
|
|
1588
1619
|
const response = this.createOAuthErrorResponse(error);
|
|
1589
1620
|
if (response) return response;
|
|
@@ -1601,7 +1632,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1601
1632
|
async handleJwtBearerGrant(body, clientInfo, env, requestUrl, request) {
|
|
1602
1633
|
const enterpriseOptions = this.options.enterpriseManagedAuthorization;
|
|
1603
1634
|
if (!enterpriseOptions) return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" });
|
|
1604
|
-
if (clientInfo.tokenEndpointAuthMethod === "none") return this.createErrorResponse("invalid_client", {
|
|
1635
|
+
if (clientInfo.tokenEndpointAuthMethod === "none" && !enterpriseOptions.allowPublicClients) return this.createErrorResponse("invalid_client", {
|
|
1605
1636
|
description: "Enterprise-managed authorization requires client authentication",
|
|
1606
1637
|
statusCode: 401
|
|
1607
1638
|
});
|
|
@@ -1613,16 +1644,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1613
1644
|
request,
|
|
1614
1645
|
enterpriseOptions
|
|
1615
1646
|
});
|
|
1616
|
-
const noCacheHeaders = {
|
|
1617
|
-
"Cache-Control": "no-store",
|
|
1618
|
-
Pragma: "no-cache"
|
|
1619
|
-
};
|
|
1620
1647
|
if (!result.ok) {
|
|
1621
1648
|
const wire = emaErrorToWire(result.error);
|
|
1622
|
-
return this.createErrorResponse(wire.code, {
|
|
1623
|
-
description: wire.message,
|
|
1624
|
-
headers: noCacheHeaders
|
|
1625
|
-
}, {
|
|
1649
|
+
return this.createErrorResponse(wire.code, { description: wire.message }, {
|
|
1626
1650
|
category: "enterprise-managed-authorization",
|
|
1627
1651
|
reason: result.error.reason,
|
|
1628
1652
|
detail: result.error
|
|
@@ -1630,7 +1654,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1630
1654
|
}
|
|
1631
1655
|
return new Response(JSON.stringify(result.value), { headers: {
|
|
1632
1656
|
"Content-Type": "application/json",
|
|
1633
|
-
...
|
|
1657
|
+
...NO_CACHE_HEADERS
|
|
1634
1658
|
} });
|
|
1635
1659
|
}
|
|
1636
1660
|
/**
|
|
@@ -1807,29 +1831,55 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1807
1831
|
* @param env - Cloudflare Worker environment variables
|
|
1808
1832
|
* @returns Response confirming revocation or error
|
|
1809
1833
|
*/
|
|
1810
|
-
async handleRevocationRequest(body, env) {
|
|
1811
|
-
return this.revokeToken(body, env);
|
|
1834
|
+
async handleRevocationRequest(body, clientInfo, env) {
|
|
1835
|
+
return this.revokeToken(body, clientInfo, env);
|
|
1812
1836
|
}
|
|
1813
1837
|
/**
|
|
1814
1838
|
* - Access tokens: Revokes only the specific token
|
|
1815
1839
|
* - Refresh tokens: Revokes the entire grant (access + refresh tokens)
|
|
1840
|
+
* Per RFC 7009 §2.1, the server MUST verify the token was issued to the client making the request.
|
|
1816
1841
|
* @param body - The parsed request body containing token parameter
|
|
1842
|
+
* @param clientInfo - The authenticated client information
|
|
1817
1843
|
* @param env - Cloudflare Worker environment variables
|
|
1818
1844
|
* @returns Response confirming revocation or error
|
|
1819
1845
|
*/
|
|
1820
|
-
async revokeToken(body, env) {
|
|
1846
|
+
async revokeToken(body, clientInfo, env) {
|
|
1821
1847
|
const token = body.token;
|
|
1848
|
+
const tokenTypeHint = body.token_type_hint;
|
|
1822
1849
|
if (!token) return this.createErrorResponse("invalid_request", { description: "Token parameter is required" });
|
|
1823
1850
|
const tokenParts = token.split(":");
|
|
1824
1851
|
if (tokenParts.length !== 3) return new Response("", { status: 200 });
|
|
1825
1852
|
const [userId, grantId, _] = tokenParts;
|
|
1826
1853
|
const tokenId = await generateTokenId(token);
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
else
|
|
1854
|
+
if (tokenTypeHint === "refresh_token") {
|
|
1855
|
+
if (await this.revokeRefreshIfOwned(tokenId, userId, grantId, clientInfo, env)) return new Response("", { status: 200 });
|
|
1856
|
+
if (await this.revokeAccessIfOwned(tokenId, userId, grantId, clientInfo, env)) return new Response("", { status: 200 });
|
|
1857
|
+
} else {
|
|
1858
|
+
if (await this.revokeAccessIfOwned(tokenId, userId, grantId, clientInfo, env)) return new Response("", { status: 200 });
|
|
1859
|
+
if (await this.revokeRefreshIfOwned(tokenId, userId, grantId, clientInfo, env)) return new Response("", { status: 200 });
|
|
1860
|
+
}
|
|
1831
1861
|
return new Response("", { status: 200 });
|
|
1832
1862
|
}
|
|
1863
|
+
/** Revoke an access token if it exists and belongs to the requesting client. */
|
|
1864
|
+
async revokeAccessIfOwned(tokenId, userId, grantId, clientInfo, env) {
|
|
1865
|
+
const tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${tokenId}`, { type: "json" });
|
|
1866
|
+
if (!tokenData) return false;
|
|
1867
|
+
const tokenClientId = tokenData.grant?.clientId;
|
|
1868
|
+
if (tokenClientId !== void 0) {
|
|
1869
|
+
if (tokenClientId !== clientInfo.clientId) return false;
|
|
1870
|
+
} else if ((await env.OAUTH_KV.get(`grant:${userId}:${grantId}`, { type: "json" }))?.clientId !== clientInfo.clientId) return false;
|
|
1871
|
+
await this.revokeSpecificAccessToken(tokenId, userId, grantId, env);
|
|
1872
|
+
return true;
|
|
1873
|
+
}
|
|
1874
|
+
/** Revoke a refresh token (and its grant) if it exists and belongs to the requesting client. */
|
|
1875
|
+
async revokeRefreshIfOwned(tokenId, userId, grantId, clientInfo, env) {
|
|
1876
|
+
const grantData = await env.OAUTH_KV.get(`grant:${userId}:${grantId}`, { type: "json" });
|
|
1877
|
+
if (!grantData) return false;
|
|
1878
|
+
if (!(grantData.refreshTokenId === tokenId || grantData.previousRefreshTokenId === tokenId)) return false;
|
|
1879
|
+
if (grantData.clientId !== clientInfo.clientId) return false;
|
|
1880
|
+
await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
|
|
1881
|
+
return true;
|
|
1882
|
+
}
|
|
1833
1883
|
/**
|
|
1834
1884
|
* Revokes a specific access token without affecting the refresh token
|
|
1835
1885
|
* @param tokenId - The hashed token ID
|
|
@@ -1842,35 +1892,6 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1842
1892
|
await env.OAUTH_KV.delete(tokenKey);
|
|
1843
1893
|
}
|
|
1844
1894
|
/**
|
|
1845
|
-
* Validates if a token is a valid access token
|
|
1846
|
-
* @param tokenId - The hashed token ID
|
|
1847
|
-
* @param userId - The user ID extracted from the token
|
|
1848
|
-
* @param grantId - The grant ID extracted from the token
|
|
1849
|
-
* @param env - Cloudflare Worker environment variables
|
|
1850
|
-
* @returns Promise<boolean> indicating if the token is valid
|
|
1851
|
-
*/
|
|
1852
|
-
async validateAccessToken(tokenId, userId, grantId, env) {
|
|
1853
|
-
const tokenKey = `token:${userId}:${grantId}:${tokenId}`;
|
|
1854
|
-
const tokenData = await env.OAUTH_KV.get(tokenKey, { type: "json" });
|
|
1855
|
-
if (!tokenData) return false;
|
|
1856
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
1857
|
-
return tokenData.expiresAt >= now;
|
|
1858
|
-
}
|
|
1859
|
-
/**
|
|
1860
|
-
* Validates if a token is a valid refresh token
|
|
1861
|
-
* @param tokenId - The hashed token ID
|
|
1862
|
-
* @param userId - The user ID extracted from the token
|
|
1863
|
-
* @param grantId - The grant ID extracted from the token
|
|
1864
|
-
* @param env - Cloudflare Worker environment variables
|
|
1865
|
-
* @returns Promise<boolean> indicating if the token is valid
|
|
1866
|
-
*/
|
|
1867
|
-
async validateRefreshToken(tokenId, userId, grantId, env) {
|
|
1868
|
-
const grantKey = `grant:${userId}:${grantId}`;
|
|
1869
|
-
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
1870
|
-
if (!grantData) return false;
|
|
1871
|
-
return grantData.refreshTokenId === tokenId || grantData.previousRefreshTokenId === tokenId;
|
|
1872
|
-
}
|
|
1873
|
-
/**
|
|
1874
1895
|
* Handles the dynamic client registration endpoint (RFC 7591)
|
|
1875
1896
|
* @param request - The HTTP request
|
|
1876
1897
|
* @param env - Cloudflare Worker environment variables
|
|
@@ -1889,6 +1910,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1889
1910
|
description: "Request payload too large, must be under 1 MiB",
|
|
1890
1911
|
statusCode: 413
|
|
1891
1912
|
});
|
|
1913
|
+
const callbackRequest = request.clone();
|
|
1892
1914
|
let clientMetadata;
|
|
1893
1915
|
try {
|
|
1894
1916
|
const text = await request.text();
|
|
@@ -1942,6 +1964,24 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1942
1964
|
} catch (error) {
|
|
1943
1965
|
return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" });
|
|
1944
1966
|
}
|
|
1967
|
+
if (this.options.clientRegistrationCallback) {
|
|
1968
|
+
let callbackResult;
|
|
1969
|
+
try {
|
|
1970
|
+
callbackResult = await Promise.resolve(this.options.clientRegistrationCallback({
|
|
1971
|
+
clientMetadata,
|
|
1972
|
+
request: callbackRequest
|
|
1973
|
+
}));
|
|
1974
|
+
} catch (error) {
|
|
1975
|
+
return this.createErrorResponse("server_error", {
|
|
1976
|
+
description: error instanceof Error ? error.message : "Client registration callback failed",
|
|
1977
|
+
statusCode: 500
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1980
|
+
if (callbackResult !== void 0) return this.createErrorResponse(callbackResult.code || "invalid_client_metadata", {
|
|
1981
|
+
description: callbackResult.description || "Client registration denied",
|
|
1982
|
+
statusCode: callbackResult.status ?? 400
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1945
1985
|
const clientKvOptions = {};
|
|
1946
1986
|
if (this.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.options.clientRegistrationTTL;
|
|
1947
1987
|
await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(clientInfo), clientKvOptions);
|
|
@@ -1971,7 +2011,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1971
2011
|
}
|
|
1972
2012
|
return new Response(JSON.stringify(response), {
|
|
1973
2013
|
status: 201,
|
|
1974
|
-
headers: {
|
|
2014
|
+
headers: {
|
|
2015
|
+
"Content-Type": "application/json",
|
|
2016
|
+
...NO_CACHE_HEADERS
|
|
2017
|
+
}
|
|
1975
2018
|
});
|
|
1976
2019
|
}
|
|
1977
2020
|
/**
|
|
@@ -2387,7 +2430,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2387
2430
|
createErrorResponse(code, options, internal) {
|
|
2388
2431
|
const { description } = options;
|
|
2389
2432
|
const responseStatus = options.statusCode ?? 400;
|
|
2390
|
-
const responseHeaders =
|
|
2433
|
+
const responseHeaders = {
|
|
2434
|
+
...NO_CACHE_HEADERS,
|
|
2435
|
+
...options.headers ?? {}
|
|
2436
|
+
};
|
|
2391
2437
|
const customErrorResponse = this.options.onError?.({
|
|
2392
2438
|
code,
|
|
2393
2439
|
description,
|
|
@@ -2481,6 +2527,20 @@ const DEFAULT_CLIENT_REGISTRATION_TTL = 2160 * 60 * 60;
|
|
|
2481
2527
|
*/
|
|
2482
2528
|
const DEFAULT_PURGE_BATCH_SIZE = 50;
|
|
2483
2529
|
/**
|
|
2530
|
+
* Maximum supported Cloudflare KV list page size.
|
|
2531
|
+
*/
|
|
2532
|
+
const MAX_KV_LIST_LIMIT = 1e3;
|
|
2533
|
+
/**
|
|
2534
|
+
* Default batch size for paginating existing grants when revoking them
|
|
2535
|
+
* during completeAuthorization. Conservative for each KV list page.
|
|
2536
|
+
*/
|
|
2537
|
+
const DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE = 50;
|
|
2538
|
+
function getRevokeExistingGrantsBatchSize(batchSize) {
|
|
2539
|
+
if (batchSize === void 0) return DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE;
|
|
2540
|
+
if (!Number.isFinite(batchSize) || !Number.isInteger(batchSize) || batchSize < 1) throw new Error("revokeExistingGrantsBatchSize must be a positive integer.");
|
|
2541
|
+
return Math.min(batchSize, MAX_KV_LIST_LIMIT);
|
|
2542
|
+
}
|
|
2543
|
+
/**
|
|
2484
2544
|
* Length of generated token strings
|
|
2485
2545
|
*/
|
|
2486
2546
|
const TOKEN_LENGTH = 32;
|
|
@@ -2558,6 +2618,14 @@ async function hashSecret(secret) {
|
|
|
2558
2618
|
return generateTokenId(secret);
|
|
2559
2619
|
}
|
|
2560
2620
|
/**
|
|
2621
|
+
* Decodes an application/x-www-form-urlencoded component.
|
|
2622
|
+
* @param value - The encoded component value
|
|
2623
|
+
* @returns The decoded component value
|
|
2624
|
+
*/
|
|
2625
|
+
function decodeFormUrlEncodedComponent(value) {
|
|
2626
|
+
return decodeURIComponent(value.replace(/\+/g, " "));
|
|
2627
|
+
}
|
|
2628
|
+
/**
|
|
2561
2629
|
* Generates a cryptographically secure random string
|
|
2562
2630
|
* @param length - The length of the string to generate
|
|
2563
2631
|
* @returns A random string of the specified length
|
|
@@ -2904,9 +2972,13 @@ var OAuthHelpersImpl = class {
|
|
|
2904
2972
|
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.");
|
|
2905
2973
|
let grantsToRevoke = [];
|
|
2906
2974
|
if (options.revokeExistingGrants !== false) {
|
|
2975
|
+
const batchSize = getRevokeExistingGrantsBatchSize(options.revokeExistingGrantsBatchSize);
|
|
2907
2976
|
let cursor;
|
|
2908
2977
|
do {
|
|
2909
|
-
const page = await this.listUserGrants(options.userId, {
|
|
2978
|
+
const page = await this.listUserGrants(options.userId, {
|
|
2979
|
+
cursor,
|
|
2980
|
+
limit: batchSize
|
|
2981
|
+
});
|
|
2910
2982
|
for (const grant of page.items) if (grant.clientId === clientId) grantsToRevoke.push(grant.id);
|
|
2911
2983
|
cursor = page.cursor;
|
|
2912
2984
|
} while (cursor);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudflare/workers-oauth-provider",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "OAuth provider for Cloudflare Workers",
|
|
5
5
|
"main": "dist/oauth-provider.js",
|
|
6
6
|
"types": "dist/oauth-provider.d.ts",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"prettier": "^3.7.4",
|
|
33
33
|
"tsdown": "^0.18.1",
|
|
34
34
|
"typescript": "^5.9.3",
|
|
35
|
-
"vitest": "^
|
|
35
|
+
"vitest": "^4.1.8"
|
|
36
36
|
},
|
|
37
37
|
"repository": {
|
|
38
38
|
"type": "git",
|