@cloudflare/workers-oauth-provider 0.7.2 → 0.8.1
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 +9 -0
- package/dist/oauth-provider.d.ts +60 -3
- package/dist/oauth-provider.js +164 -65
- 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:
|
package/dist/oauth-provider.d.ts
CHANGED
|
@@ -393,6 +393,48 @@ interface TokenExchangeCallbackOptions {
|
|
|
393
393
|
*/
|
|
394
394
|
props: any;
|
|
395
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
|
+
}
|
|
396
438
|
/**
|
|
397
439
|
* Input parameters for the resolveExternalToken callback function
|
|
398
440
|
*/
|
|
@@ -538,6 +580,11 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
|
|
|
538
580
|
* Defaults to false.
|
|
539
581
|
*/
|
|
540
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;
|
|
541
588
|
/**
|
|
542
589
|
* Optional callback function that is called during token exchange.
|
|
543
590
|
* This allows updating the props stored in both the access token and the grant.
|
|
@@ -896,6 +943,13 @@ interface CompleteAuthorizationOptions {
|
|
|
896
943
|
* Set to false to allow multiple concurrent grants per user+client.
|
|
897
944
|
*/
|
|
898
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;
|
|
899
953
|
}
|
|
900
954
|
/**
|
|
901
955
|
* Authorization grant record
|
|
@@ -952,12 +1006,15 @@ interface Grant {
|
|
|
952
1006
|
previousRefreshTokenWrappedKey?: string;
|
|
953
1007
|
/**
|
|
954
1008
|
* The hash of the authorization code associated with this grant
|
|
955
|
-
*
|
|
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.
|
|
956
1012
|
*/
|
|
957
1013
|
authCodeId?: string;
|
|
958
1014
|
/**
|
|
959
1015
|
* Wrapped encryption key for the authorization code
|
|
960
|
-
*
|
|
1016
|
+
* Present only until the authorization code is exchanged; its absence
|
|
1017
|
+
* (with authCodeId still set) marks the code as already used.
|
|
961
1018
|
*/
|
|
962
1019
|
authCodeWrappedKey?: string;
|
|
963
1020
|
/**
|
|
@@ -1335,4 +1392,4 @@ declare function getJwtCryptoAlgorithms(alg: string): {
|
|
|
1335
1392
|
verifyAlgorithm: Parameters<SubtleCrypto['verify']>[0];
|
|
1336
1393
|
};
|
|
1337
1394
|
//#endregion
|
|
1338
|
-
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
|
@@ -639,9 +639,11 @@ function validateEmaMapperResult(result) {
|
|
|
639
639
|
* TOCTOU window between claim validation and token mint.
|
|
640
640
|
*/
|
|
641
641
|
function computeEmaAccessTokenTTL(input) {
|
|
642
|
-
const { configuredDefaultSeconds, assertionExp, mapperTtl, now } = input;
|
|
642
|
+
const { configuredDefaultSeconds, assertionExp, mapperTtl, now, minTtlSeconds } = input;
|
|
643
643
|
if (assertionExp - now <= 0) return err({ reason: "assertion_expired_after_processing" });
|
|
644
|
-
|
|
644
|
+
const ttl = mapperTtl ?? configuredDefaultSeconds;
|
|
645
|
+
if (ttl < minTtlSeconds) return err({ reason: "invalid_mapped_ttl" });
|
|
646
|
+
return ok(ttl);
|
|
645
647
|
}
|
|
646
648
|
function readRequiredString(claims, claimName) {
|
|
647
649
|
const value = claims[claimName];
|
|
@@ -672,6 +674,10 @@ function readNumericDateClaim(claims, claimName) {
|
|
|
672
674
|
//#endregion
|
|
673
675
|
//#region src/oauth-provider.ts
|
|
674
676
|
const PROTECTED_RESOURCE_WELL_KNOWN_PREFIX = "/.well-known/oauth-protected-resource";
|
|
677
|
+
const NO_CACHE_HEADERS = {
|
|
678
|
+
"Cache-Control": "no-store",
|
|
679
|
+
Pragma: "no-cache"
|
|
680
|
+
};
|
|
675
681
|
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
682
|
/**
|
|
677
683
|
* Enum representing the type of handler (ExportedHandler or WorkerEntrypoint)
|
|
@@ -781,6 +787,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
781
787
|
onError: ({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`),
|
|
782
788
|
...options
|
|
783
789
|
};
|
|
790
|
+
if (!Number.isInteger(this.options.accessTokenTTL) || this.options.accessTokenTTL < KV_MIN_EXPIRATION_TTL_SECONDS) throw new TypeError(`accessTokenTTL must be an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds (Cloudflare KV's minimum expiration window).`);
|
|
784
791
|
this.validateEmaOptions(this.options.enterpriseManagedAuthorization);
|
|
785
792
|
if (this.options.enterpriseManagedAuthorization) {
|
|
786
793
|
this.jwksProvider = createDefaultJwksProvider({ cacheTtlSeconds: this.options.enterpriseManagedAuthorization.jwksCacheTtlSeconds });
|
|
@@ -865,7 +872,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
865
872
|
const parsed = await this.parseTokenEndpointRequest(request, env);
|
|
866
873
|
if (parsed instanceof Response) return this.addCorsHeaders(parsed, request);
|
|
867
874
|
let response;
|
|
868
|
-
if (parsed.isRevocationRequest) response = await this.handleRevocationRequest(parsed.body, env);
|
|
875
|
+
if (parsed.isRevocationRequest) response = await this.handleRevocationRequest(parsed.body, parsed.clientInfo, env);
|
|
869
876
|
else response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env, url, request);
|
|
870
877
|
return this.addCorsHeaders(response, request);
|
|
871
878
|
}
|
|
@@ -989,17 +996,35 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
989
996
|
statusCode: 400
|
|
990
997
|
});
|
|
991
998
|
const formData = await request.formData();
|
|
999
|
+
const processedKeys = /* @__PURE__ */ new Set();
|
|
992
1000
|
for (const [key, value] of formData.entries()) {
|
|
1001
|
+
if (processedKeys.has(key)) continue;
|
|
1002
|
+
processedKeys.add(key);
|
|
993
1003
|
const allValues = formData.getAll(key);
|
|
1004
|
+
if (key !== "resource" && allValues.length > 1) return this.createErrorResponse("invalid_request", {
|
|
1005
|
+
description: `Request parameter "${key}" must not be repeated`,
|
|
1006
|
+
statusCode: 400
|
|
1007
|
+
});
|
|
994
1008
|
body[key] = allValues.length > 1 ? allValues : value;
|
|
995
1009
|
}
|
|
996
1010
|
const authHeader = request.headers.get("Authorization");
|
|
997
1011
|
let clientId = "";
|
|
998
1012
|
let clientSecret = "";
|
|
999
1013
|
if (authHeader && authHeader.startsWith("Basic ")) {
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1014
|
+
if (body.client_id || body.client_secret) return this.createErrorResponse("invalid_request", {
|
|
1015
|
+
description: "Client must not use multiple authentication methods",
|
|
1016
|
+
statusCode: 400
|
|
1017
|
+
});
|
|
1018
|
+
const credentials = atob(authHeader.substring(6));
|
|
1019
|
+
const separatorIndex = credentials.indexOf(":");
|
|
1020
|
+
if (separatorIndex === -1) return this.createErrorResponse("invalid_client", {
|
|
1021
|
+
description: "Client authentication failed: invalid Basic credentials",
|
|
1022
|
+
statusCode: 401
|
|
1023
|
+
});
|
|
1024
|
+
const id = credentials.substring(0, separatorIndex);
|
|
1025
|
+
const secret = credentials.substring(separatorIndex + 1);
|
|
1026
|
+
clientId = decodeFormUrlEncodedComponent(id);
|
|
1027
|
+
clientSecret = decodeFormUrlEncodedComponent(secret);
|
|
1003
1028
|
} else {
|
|
1004
1029
|
clientId = body.client_id;
|
|
1005
1030
|
clientSecret = body.client_secret || "";
|
|
@@ -1126,7 +1151,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1126
1151
|
registration_endpoint: registrationEndpoint,
|
|
1127
1152
|
scopes_supported: this.options.scopesSupported,
|
|
1128
1153
|
response_types_supported: responseTypesSupported,
|
|
1129
|
-
response_modes_supported: ["query"],
|
|
1154
|
+
response_modes_supported: this.options.allowImplicitFlow ? ["query", "fragment"] : ["query"],
|
|
1130
1155
|
grant_types_supported: grantTypesSupported,
|
|
1131
1156
|
...authorizationGrantProfilesSupported.length > 0 ? { authorization_grant_profiles_supported: authorizationGrantProfilesSupported } : {},
|
|
1132
1157
|
token_endpoint_auth_methods_supported: [
|
|
@@ -1214,14 +1239,15 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1214
1239
|
const grantKey = `grant:${userId}:${grantId}`;
|
|
1215
1240
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
1216
1241
|
if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found or authorization code expired" });
|
|
1217
|
-
|
|
1242
|
+
const codeHash = await hashSecret(code);
|
|
1243
|
+
if (!grantData.authCodeId || codeHash !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code" });
|
|
1244
|
+
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
|
|
1245
|
+
if (!grantData.authCodeWrappedKey) {
|
|
1218
1246
|
try {
|
|
1219
1247
|
await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
|
|
1220
1248
|
} catch {}
|
|
1221
1249
|
return this.createErrorResponse("invalid_grant", { description: "Authorization code already used" });
|
|
1222
1250
|
}
|
|
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
1251
|
const isPkceEnabled = !!grantData.codeChallenge;
|
|
1226
1252
|
if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" });
|
|
1227
1253
|
if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", { description: "Invalid redirect URI" });
|
|
@@ -1282,7 +1308,6 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1282
1308
|
}
|
|
1283
1309
|
const now = Math.floor(Date.now() / 1e3);
|
|
1284
1310
|
const useRefreshToken = refreshTokenTTL !== 0;
|
|
1285
|
-
delete grantData.authCodeId;
|
|
1286
1311
|
delete grantData.codeChallenge;
|
|
1287
1312
|
delete grantData.codeChallengeMethod;
|
|
1288
1313
|
delete grantData.authCodeWrappedKey;
|
|
@@ -1325,7 +1350,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1325
1350
|
};
|
|
1326
1351
|
if (refreshToken) tokenResponse.refresh_token = refreshToken;
|
|
1327
1352
|
if (audience) tokenResponse.resource = audience;
|
|
1328
|
-
return new Response(JSON.stringify(tokenResponse), { headers: {
|
|
1353
|
+
return new Response(JSON.stringify(tokenResponse), { headers: {
|
|
1354
|
+
"Content-Type": "application/json",
|
|
1355
|
+
...NO_CACHE_HEADERS
|
|
1356
|
+
} });
|
|
1329
1357
|
}
|
|
1330
1358
|
/**
|
|
1331
1359
|
* Handles the refresh token grant type
|
|
@@ -1350,7 +1378,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1350
1378
|
if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", { description: "Invalid refresh token" });
|
|
1351
1379
|
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
|
|
1352
1380
|
if (grantData.expiresAt !== void 0) {
|
|
1353
|
-
|
|
1381
|
+
const now$1 = Math.floor(Date.now() / 1e3);
|
|
1382
|
+
if (grantData.expiresAt - now$1 < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" });
|
|
1354
1383
|
}
|
|
1355
1384
|
const newAccessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
1356
1385
|
const accessTokenId = await generateTokenId(newAccessToken);
|
|
@@ -1407,10 +1436,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1407
1436
|
}
|
|
1408
1437
|
}
|
|
1409
1438
|
const now = Math.floor(Date.now() / 1e3);
|
|
1439
|
+
if (grantData.expiresAt !== void 0 && grantData.expiresAt - now < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" });
|
|
1410
1440
|
if (grantData.expiresAt !== void 0) {
|
|
1411
1441
|
const remainingRefreshTokenLifetime = grantData.expiresAt - now;
|
|
1412
1442
|
if (remainingRefreshTokenLifetime > 0) accessTokenTTL = Math.min(accessTokenTTL, remainingRefreshTokenLifetime);
|
|
1413
1443
|
}
|
|
1444
|
+
if (accessTokenTTL < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" });
|
|
1414
1445
|
const accessTokenExpiresAt = now + accessTokenTTL;
|
|
1415
1446
|
const accessTokenWrappedKey = await wrapKeyWithToken(newAccessToken, accessTokenEncryptionKey);
|
|
1416
1447
|
const newRefreshToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
@@ -1458,7 +1489,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1458
1489
|
scope: tokenScopes.join(" ")
|
|
1459
1490
|
};
|
|
1460
1491
|
if (audience) tokenResponse.resource = audience;
|
|
1461
|
-
return new Response(JSON.stringify(tokenResponse), { headers: {
|
|
1492
|
+
return new Response(JSON.stringify(tokenResponse), { headers: {
|
|
1493
|
+
"Content-Type": "application/json",
|
|
1494
|
+
...NO_CACHE_HEADERS
|
|
1495
|
+
} });
|
|
1462
1496
|
}
|
|
1463
1497
|
/**
|
|
1464
1498
|
* Core token exchange logic (RFC 8693)
|
|
@@ -1496,6 +1530,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1496
1530
|
}
|
|
1497
1531
|
const now = Math.floor(Date.now() / 1e3);
|
|
1498
1532
|
const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
|
|
1533
|
+
if (subjectTokenRemainingLifetime < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_grant", { description: "Subject token is too close to expiry to exchange" });
|
|
1499
1534
|
let accessTokenTTL = this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL;
|
|
1500
1535
|
if (expiresIn !== void 0) {
|
|
1501
1536
|
if (expiresIn <= 0) throw new OAuthError("invalid_request", { description: "Invalid expires_in parameter" });
|
|
@@ -1533,6 +1568,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1533
1568
|
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
1534
1569
|
}
|
|
1535
1570
|
}
|
|
1571
|
+
if (accessTokenTTL < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" });
|
|
1536
1572
|
const tokenResponse = {
|
|
1537
1573
|
access_token: await this.createAccessToken({
|
|
1538
1574
|
userId: tokenSummary.userId,
|
|
@@ -1583,7 +1619,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1583
1619
|
}
|
|
1584
1620
|
try {
|
|
1585
1621
|
const tokenResponse = await this.exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env);
|
|
1586
|
-
return new Response(JSON.stringify(tokenResponse), { headers: {
|
|
1622
|
+
return new Response(JSON.stringify(tokenResponse), { headers: {
|
|
1623
|
+
"Content-Type": "application/json",
|
|
1624
|
+
...NO_CACHE_HEADERS
|
|
1625
|
+
} });
|
|
1587
1626
|
} catch (error) {
|
|
1588
1627
|
const response = this.createOAuthErrorResponse(error);
|
|
1589
1628
|
if (response) return response;
|
|
@@ -1613,16 +1652,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1613
1652
|
request,
|
|
1614
1653
|
enterpriseOptions
|
|
1615
1654
|
});
|
|
1616
|
-
const noCacheHeaders = {
|
|
1617
|
-
"Cache-Control": "no-store",
|
|
1618
|
-
Pragma: "no-cache"
|
|
1619
|
-
};
|
|
1620
1655
|
if (!result.ok) {
|
|
1621
1656
|
const wire = emaErrorToWire(result.error);
|
|
1622
|
-
return this.createErrorResponse(wire.code, {
|
|
1623
|
-
description: wire.message,
|
|
1624
|
-
headers: noCacheHeaders
|
|
1625
|
-
}, {
|
|
1657
|
+
return this.createErrorResponse(wire.code, { description: wire.message }, {
|
|
1626
1658
|
category: "enterprise-managed-authorization",
|
|
1627
1659
|
reason: result.error.reason,
|
|
1628
1660
|
detail: result.error
|
|
@@ -1630,7 +1662,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1630
1662
|
}
|
|
1631
1663
|
return new Response(JSON.stringify(result.value), { headers: {
|
|
1632
1664
|
"Content-Type": "application/json",
|
|
1633
|
-
...
|
|
1665
|
+
...NO_CACHE_HEADERS
|
|
1634
1666
|
} });
|
|
1635
1667
|
}
|
|
1636
1668
|
/**
|
|
@@ -1713,7 +1745,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1713
1745
|
configuredDefaultSeconds: this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL,
|
|
1714
1746
|
assertionExp: claims.value.claims.exp,
|
|
1715
1747
|
mapperTtl: mapped.value.accessTokenTTL,
|
|
1716
|
-
now: issueNow
|
|
1748
|
+
now: issueNow,
|
|
1749
|
+
minTtlSeconds: KV_MIN_EXPIRATION_TTL_SECONDS
|
|
1717
1750
|
});
|
|
1718
1751
|
if (!ttl.ok) return ttl;
|
|
1719
1752
|
return ok(await this.issueEmaAccessToken({
|
|
@@ -1807,29 +1840,55 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1807
1840
|
* @param env - Cloudflare Worker environment variables
|
|
1808
1841
|
* @returns Response confirming revocation or error
|
|
1809
1842
|
*/
|
|
1810
|
-
async handleRevocationRequest(body, env) {
|
|
1811
|
-
return this.revokeToken(body, env);
|
|
1843
|
+
async handleRevocationRequest(body, clientInfo, env) {
|
|
1844
|
+
return this.revokeToken(body, clientInfo, env);
|
|
1812
1845
|
}
|
|
1813
1846
|
/**
|
|
1814
1847
|
* - Access tokens: Revokes only the specific token
|
|
1815
1848
|
* - Refresh tokens: Revokes the entire grant (access + refresh tokens)
|
|
1849
|
+
* Per RFC 7009 §2.1, the server MUST verify the token was issued to the client making the request.
|
|
1816
1850
|
* @param body - The parsed request body containing token parameter
|
|
1851
|
+
* @param clientInfo - The authenticated client information
|
|
1817
1852
|
* @param env - Cloudflare Worker environment variables
|
|
1818
1853
|
* @returns Response confirming revocation or error
|
|
1819
1854
|
*/
|
|
1820
|
-
async revokeToken(body, env) {
|
|
1855
|
+
async revokeToken(body, clientInfo, env) {
|
|
1821
1856
|
const token = body.token;
|
|
1857
|
+
const tokenTypeHint = body.token_type_hint;
|
|
1822
1858
|
if (!token) return this.createErrorResponse("invalid_request", { description: "Token parameter is required" });
|
|
1823
1859
|
const tokenParts = token.split(":");
|
|
1824
1860
|
if (tokenParts.length !== 3) return new Response("", { status: 200 });
|
|
1825
1861
|
const [userId, grantId, _] = tokenParts;
|
|
1826
1862
|
const tokenId = await generateTokenId(token);
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
else
|
|
1863
|
+
if (tokenTypeHint === "refresh_token") {
|
|
1864
|
+
if (await this.revokeRefreshIfOwned(tokenId, userId, grantId, clientInfo, env)) return new Response("", { status: 200 });
|
|
1865
|
+
if (await this.revokeAccessIfOwned(tokenId, userId, grantId, clientInfo, env)) return new Response("", { status: 200 });
|
|
1866
|
+
} else {
|
|
1867
|
+
if (await this.revokeAccessIfOwned(tokenId, userId, grantId, clientInfo, env)) return new Response("", { status: 200 });
|
|
1868
|
+
if (await this.revokeRefreshIfOwned(tokenId, userId, grantId, clientInfo, env)) return new Response("", { status: 200 });
|
|
1869
|
+
}
|
|
1831
1870
|
return new Response("", { status: 200 });
|
|
1832
1871
|
}
|
|
1872
|
+
/** Revoke an access token if it exists and belongs to the requesting client. */
|
|
1873
|
+
async revokeAccessIfOwned(tokenId, userId, grantId, clientInfo, env) {
|
|
1874
|
+
const tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${tokenId}`, { type: "json" });
|
|
1875
|
+
if (!tokenData) return false;
|
|
1876
|
+
const tokenClientId = tokenData.grant?.clientId;
|
|
1877
|
+
if (tokenClientId !== void 0) {
|
|
1878
|
+
if (tokenClientId !== clientInfo.clientId) return false;
|
|
1879
|
+
} else if ((await env.OAUTH_KV.get(`grant:${userId}:${grantId}`, { type: "json" }))?.clientId !== clientInfo.clientId) return false;
|
|
1880
|
+
await this.revokeSpecificAccessToken(tokenId, userId, grantId, env);
|
|
1881
|
+
return true;
|
|
1882
|
+
}
|
|
1883
|
+
/** Revoke a refresh token (and its grant) if it exists and belongs to the requesting client. */
|
|
1884
|
+
async revokeRefreshIfOwned(tokenId, userId, grantId, clientInfo, env) {
|
|
1885
|
+
const grantData = await env.OAUTH_KV.get(`grant:${userId}:${grantId}`, { type: "json" });
|
|
1886
|
+
if (!grantData) return false;
|
|
1887
|
+
if (!(grantData.refreshTokenId === tokenId || grantData.previousRefreshTokenId === tokenId)) return false;
|
|
1888
|
+
if (grantData.clientId !== clientInfo.clientId) return false;
|
|
1889
|
+
await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
|
|
1890
|
+
return true;
|
|
1891
|
+
}
|
|
1833
1892
|
/**
|
|
1834
1893
|
* Revokes a specific access token without affecting the refresh token
|
|
1835
1894
|
* @param tokenId - The hashed token ID
|
|
@@ -1842,35 +1901,6 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1842
1901
|
await env.OAUTH_KV.delete(tokenKey);
|
|
1843
1902
|
}
|
|
1844
1903
|
/**
|
|
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
1904
|
* Handles the dynamic client registration endpoint (RFC 7591)
|
|
1875
1905
|
* @param request - The HTTP request
|
|
1876
1906
|
* @param env - Cloudflare Worker environment variables
|
|
@@ -1889,6 +1919,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1889
1919
|
description: "Request payload too large, must be under 1 MiB",
|
|
1890
1920
|
statusCode: 413
|
|
1891
1921
|
});
|
|
1922
|
+
const callbackRequest = request.clone();
|
|
1892
1923
|
let clientMetadata;
|
|
1893
1924
|
try {
|
|
1894
1925
|
const text = await request.text();
|
|
@@ -1942,6 +1973,24 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1942
1973
|
} catch (error) {
|
|
1943
1974
|
return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" });
|
|
1944
1975
|
}
|
|
1976
|
+
if (this.options.clientRegistrationCallback) {
|
|
1977
|
+
let callbackResult;
|
|
1978
|
+
try {
|
|
1979
|
+
callbackResult = await Promise.resolve(this.options.clientRegistrationCallback({
|
|
1980
|
+
clientMetadata,
|
|
1981
|
+
request: callbackRequest
|
|
1982
|
+
}));
|
|
1983
|
+
} catch (error) {
|
|
1984
|
+
return this.createErrorResponse("server_error", {
|
|
1985
|
+
description: error instanceof Error ? error.message : "Client registration callback failed",
|
|
1986
|
+
statusCode: 500
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
if (callbackResult !== void 0) return this.createErrorResponse(callbackResult.code || "invalid_client_metadata", {
|
|
1990
|
+
description: callbackResult.description || "Client registration denied",
|
|
1991
|
+
statusCode: callbackResult.status ?? 400
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1945
1994
|
const clientKvOptions = {};
|
|
1946
1995
|
if (this.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.options.clientRegistrationTTL;
|
|
1947
1996
|
await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(clientInfo), clientKvOptions);
|
|
@@ -1971,7 +2020,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1971
2020
|
}
|
|
1972
2021
|
return new Response(JSON.stringify(response), {
|
|
1973
2022
|
status: 201,
|
|
1974
|
-
headers: {
|
|
2023
|
+
headers: {
|
|
2024
|
+
"Content-Type": "application/json",
|
|
2025
|
+
...NO_CACHE_HEADERS
|
|
2026
|
+
}
|
|
1975
2027
|
});
|
|
1976
2028
|
}
|
|
1977
2029
|
/**
|
|
@@ -2071,7 +2123,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2071
2123
|
* @param now - Current timestamp in seconds
|
|
2072
2124
|
*/
|
|
2073
2125
|
async saveGrantWithTTL(env, grantKey, grantData, now) {
|
|
2074
|
-
const
|
|
2126
|
+
const minExpiration = now + KV_MIN_EXPIRATION_TTL_SECONDS + KV_EXPIRATION_CLAMP_MARGIN_SECONDS;
|
|
2127
|
+
const kvOptions = grantData.expiresAt !== void 0 ? { expiration: Math.max(grantData.expiresAt, minExpiration) } : {};
|
|
2075
2128
|
try {
|
|
2076
2129
|
await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
|
|
2077
2130
|
} catch (error) {
|
|
@@ -2128,6 +2181,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2128
2181
|
*/
|
|
2129
2182
|
async createAccessToken(params) {
|
|
2130
2183
|
const { userId, grantId, clientId, scope, encryptedProps, encryptionKey, expiresIn, audience, env } = params;
|
|
2184
|
+
if (expiresIn < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" });
|
|
2131
2185
|
const accessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
2132
2186
|
const now = Math.floor(Date.now() / 1e3);
|
|
2133
2187
|
const accessTokenId = await generateTokenId(accessToken);
|
|
@@ -2387,7 +2441,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2387
2441
|
createErrorResponse(code, options, internal) {
|
|
2388
2442
|
const { description } = options;
|
|
2389
2443
|
const responseStatus = options.statusCode ?? 400;
|
|
2390
|
-
const responseHeaders =
|
|
2444
|
+
const responseHeaders = {
|
|
2445
|
+
...NO_CACHE_HEADERS,
|
|
2446
|
+
...options.headers ?? {}
|
|
2447
|
+
};
|
|
2391
2448
|
const customErrorResponse = this.options.onError?.({
|
|
2392
2449
|
code,
|
|
2393
2450
|
description,
|
|
@@ -2476,11 +2533,41 @@ const DEFAULT_REFRESH_TOKEN_TTL = 720 * 60 * 60;
|
|
|
2476
2533
|
*/
|
|
2477
2534
|
const DEFAULT_CLIENT_REGISTRATION_TTL = 2160 * 60 * 60;
|
|
2478
2535
|
/**
|
|
2536
|
+
* Minimum number of seconds an absolute KV expiration must be in the future.
|
|
2537
|
+
* Cloudflare KV rejects `put` calls whose `expiration` is less than 60 seconds
|
|
2538
|
+
* away with "400 Invalid expiration ... Expiration times must be at least 60
|
|
2539
|
+
* seconds in the future." We use this to treat near-expiry grants as expired and
|
|
2540
|
+
* to clamp absolute expirations when writing grants back to KV.
|
|
2541
|
+
*/
|
|
2542
|
+
const KV_MIN_EXPIRATION_TTL_SECONDS = 60;
|
|
2543
|
+
/**
|
|
2544
|
+
* Safety margin (seconds) added on top of `KV_MIN_EXPIRATION_TTL_SECONDS` when clamping an
|
|
2545
|
+
* absolute KV expiration. Absolute expirations are validated against KV's clock at the
|
|
2546
|
+
* moment the write is processed, so writing exactly `now + 60` can be rejected once
|
|
2547
|
+
* worker→KV latency or minor clock skew is accounted for. The margin keeps clamped writes
|
|
2548
|
+
* comfortably above KV's hard minimum without meaningfully extending a grant's lifetime.
|
|
2549
|
+
*/
|
|
2550
|
+
const KV_EXPIRATION_CLAMP_MARGIN_SECONDS = 5;
|
|
2551
|
+
/**
|
|
2479
2552
|
* Default batch size for purgeExpiredData. Conservative to stay within
|
|
2480
2553
|
* Cloudflare's 1000 subrequest limit per invocation.
|
|
2481
2554
|
*/
|
|
2482
2555
|
const DEFAULT_PURGE_BATCH_SIZE = 50;
|
|
2483
2556
|
/**
|
|
2557
|
+
* Maximum supported Cloudflare KV list page size.
|
|
2558
|
+
*/
|
|
2559
|
+
const MAX_KV_LIST_LIMIT = 1e3;
|
|
2560
|
+
/**
|
|
2561
|
+
* Default batch size for paginating existing grants when revoking them
|
|
2562
|
+
* during completeAuthorization. Conservative for each KV list page.
|
|
2563
|
+
*/
|
|
2564
|
+
const DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE = 50;
|
|
2565
|
+
function getRevokeExistingGrantsBatchSize(batchSize) {
|
|
2566
|
+
if (batchSize === void 0) return DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE;
|
|
2567
|
+
if (!Number.isFinite(batchSize) || !Number.isInteger(batchSize) || batchSize < 1) throw new Error("revokeExistingGrantsBatchSize must be a positive integer.");
|
|
2568
|
+
return Math.min(batchSize, MAX_KV_LIST_LIMIT);
|
|
2569
|
+
}
|
|
2570
|
+
/**
|
|
2484
2571
|
* Length of generated token strings
|
|
2485
2572
|
*/
|
|
2486
2573
|
const TOKEN_LENGTH = 32;
|
|
@@ -2558,6 +2645,14 @@ async function hashSecret(secret) {
|
|
|
2558
2645
|
return generateTokenId(secret);
|
|
2559
2646
|
}
|
|
2560
2647
|
/**
|
|
2648
|
+
* Decodes an application/x-www-form-urlencoded component.
|
|
2649
|
+
* @param value - The encoded component value
|
|
2650
|
+
* @returns The decoded component value
|
|
2651
|
+
*/
|
|
2652
|
+
function decodeFormUrlEncodedComponent(value) {
|
|
2653
|
+
return decodeURIComponent(value.replace(/\+/g, " "));
|
|
2654
|
+
}
|
|
2655
|
+
/**
|
|
2561
2656
|
* Generates a cryptographically secure random string
|
|
2562
2657
|
* @param length - The length of the string to generate
|
|
2563
2658
|
* @returns A random string of the specified length
|
|
@@ -2904,9 +2999,13 @@ var OAuthHelpersImpl = class {
|
|
|
2904
2999
|
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
3000
|
let grantsToRevoke = [];
|
|
2906
3001
|
if (options.revokeExistingGrants !== false) {
|
|
3002
|
+
const batchSize = getRevokeExistingGrantsBatchSize(options.revokeExistingGrantsBatchSize);
|
|
2907
3003
|
let cursor;
|
|
2908
3004
|
do {
|
|
2909
|
-
const page = await this.listUserGrants(options.userId, {
|
|
3005
|
+
const page = await this.listUserGrants(options.userId, {
|
|
3006
|
+
cursor,
|
|
3007
|
+
limit: batchSize
|
|
3008
|
+
});
|
|
2910
3009
|
for (const grant of page.items) if (grant.clientId === clientId) grantsToRevoke.push(grant.id);
|
|
2911
3010
|
cursor = page.cursor;
|
|
2912
3011
|
} 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.1",
|
|
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",
|