@cloudflare/workers-oauth-provider 0.8.2 → 0.9.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 +356 -451
- package/dist/oauth-provider.d.ts +146 -35
- package/dist/oauth-provider.js +451 -161
- package/package.json +5 -3
package/dist/oauth-provider.js
CHANGED
|
@@ -1,5 +1,70 @@
|
|
|
1
1
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
2
2
|
|
|
3
|
+
//#region src/oauth-capabilities.ts
|
|
4
|
+
const OAUTH_SCOPE_TOKEN_PATTERN = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
|
|
5
|
+
function buildOAuthServerCapabilities(options) {
|
|
6
|
+
return {
|
|
7
|
+
grantTypes: [
|
|
8
|
+
"authorization_code",
|
|
9
|
+
"refresh_token",
|
|
10
|
+
...options.allowImplicitFlow ? ["implicit"] : [],
|
|
11
|
+
...options.allowTokenExchangeGrant ? ["urn:ietf:params:oauth:grant-type:token-exchange"] : [],
|
|
12
|
+
...options.enterpriseManagedAuthorization ? ["urn:ietf:params:oauth:grant-type:jwt-bearer"] : []
|
|
13
|
+
],
|
|
14
|
+
responseTypes: options.allowImplicitFlow ? ["code", "token"] : ["code"],
|
|
15
|
+
tokenEndpointAuthMethods: [
|
|
16
|
+
"client_secret_basic",
|
|
17
|
+
"client_secret_post",
|
|
18
|
+
"none"
|
|
19
|
+
],
|
|
20
|
+
codeChallengeMethods: options.allowPlainPKCE ? ["plain", "S256"] : ["S256"]
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function validateClientCapabilities(server, client) {
|
|
24
|
+
if (!server.tokenEndpointAuthMethods.includes(client.tokenEndpointAuthMethod)) throw new Error(`Unsupported token_endpoint_auth_method: ${client.tokenEndpointAuthMethod}`);
|
|
25
|
+
const unsupportedGrant = client.grantTypes.find((grantType) => !server.grantTypes.includes(grantType));
|
|
26
|
+
if (unsupportedGrant) throw new Error(`Unsupported grant_type: ${unsupportedGrant}`);
|
|
27
|
+
const unsupportedResponse = client.responseTypes.find((responseType) => !server.responseTypes.includes(responseType));
|
|
28
|
+
if (unsupportedResponse) throw new Error(`Unsupported response_type: ${unsupportedResponse}`);
|
|
29
|
+
if (client.grantTypes.includes("authorization_code") !== client.responseTypes.includes("code")) throw new Error("grant_types authorization_code and response_types code must be registered together");
|
|
30
|
+
if (client.grantTypes.includes("implicit") !== client.responseTypes.includes("token")) throw new Error("grant_types implicit and response_types token must be registered together");
|
|
31
|
+
}
|
|
32
|
+
function validateAuthorizationResponseType(server, responseType, clientResponseTypes) {
|
|
33
|
+
if (!responseType) throw new Error("invalid_request: response_type is required");
|
|
34
|
+
if (!server.responseTypes.includes(responseType)) throw new Error(`unsupported_response_type: the authorization server does not support ${responseType}`);
|
|
35
|
+
if (!(clientResponseTypes ?? ["code"]).includes(responseType)) throw new Error(`unauthorized_client: the client is not registered for response_type ${responseType}`);
|
|
36
|
+
}
|
|
37
|
+
/** Parse a PKCE method, applying RFC 7636's default of `plain`. */
|
|
38
|
+
function normalizePkceCodeChallengeMethod(method) {
|
|
39
|
+
const effectiveMethod = method ?? "plain";
|
|
40
|
+
if (effectiveMethod !== "plain" && effectiveMethod !== "S256") throw new Error(`Unsupported PKCE code_challenge_method: ${effectiveMethod}`);
|
|
41
|
+
return effectiveMethod;
|
|
42
|
+
}
|
|
43
|
+
/** Require a syntactically valid PKCE method that the server advertises. */
|
|
44
|
+
function validatePkceCodeChallengeMethod(server, method) {
|
|
45
|
+
const effectiveMethod = normalizePkceCodeChallengeMethod(method);
|
|
46
|
+
if (!server.codeChallengeMethods.includes(effectiveMethod)) throw new Error("The plain PKCE method is not allowed. Use S256 instead.");
|
|
47
|
+
return effectiveMethod;
|
|
48
|
+
}
|
|
49
|
+
/** Validate authorization-request PKCE against server and client capabilities. */
|
|
50
|
+
function validateAuthorizationPkce(server, request, client) {
|
|
51
|
+
if (request.codeChallenge) {
|
|
52
|
+
validatePkceCodeChallengeMethod(server, request.codeChallengeMethod);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (request.codeChallengeMethod) throw new Error("PKCE code_challenge is required when code_challenge_method is provided.");
|
|
56
|
+
if (request.responseType === "code" && client.tokenEndpointAuthMethod === "none") throw new Error("Public clients must use PKCE with the authorization code flow.");
|
|
57
|
+
}
|
|
58
|
+
function validateAuthorizationServerScopes(scopes) {
|
|
59
|
+
if (!scopes) return;
|
|
60
|
+
if (scopes.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("scopesSupported must contain valid OAuth scope tokens");
|
|
61
|
+
if (new Set(scopes).size !== scopes.length) throw new TypeError("scopesSupported must not contain duplicate values");
|
|
62
|
+
}
|
|
63
|
+
function isValidOAuthScopeToken(scopeToken) {
|
|
64
|
+
return OAUTH_SCOPE_TOKEN_PATTERN.test(scopeToken);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
//#endregion
|
|
3
68
|
//#region src/ema/constants.ts
|
|
4
69
|
/**
|
|
5
70
|
* Constants for MCP Enterprise-Managed Authorization (EMA).
|
|
@@ -105,6 +170,7 @@ function emaErrorToWire(e) {
|
|
|
105
170
|
case "jwks_fetch_failed":
|
|
106
171
|
case "invalid_claim":
|
|
107
172
|
case "aud_mismatch":
|
|
173
|
+
case "unsupported_claim":
|
|
108
174
|
case "expired":
|
|
109
175
|
case "iat_in_future":
|
|
110
176
|
case "nbf_in_future":
|
|
@@ -473,7 +539,8 @@ function isWellFormedTrustedIssuer(issuer) {
|
|
|
473
539
|
*
|
|
474
540
|
* Enforces (in order):
|
|
475
541
|
* - presence + type of `iss`, `sub`, `aud`, `client_id`, `jti`, `exp`, `iat`
|
|
476
|
-
* - `aud`
|
|
542
|
+
* - `aud` is the AS's expected audience, as a string or single-element array
|
|
543
|
+
* - unsupported authorization-bearing claims fail closed
|
|
477
544
|
* - `client_id` matches the authenticated client
|
|
478
545
|
* - optional `resource` is a valid RFC 8707 URI and matches the AS's configured resource
|
|
479
546
|
* - the configured resource is used when `resource` is omitted
|
|
@@ -496,6 +563,10 @@ function validateIdJagClaims(input) {
|
|
|
496
563
|
if (!sub.ok) return sub;
|
|
497
564
|
const aud = readAudienceClaim(rawClaims);
|
|
498
565
|
if (!aud.ok) return aud;
|
|
566
|
+
for (const claim of ["authorization_details", "cnf"]) if (rawClaims[claim] !== void 0) return err({
|
|
567
|
+
reason: "unsupported_claim",
|
|
568
|
+
claim
|
|
569
|
+
});
|
|
499
570
|
const resource = rawClaims.resource === void 0 ? ok(configuredResource) : readRequiredString(rawClaims, "resource");
|
|
500
571
|
if (!resource.ok) return resource;
|
|
501
572
|
const claimClientId = readRequiredString(rawClaims, "client_id");
|
|
@@ -506,7 +577,7 @@ function validateIdJagClaims(input) {
|
|
|
506
577
|
if (!exp.ok) return exp;
|
|
507
578
|
const iat = readNumericDateClaim(rawClaims, "iat");
|
|
508
579
|
if (!iat.ok) return iat;
|
|
509
|
-
if (
|
|
580
|
+
if ((Array.isArray(aud.value) ? aud.value[0] : aud.value) !== expectedAudience) return err({
|
|
510
581
|
reason: "aud_mismatch",
|
|
511
582
|
expected: expectedAudience,
|
|
512
583
|
got: aud.value
|
|
@@ -657,7 +728,7 @@ function readRequiredString(claims, claimName) {
|
|
|
657
728
|
function readAudienceClaim(claims) {
|
|
658
729
|
const aud = claims.aud;
|
|
659
730
|
if (typeof aud === "string" && aud.length > 0) return ok(aud);
|
|
660
|
-
if (Array.isArray(aud) && aud.length
|
|
731
|
+
if (Array.isArray(aud) && aud.length === 1 && typeof aud[0] === "string" && aud[0].length > 0) return ok(aud);
|
|
661
732
|
return err({
|
|
662
733
|
reason: "invalid_claim",
|
|
663
734
|
claim: "aud"
|
|
@@ -679,6 +750,7 @@ const NO_CACHE_HEADERS = {
|
|
|
679
750
|
"Cache-Control": "no-store",
|
|
680
751
|
Pragma: "no-cache"
|
|
681
752
|
};
|
|
753
|
+
const BASIC_AUTH_CHALLENGE = "Basic realm=\"OAuth\"";
|
|
682
754
|
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");
|
|
683
755
|
/**
|
|
684
756
|
* Enum representing the type of handler (ExportedHandler or WorkerEntrypoint)
|
|
@@ -789,6 +861,14 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
789
861
|
...options
|
|
790
862
|
};
|
|
791
863
|
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).`);
|
|
864
|
+
this.serverCapabilities = buildOAuthServerCapabilities({
|
|
865
|
+
allowImplicitFlow: !!this.options.allowImplicitFlow,
|
|
866
|
+
allowPlainPKCE: this.options.allowPlainPKCE === true,
|
|
867
|
+
allowTokenExchangeGrant: !!this.options.allowTokenExchangeGrant,
|
|
868
|
+
enterpriseManagedAuthorization: !!this.options.enterpriseManagedAuthorization
|
|
869
|
+
});
|
|
870
|
+
validateAuthorizationServerScopes(this.options.scopesSupported);
|
|
871
|
+
this.validateResourceMetadataOptions(this.options.resourceMetadata);
|
|
792
872
|
this.validateEmaOptions(this.options.enterpriseManagedAuthorization);
|
|
793
873
|
if (this.options.enterpriseManagedAuthorization) {
|
|
794
874
|
this.jwksProvider = createDefaultJwksProvider({ cacheTtlSeconds: this.options.enterpriseManagedAuthorization.jwksCacheTtlSeconds });
|
|
@@ -828,6 +908,25 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
828
908
|
};
|
|
829
909
|
throw new TypeError(`${name} must be either an ExportedHandler object with a fetch method or a class extending WorkerEntrypoint`);
|
|
830
910
|
}
|
|
911
|
+
/** Validate configured RFC 9728 protected resource metadata. */
|
|
912
|
+
validateResourceMetadataOptions(options) {
|
|
913
|
+
if (!options) return;
|
|
914
|
+
if (options.resource && !validateResourceUri(options.resource)) throw new TypeError("resourceMetadata.resource must be an absolute HTTP(S) URI without a fragment");
|
|
915
|
+
if (options.authorization_servers !== void 0) {
|
|
916
|
+
if (options.authorization_servers.length === 0) throw new TypeError("resourceMetadata.authorization_servers must contain at least one issuer");
|
|
917
|
+
for (const issuer of options.authorization_servers) {
|
|
918
|
+
let parsed;
|
|
919
|
+
try {
|
|
920
|
+
parsed = new URL(issuer);
|
|
921
|
+
} catch {
|
|
922
|
+
throw new TypeError("resourceMetadata.authorization_servers must contain valid HTTPS issuer URLs");
|
|
923
|
+
}
|
|
924
|
+
if (parsed.protocol !== "https:" || parsed.search || parsed.hash) throw new TypeError("resourceMetadata.authorization_servers must contain valid HTTPS issuer URLs");
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
if (options.scopes_supported?.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("resourceMetadata.scopes_supported must contain valid OAuth scope tokens");
|
|
928
|
+
if (options.bearer_methods_supported?.some((method) => method !== "header")) throw new TypeError("resourceMetadata.bearer_methods_supported only supports 'header'");
|
|
929
|
+
}
|
|
831
930
|
/**
|
|
832
931
|
* Validates MCP Enterprise-Managed Authorization configuration at construction time.
|
|
833
932
|
*
|
|
@@ -887,7 +986,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
887
986
|
}
|
|
888
987
|
if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
|
|
889
988
|
if (this.typedDefaultHandler.type === HandlerType.EXPORTED_HANDLER) return this.typedDefaultHandler.handler.fetch(request, env, ctx);
|
|
890
|
-
|
|
989
|
+
return new this.typedDefaultHandler.handler(ctx, env).fetch(request);
|
|
891
990
|
}
|
|
892
991
|
/**
|
|
893
992
|
* Decodes a token and returns token data with decrypted props
|
|
@@ -980,6 +1079,13 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
980
1079
|
if (!suffix || suffix === "/") return requestUrl.origin;
|
|
981
1080
|
return `${requestUrl.origin}${suffix}`;
|
|
982
1081
|
}
|
|
1082
|
+
createInvalidClientResponse(description, basicAuthenticationAttempted, internal, request) {
|
|
1083
|
+
return this.createErrorResponse("invalid_client", {
|
|
1084
|
+
description,
|
|
1085
|
+
statusCode: 401,
|
|
1086
|
+
...basicAuthenticationAttempted ? { headers: { "WWW-Authenticate": BASIC_AUTH_CHALLENGE } } : {}
|
|
1087
|
+
}, internal, request);
|
|
1088
|
+
}
|
|
983
1089
|
/**
|
|
984
1090
|
* Parses and validates a token endpoint request (used for both token exchange and revocation)
|
|
985
1091
|
* @param request - The HTTP request to parse
|
|
@@ -990,13 +1096,21 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
990
1096
|
description: "Method not allowed",
|
|
991
1097
|
statusCode: 405
|
|
992
1098
|
});
|
|
993
|
-
|
|
1099
|
+
const contentType = request.headers.get("Content-Type") || "";
|
|
994
1100
|
let body = {};
|
|
995
|
-
if (
|
|
1101
|
+
if (contentType.split(";")[0].trim().toLowerCase() !== "application/x-www-form-urlencoded") return this.createErrorResponse("invalid_request", {
|
|
996
1102
|
description: "Content-Type must be application/x-www-form-urlencoded",
|
|
997
1103
|
statusCode: 400
|
|
998
1104
|
});
|
|
999
|
-
|
|
1105
|
+
let formData;
|
|
1106
|
+
try {
|
|
1107
|
+
formData = await request.formData();
|
|
1108
|
+
} catch {
|
|
1109
|
+
return this.createErrorResponse("invalid_request", {
|
|
1110
|
+
description: "Request body must be valid application/x-www-form-urlencoded data",
|
|
1111
|
+
statusCode: 400
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1000
1114
|
const processedKeys = /* @__PURE__ */ new Set();
|
|
1001
1115
|
for (const [key, value] of formData.entries()) {
|
|
1002
1116
|
if (processedKeys.has(key)) continue;
|
|
@@ -1008,55 +1122,50 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1008
1122
|
});
|
|
1009
1123
|
body[key] = allValues.length > 1 ? allValues : value;
|
|
1010
1124
|
}
|
|
1011
|
-
const
|
|
1125
|
+
const basicAuthorization = parseBasicAuthorizationHeader(request.headers.get("Authorization"));
|
|
1126
|
+
const basicAuthenticationAttempted = basicAuthorization.kind !== "not-basic";
|
|
1012
1127
|
let clientId = "";
|
|
1013
1128
|
let clientSecret = "";
|
|
1014
|
-
if (
|
|
1015
|
-
if (
|
|
1129
|
+
if (basicAuthenticationAttempted) {
|
|
1130
|
+
if (formData.has("client_id") || formData.has("client_secret")) return this.createErrorResponse("invalid_request", {
|
|
1016
1131
|
description: "Client must not use multiple authentication methods",
|
|
1017
1132
|
statusCode: 400
|
|
1018
1133
|
});
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
description: "Client authentication failed: invalid Basic credentials",
|
|
1023
|
-
statusCode: 401
|
|
1024
|
-
});
|
|
1025
|
-
const id = credentials.substring(0, separatorIndex);
|
|
1026
|
-
const secret = credentials.substring(separatorIndex + 1);
|
|
1027
|
-
clientId = decodeFormUrlEncodedComponent(id);
|
|
1028
|
-
clientSecret = decodeFormUrlEncodedComponent(secret);
|
|
1134
|
+
if (basicAuthorization.kind === "malformed") return this.createInvalidClientResponse("Client authentication failed: invalid Basic credentials", basicAuthenticationAttempted);
|
|
1135
|
+
clientId = basicAuthorization.clientId;
|
|
1136
|
+
clientSecret = basicAuthorization.clientSecret;
|
|
1029
1137
|
} else {
|
|
1030
1138
|
clientId = body.client_id;
|
|
1031
1139
|
clientSecret = body.client_secret || "";
|
|
1032
1140
|
}
|
|
1033
|
-
if (!clientId) return this.
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1141
|
+
if (!clientId) return this.createInvalidClientResponse("Client ID is required", basicAuthenticationAttempted);
|
|
1142
|
+
let clientInfo;
|
|
1143
|
+
try {
|
|
1144
|
+
clientInfo = await this.getClient(env, clientId);
|
|
1145
|
+
} catch (error) {
|
|
1146
|
+
if (error instanceof CimdFetchError) return this.createInvalidClientResponse("Client not found", basicAuthenticationAttempted, {
|
|
1147
|
+
category: "client-id-metadata-document",
|
|
1148
|
+
reason: error.reason,
|
|
1149
|
+
detail: {
|
|
1150
|
+
metadataUrl: error.metadataUrl,
|
|
1151
|
+
message: error.detail
|
|
1152
|
+
}
|
|
1153
|
+
}, request);
|
|
1154
|
+
throw error;
|
|
1155
|
+
}
|
|
1156
|
+
if (!clientInfo) return this.createInvalidClientResponse("Client not found", basicAuthenticationAttempted);
|
|
1157
|
+
const presentedAuthMethod = basicAuthenticationAttempted ? "client_secret_basic" : formData.has("client_secret") ? "client_secret_post" : "none";
|
|
1158
|
+
if (presentedAuthMethod !== clientInfo.tokenEndpointAuthMethod) return this.createInvalidClientResponse("Client authentication failed", basicAuthenticationAttempted);
|
|
1159
|
+
if (presentedAuthMethod !== "none") {
|
|
1160
|
+
if (!clientSecret) return this.createInvalidClientResponse("Client authentication failed: missing client_secret", basicAuthenticationAttempted);
|
|
1161
|
+
if (!clientInfo.clientSecret) return this.createInvalidClientResponse("Client authentication failed: client has no registered secret", basicAuthenticationAttempted);
|
|
1162
|
+
if (await hashSecret(clientSecret) !== clientInfo.clientSecret) return this.createInvalidClientResponse("Client authentication failed: invalid client_secret", basicAuthenticationAttempted);
|
|
1055
1163
|
}
|
|
1164
|
+
const isRevocationRequest = !body.grant_type && !!body.token;
|
|
1056
1165
|
return {
|
|
1057
1166
|
body,
|
|
1058
1167
|
clientInfo,
|
|
1059
|
-
isRevocationRequest
|
|
1168
|
+
isRevocationRequest
|
|
1060
1169
|
};
|
|
1061
1170
|
}
|
|
1062
1171
|
/**
|
|
@@ -1122,6 +1231,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1122
1231
|
newResponse.headers.set("Access-Control-Allow-Origin", origin);
|
|
1123
1232
|
newResponse.headers.set("Access-Control-Allow-Methods", "*");
|
|
1124
1233
|
newResponse.headers.set("Access-Control-Allow-Headers", "Authorization, *");
|
|
1234
|
+
const exposedHeaders = (newResponse.headers.get("Access-Control-Expose-Headers") ?? "").split(",").map((name) => name.trim()).filter(Boolean);
|
|
1235
|
+
for (const requiredHeader of ["WWW-Authenticate", "Retry-After"]) if (!exposedHeaders.some((name) => name.toLowerCase() === requiredHeader.toLowerCase())) exposedHeaders.push(requiredHeader);
|
|
1236
|
+
newResponse.headers.set("Access-Control-Expose-Headers", exposedHeaders.join(", "));
|
|
1125
1237
|
newResponse.headers.set("Access-Control-Max-Age", "86400");
|
|
1126
1238
|
return newResponse;
|
|
1127
1239
|
}
|
|
@@ -1136,15 +1248,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1136
1248
|
const authorizeEndpoint = this.getFullEndpointUrl(this.options.authorizeEndpoint, requestUrl);
|
|
1137
1249
|
let registrationEndpoint = void 0;
|
|
1138
1250
|
if (this.options.clientRegistrationEndpoint) registrationEndpoint = this.getFullEndpointUrl(this.options.clientRegistrationEndpoint, requestUrl);
|
|
1139
|
-
const responseTypesSupported =
|
|
1140
|
-
|
|
1141
|
-
const
|
|
1142
|
-
if (this.options.allowTokenExchangeGrant) grantTypesSupported.push(GrantType.TOKEN_EXCHANGE);
|
|
1143
|
-
const authorizationGrantProfilesSupported = [];
|
|
1144
|
-
if (this.options.enterpriseManagedAuthorization) {
|
|
1145
|
-
grantTypesSupported.push(GrantType.JWT_BEARER);
|
|
1146
|
-
authorizationGrantProfilesSupported.push(EMA_ID_JAG_GRANT_PROFILE);
|
|
1147
|
-
}
|
|
1251
|
+
const responseTypesSupported = this.serverCapabilities.responseTypes;
|
|
1252
|
+
const grantTypesSupported = this.serverCapabilities.grantTypes;
|
|
1253
|
+
const authorizationGrantProfilesSupported = this.options.enterpriseManagedAuthorization ? [EMA_ID_JAG_GRANT_PROFILE] : [];
|
|
1148
1254
|
const metadata = {
|
|
1149
1255
|
issuer: new URL(tokenEndpoint).origin,
|
|
1150
1256
|
authorization_endpoint: authorizeEndpoint,
|
|
@@ -1155,17 +1261,22 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1155
1261
|
response_modes_supported: this.options.allowImplicitFlow ? ["query", "fragment"] : ["query"],
|
|
1156
1262
|
grant_types_supported: grantTypesSupported,
|
|
1157
1263
|
...authorizationGrantProfilesSupported.length > 0 ? { authorization_grant_profiles_supported: authorizationGrantProfilesSupported } : {},
|
|
1158
|
-
token_endpoint_auth_methods_supported:
|
|
1159
|
-
"client_secret_basic",
|
|
1160
|
-
"client_secret_post",
|
|
1161
|
-
"none"
|
|
1162
|
-
],
|
|
1264
|
+
token_endpoint_auth_methods_supported: this.serverCapabilities.tokenEndpointAuthMethods,
|
|
1163
1265
|
revocation_endpoint: tokenEndpoint,
|
|
1164
|
-
code_challenge_methods_supported: this.
|
|
1266
|
+
code_challenge_methods_supported: this.serverCapabilities.codeChallengeMethods,
|
|
1267
|
+
authorization_response_iss_parameter_supported: true,
|
|
1165
1268
|
client_id_metadata_document_supported: !!this.options.clientIdMetadataDocumentEnabled && this.hasGlobalFetchStrictlyPublic()
|
|
1166
1269
|
};
|
|
1167
1270
|
return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
|
|
1168
1271
|
}
|
|
1272
|
+
/** Scopes that are baseline requirements of the protected resource itself. */
|
|
1273
|
+
getProtectedResourceScopes() {
|
|
1274
|
+
return this.normalizeProtectedResourceScopes(this.options.resourceMetadata?.scopes_supported ?? []);
|
|
1275
|
+
}
|
|
1276
|
+
/** Deduplicate resource-facing scopes and remove authorization-server-only capabilities. */
|
|
1277
|
+
normalizeProtectedResourceScopes(scopes) {
|
|
1278
|
+
return [...new Set(scopes)].filter((scope) => scope !== "offline_access");
|
|
1279
|
+
}
|
|
1169
1280
|
/**
|
|
1170
1281
|
* Handles the OAuth Protected Resource Metadata endpoint
|
|
1171
1282
|
* Implements RFC 9728 for OAuth Protected Resource Metadata
|
|
@@ -1176,10 +1287,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1176
1287
|
const rm = this.options.resourceMetadata;
|
|
1177
1288
|
const tokenEndpointUrl = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
|
|
1178
1289
|
const authServerOrigin = new URL(tokenEndpointUrl).origin;
|
|
1290
|
+
const resourceScopes = this.getProtectedResourceScopes();
|
|
1179
1291
|
const metadata = {
|
|
1180
1292
|
resource: rm?.resource ?? this.deriveResourceIdentifier(requestUrl),
|
|
1181
1293
|
authorization_servers: rm?.authorization_servers ?? [authServerOrigin],
|
|
1182
|
-
scopes_supported:
|
|
1294
|
+
...resourceScopes.length > 0 ? { scopes_supported: resourceScopes } : {},
|
|
1183
1295
|
bearer_methods_supported: rm?.bearer_methods_supported ?? ["header"]
|
|
1184
1296
|
};
|
|
1185
1297
|
if (rm?.resource_name) metadata.resource_name = rm.resource_name;
|
|
@@ -1208,20 +1320,44 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1208
1320
|
}
|
|
1209
1321
|
}
|
|
1210
1322
|
/**
|
|
1211
|
-
* Build a structured OAuth
|
|
1212
|
-
*
|
|
1213
|
-
* The supported form is throwing this package's exported `OAuthError`.
|
|
1214
|
-
* Anything else is re-thrown so unexpected failures still surface as 500s.
|
|
1323
|
+
* Build a structured OAuth token-endpoint response from an OAuth error.
|
|
1215
1324
|
*
|
|
1216
|
-
*
|
|
1217
|
-
*
|
|
1218
|
-
*
|
|
1325
|
+
* The supported form is throwing this package's exported `OAuthError` from
|
|
1326
|
+
* token issuance or `tokenExchangeCallback`. Anything else is re-thrown so
|
|
1327
|
+
* unexpected failures still surface as 500s.
|
|
1219
1328
|
*/
|
|
1220
1329
|
createOAuthErrorResponse(error) {
|
|
1221
1330
|
if (!(error instanceof OAuthError)) return void 0;
|
|
1222
1331
|
return this.createErrorResponse(error.code, error.options);
|
|
1223
1332
|
}
|
|
1224
1333
|
/**
|
|
1334
|
+
* Build a structured protected-resource response from an external-token error.
|
|
1335
|
+
*
|
|
1336
|
+
* Only this package's exported `ExternalTokenError` is converted. Standard
|
|
1337
|
+
* bearer failures receive an RFC 6750 / RFC 9728 challenge unless the caller
|
|
1338
|
+
* supplied one. Other errors retain the pre-existing behavior and are re-thrown.
|
|
1339
|
+
*/
|
|
1340
|
+
createExternalTokenErrorResponse(error, resourceMetadataUrl) {
|
|
1341
|
+
if (!(error instanceof ExternalTokenError)) return void 0;
|
|
1342
|
+
const headers = error.headers ?? {};
|
|
1343
|
+
const hasChallenge = Object.keys(headers).some((name) => name.toLowerCase() === "www-authenticate");
|
|
1344
|
+
const isBearerError = error.code === "invalid_token" && error.statusCode === 401 || error.code === "insufficient_scope" && error.statusCode === 403;
|
|
1345
|
+
let challengeHeaders;
|
|
1346
|
+
if (isBearerError && !hasChallenge) {
|
|
1347
|
+
const requiredScopes = [...new Set(error.requiredScopes ?? [])];
|
|
1348
|
+
if (requiredScopes.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("ExternalTokenError requiredScopes must contain valid OAuth scope tokens");
|
|
1349
|
+
challengeHeaders = {
|
|
1350
|
+
...headers,
|
|
1351
|
+
"WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, error.code, void 0, requiredScopes)
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
return this.createErrorResponse(error.code, {
|
|
1355
|
+
description: error.description,
|
|
1356
|
+
statusCode: error.statusCode,
|
|
1357
|
+
headers: challengeHeaders ?? headers
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1225
1361
|
* Handles the authorization code grant type
|
|
1226
1362
|
* Exchanges an authorization code for access and refresh tokens
|
|
1227
1363
|
* @param body - The parsed request body
|
|
@@ -1249,6 +1385,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1249
1385
|
} catch {}
|
|
1250
1386
|
return this.createErrorResponse("invalid_grant", { description: "Authorization code already used" });
|
|
1251
1387
|
}
|
|
1388
|
+
let codeChallengeMethod;
|
|
1389
|
+
try {
|
|
1390
|
+
codeChallengeMethod = grantData.codeChallenge ? validatePkceCodeChallengeMethod(this.serverCapabilities, grantData.codeChallengeMethod) : normalizePkceCodeChallengeMethod(grantData.codeChallengeMethod);
|
|
1391
|
+
} catch (error) {
|
|
1392
|
+
return this.createErrorResponse("invalid_grant", { description: error instanceof Error ? error.message : "Invalid PKCE code_challenge_method" });
|
|
1393
|
+
}
|
|
1252
1394
|
const isPkceEnabled = !!grantData.codeChallenge;
|
|
1253
1395
|
if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" });
|
|
1254
1396
|
if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", { description: "Invalid redirect URI" });
|
|
@@ -1256,7 +1398,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1256
1398
|
if (isPkceEnabled) {
|
|
1257
1399
|
if (!codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier is required for PKCE" });
|
|
1258
1400
|
let calculatedChallenge;
|
|
1259
|
-
if (
|
|
1401
|
+
if (codeChallengeMethod === "S256") {
|
|
1260
1402
|
const data = new TextEncoder().encode(codeVerifier);
|
|
1261
1403
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
1262
1404
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
@@ -1264,6 +1406,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1264
1406
|
} else calculatedChallenge = codeVerifier;
|
|
1265
1407
|
if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" });
|
|
1266
1408
|
}
|
|
1409
|
+
const audience = this.resolveTokenResource(body.resource, grantData.resource);
|
|
1267
1410
|
let accessTokenTTL = this.options.accessTokenTTL;
|
|
1268
1411
|
let refreshTokenTTL = this.options.refreshTokenTTL;
|
|
1269
1412
|
const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
|
|
@@ -1325,14 +1468,6 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1325
1468
|
grantData.expiresAt = expiresAt;
|
|
1326
1469
|
}
|
|
1327
1470
|
await this.saveGrantWithTTL(env, grantKey, grantData, now);
|
|
1328
|
-
const originOnly = !!this.options.resourceMatchOriginOnly;
|
|
1329
|
-
if (body.resource && grantData.resource) {
|
|
1330
|
-
const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
|
|
1331
|
-
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
1332
|
-
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", { description: "Requested resource was not included in the authorization request" });
|
|
1333
|
-
}
|
|
1334
|
-
const audience = parseResourceParameter(body.resource || grantData.resource);
|
|
1335
|
-
if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
1336
1471
|
const tokenResponse = {
|
|
1337
1472
|
access_token: await this.createAccessToken({
|
|
1338
1473
|
userId,
|
|
@@ -1382,6 +1517,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1382
1517
|
const now$1 = Math.floor(Date.now() / 1e3);
|
|
1383
1518
|
if (grantData.expiresAt - now$1 < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" });
|
|
1384
1519
|
}
|
|
1520
|
+
const audience = this.resolveTokenResource(body.resource, grantData.resource);
|
|
1385
1521
|
const newAccessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
1386
1522
|
const accessTokenId = await generateTokenId(newAccessToken);
|
|
1387
1523
|
let accessTokenTTL = this.options.accessTokenTTL;
|
|
@@ -1453,14 +1589,6 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1453
1589
|
grantData.refreshTokenId = newRefreshTokenId;
|
|
1454
1590
|
grantData.refreshTokenWrappedKey = newRefreshTokenWrappedKey;
|
|
1455
1591
|
await this.saveGrantWithTTL(env, grantKey, grantData, now);
|
|
1456
|
-
const originOnly = !!this.options.resourceMatchOriginOnly;
|
|
1457
|
-
if (body.resource && grantData.resource) {
|
|
1458
|
-
const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
|
|
1459
|
-
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
1460
|
-
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", { description: "Requested resource was not included in the authorization request" });
|
|
1461
|
-
}
|
|
1462
|
-
const audience = parseResourceParameter(body.resource || grantData.resource);
|
|
1463
|
-
if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
1464
1592
|
const accessTokenData = {
|
|
1465
1593
|
id: accessTokenId,
|
|
1466
1594
|
grantId,
|
|
@@ -1502,7 +1630,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1502
1630
|
* `OAuthProviderImpl` is not exposed outside this module, this is still effectively
|
|
1503
1631
|
* module-private.
|
|
1504
1632
|
* @param subjectToken - The subject token to exchange
|
|
1505
|
-
* @param requestedScopes - Optional
|
|
1633
|
+
* @param requestedScopes - Optional requested scopes, limited to the subject token's scopes
|
|
1506
1634
|
* @param requestedResource - Optional resource/audience (must be subset of original if original had resource)
|
|
1507
1635
|
* @param expiresIn - Optional TTL override in seconds
|
|
1508
1636
|
* @param clientInfo - The client making the exchange request
|
|
@@ -1516,19 +1644,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1516
1644
|
const grantKey = `grant:${tokenSummary.userId}:${tokenSummary.grantId}`;
|
|
1517
1645
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
1518
1646
|
if (!grantData) throw new OAuthError("invalid_grant", { description: "Grant not found" });
|
|
1519
|
-
let tokenScopes = this.downscope(requestedScopes,
|
|
1520
|
-
const
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
if (grantData.resource) {
|
|
1524
|
-
const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
|
|
1525
|
-
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
1526
|
-
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) throw new OAuthError("invalid_target", { description: "Requested resource was not included in the authorization request" });
|
|
1527
|
-
}
|
|
1528
|
-
const parsedResource = parseResourceParameter(requestedResource);
|
|
1529
|
-
if (!parsedResource) throw new OAuthError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
1530
|
-
newAudience = parsedResource;
|
|
1531
|
-
}
|
|
1647
|
+
let tokenScopes = this.downscope(requestedScopes, tokenSummary.scope);
|
|
1648
|
+
const configuredResource = this.options.resourceMetadata?.resource;
|
|
1649
|
+
if (configuredResource && !isExactResource(tokenSummary.audience, configuredResource)) throw new OAuthError("invalid_target", { description: "Subject token is not bound to the configured resource" });
|
|
1650
|
+
const newAudience = this.resolveTokenResource(requestedResource, grantData.resource);
|
|
1532
1651
|
const now = Math.floor(Date.now() / 1e3);
|
|
1533
1652
|
const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
|
|
1534
1653
|
if (subjectTokenRemainingLifetime < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_grant", { description: "Subject token is too close to expiry to exchange" });
|
|
@@ -1566,7 +1685,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1566
1685
|
encryptedAccessTokenProps = tokenResult.encryptedData;
|
|
1567
1686
|
accessTokenEncryptionKey = tokenResult.key;
|
|
1568
1687
|
}
|
|
1569
|
-
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope,
|
|
1688
|
+
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, tokenSummary.scope);
|
|
1570
1689
|
}
|
|
1571
1690
|
}
|
|
1572
1691
|
if (accessTokenTTL < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" });
|
|
@@ -1935,7 +2054,21 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1935
2054
|
statusCode: 400
|
|
1936
2055
|
});
|
|
1937
2056
|
}
|
|
1938
|
-
|
|
2057
|
+
let authMethod;
|
|
2058
|
+
let grantTypes;
|
|
2059
|
+
let responseTypes;
|
|
2060
|
+
try {
|
|
2061
|
+
authMethod = OAuthProviderImpl.validateStringField(clientMetadata.token_endpoint_auth_method) || "client_secret_basic";
|
|
2062
|
+
grantTypes = OAuthProviderImpl.validateStringArray(clientMetadata.grant_types, "grant_types") || [GrantType.AUTHORIZATION_CODE];
|
|
2063
|
+
responseTypes = OAuthProviderImpl.validateStringArray(clientMetadata.response_types, "response_types") || ["code"];
|
|
2064
|
+
validateClientCapabilities(this.serverCapabilities, {
|
|
2065
|
+
tokenEndpointAuthMethod: authMethod,
|
|
2066
|
+
grantTypes,
|
|
2067
|
+
responseTypes
|
|
2068
|
+
});
|
|
2069
|
+
} catch (error) {
|
|
2070
|
+
return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" });
|
|
2071
|
+
}
|
|
1939
2072
|
const isPublicClient = authMethod === "none";
|
|
1940
2073
|
if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" });
|
|
1941
2074
|
const clientId = generateRandomString(16);
|
|
@@ -1961,12 +2094,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1961
2094
|
jwksUri: OAuthProviderImpl.validateOptionalUriField(clientMetadata.jwks_uri, "jwks_uri"),
|
|
1962
2095
|
i18n: OAuthProviderImpl.extractI18nFields(clientMetadata),
|
|
1963
2096
|
contacts: OAuthProviderImpl.validateStringArray(clientMetadata.contacts),
|
|
1964
|
-
grantTypes
|
|
1965
|
-
|
|
1966
|
-
GrantType.REFRESH_TOKEN,
|
|
1967
|
-
...this.options.allowTokenExchangeGrant ? [GrantType.TOKEN_EXCHANGE] : []
|
|
1968
|
-
],
|
|
1969
|
-
responseTypes: OAuthProviderImpl.validateStringArray(clientMetadata.response_types) || ["code"],
|
|
2097
|
+
grantTypes,
|
|
2098
|
+
responseTypes,
|
|
1970
2099
|
registrationDate: Math.floor(Date.now() / 1e3),
|
|
1971
2100
|
tokenEndpointAuthMethod: authMethod
|
|
1972
2101
|
};
|
|
@@ -2038,10 +2167,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2038
2167
|
const url = new URL(request.url);
|
|
2039
2168
|
const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource${url.pathname}`;
|
|
2040
2169
|
const authHeader = request.headers.get("Authorization");
|
|
2041
|
-
if (!authHeader || !authHeader.startsWith("Bearer ")) return
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2170
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) return new Response(null, {
|
|
2171
|
+
status: 401,
|
|
2172
|
+
headers: {
|
|
2173
|
+
...NO_CACHE_HEADERS,
|
|
2174
|
+
"WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl)
|
|
2175
|
+
}
|
|
2045
2176
|
});
|
|
2046
2177
|
const accessToken = authHeader.substring(7);
|
|
2047
2178
|
const parts = accessToken.split(":");
|
|
@@ -2060,6 +2191,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2060
2191
|
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
|
|
2061
2192
|
});
|
|
2062
2193
|
if (tokenData) {
|
|
2194
|
+
const configuredResource = this.options.resourceMetadata?.resource;
|
|
2195
|
+
if (configuredResource && !isExactResource(tokenData.audience, configuredResource)) return this.createErrorResponse("invalid_token", {
|
|
2196
|
+
description: "Access token is not bound to the configured resource",
|
|
2197
|
+
statusCode: 401,
|
|
2198
|
+
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
|
|
2199
|
+
});
|
|
2063
2200
|
const now = Math.floor(Date.now() / 1e3);
|
|
2064
2201
|
if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", {
|
|
2065
2202
|
description: "Access token expired",
|
|
@@ -2068,7 +2205,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2068
2205
|
});
|
|
2069
2206
|
if (tokenData.audience) {
|
|
2070
2207
|
const requestUrl = new URL(request.url);
|
|
2071
|
-
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
|
|
2208
|
+
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
|
|
2072
2209
|
if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
|
|
2073
2210
|
description: "Token audience does not match resource server",
|
|
2074
2211
|
statusCode: 401,
|
|
@@ -2077,19 +2214,32 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2077
2214
|
}
|
|
2078
2215
|
ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
|
|
2079
2216
|
} else if (this.options.resolveExternalToken) {
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2217
|
+
let ext;
|
|
2218
|
+
try {
|
|
2219
|
+
ext = await this.options.resolveExternalToken({
|
|
2220
|
+
token: accessToken,
|
|
2221
|
+
request,
|
|
2222
|
+
env
|
|
2223
|
+
});
|
|
2224
|
+
} catch (error) {
|
|
2225
|
+
const response = this.createExternalTokenErrorResponse(error, resourceMetadataUrl);
|
|
2226
|
+
if (response) return response;
|
|
2227
|
+
throw error;
|
|
2228
|
+
}
|
|
2085
2229
|
if (!ext) return this.createErrorResponse("invalid_token", {
|
|
2086
2230
|
description: "Invalid access token",
|
|
2087
2231
|
statusCode: 401,
|
|
2088
2232
|
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
|
|
2089
2233
|
});
|
|
2234
|
+
const configuredResource = this.options.resourceMetadata?.resource;
|
|
2235
|
+
if (configuredResource && !isExactResource(ext.audience, configuredResource)) return this.createErrorResponse("invalid_token", {
|
|
2236
|
+
description: "External access token is not bound to the configured resource",
|
|
2237
|
+
statusCode: 401,
|
|
2238
|
+
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
|
|
2239
|
+
});
|
|
2090
2240
|
if (ext.audience) {
|
|
2091
2241
|
const requestUrl = new URL(request.url);
|
|
2092
|
-
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
|
|
2242
|
+
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
|
|
2093
2243
|
if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
|
|
2094
2244
|
description: "Token audience does not match resource server",
|
|
2095
2245
|
statusCode: 401,
|
|
@@ -2156,7 +2306,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2156
2306
|
*
|
|
2157
2307
|
* @param env - Cloudflare Worker environment variables
|
|
2158
2308
|
* @param clientId - The client ID to look up (can be a regular ID or an HTTPS URL for CIMD)
|
|
2159
|
-
* @returns The client information, or null if not
|
|
2309
|
+
* @returns The client information, or null if the client does not exist. Null means
|
|
2310
|
+
* definitive absence; failures to determine the answer throw instead (KV errors
|
|
2311
|
+
* propagate, and a CIMD metadata fetch failure throws `CimdFetchError`), so an
|
|
2312
|
+
* upstream outage is distinguishable from an unregistered client.
|
|
2160
2313
|
*/
|
|
2161
2314
|
async getClient(env, clientId) {
|
|
2162
2315
|
if (this.isClientMetadataUrl(clientId)) {
|
|
@@ -2169,13 +2322,34 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2169
2322
|
return await this.fetchClientMetadataDocument(clientId);
|
|
2170
2323
|
} catch (error) {
|
|
2171
2324
|
console.warn(`CIMD fetch failed for ${clientId}:`, error instanceof Error ? error.message : error);
|
|
2172
|
-
|
|
2325
|
+
throw new CimdFetchError(clientId, error);
|
|
2173
2326
|
}
|
|
2174
2327
|
}
|
|
2175
2328
|
const clientKey = `client:${clientId}`;
|
|
2176
2329
|
return env.OAUTH_KV.get(clientKey, { type: "json" });
|
|
2177
2330
|
}
|
|
2178
2331
|
/**
|
|
2332
|
+
* Resolves an access-token audience from a token request and its authorization grant.
|
|
2333
|
+
* Explicit resource configuration requires one exact value in both places. Without
|
|
2334
|
+
* configuration, RFC 8707 downscoping is allowed and omission inherits the grant.
|
|
2335
|
+
*/
|
|
2336
|
+
resolveTokenResource(requestedResource, grantedResource) {
|
|
2337
|
+
const requestedAudience = parseResourceParameter(requestedResource);
|
|
2338
|
+
if (requestedResource && !requestedAudience) throw new OAuthError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
2339
|
+
const grantedAudience = parseResourceParameter(grantedResource);
|
|
2340
|
+
if (grantedResource && !grantedAudience) throw new OAuthError("invalid_target", { description: "The authorization grant contains an invalid resource" });
|
|
2341
|
+
const configuredResource = this.options.resourceMetadata?.resource;
|
|
2342
|
+
if (configuredResource && (!isExactResource(grantedResource, configuredResource) || !isExactResource(requestedResource, configuredResource))) throw new OAuthError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` });
|
|
2343
|
+
if (!configuredResource && requestedResource && !grantedResource) throw new OAuthError("invalid_target", { description: "Requested resource was not included in the authorization request" });
|
|
2344
|
+
const originOnly = configuredResource ? false : !!this.options.resourceMatchOriginOnly;
|
|
2345
|
+
if (requestedResource && grantedResource) {
|
|
2346
|
+
const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
|
|
2347
|
+
const grantedResources = Array.isArray(grantedResource) ? grantedResource : [grantedResource];
|
|
2348
|
+
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) throw new OAuthError("invalid_target", { description: "Requested resource was not included in the authorization request" });
|
|
2349
|
+
}
|
|
2350
|
+
return requestedAudience ?? grantedAudience;
|
|
2351
|
+
}
|
|
2352
|
+
/**
|
|
2179
2353
|
* Creates and stores an access token
|
|
2180
2354
|
* @param params - Options for creating the access token
|
|
2181
2355
|
* @returns The access token string
|
|
@@ -2210,15 +2384,15 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2210
2384
|
return accessToken;
|
|
2211
2385
|
}
|
|
2212
2386
|
/**
|
|
2213
|
-
*
|
|
2214
|
-
*
|
|
2387
|
+
* Restricts requested scopes to the scopes available for the current flow.
|
|
2388
|
+
* If no scope is requested, all available scopes are returned.
|
|
2215
2389
|
* @param requestedScope - The scope parameter from the request (string or array)
|
|
2216
|
-
* @param
|
|
2217
|
-
* @returns The
|
|
2390
|
+
* @param allowedScopes - The maximum scopes available for the current flow
|
|
2391
|
+
* @returns The requested scopes that are included in the allowed scopes
|
|
2218
2392
|
*/
|
|
2219
|
-
downscope(requestedScope,
|
|
2220
|
-
if (!requestedScope) return
|
|
2221
|
-
return (typeof requestedScope === "string" ? requestedScope.split(" ").filter(Boolean) : requestedScope).filter((scope) =>
|
|
2393
|
+
downscope(requestedScope, allowedScopes) {
|
|
2394
|
+
if (!requestedScope) return allowedScopes;
|
|
2395
|
+
return (typeof requestedScope === "string" ? requestedScope.split(" ").filter(Boolean) : requestedScope).filter((scope) => allowedScopes.includes(scope));
|
|
2222
2396
|
}
|
|
2223
2397
|
/**
|
|
2224
2398
|
* Checks if the global_fetch_strictly_public compatibility flag is enabled.
|
|
@@ -2247,7 +2421,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2247
2421
|
this.CIMD_FETCH_TIMEOUT_MS = 1e4;
|
|
2248
2422
|
}
|
|
2249
2423
|
static {
|
|
2250
|
-
this.CIMD_ALLOWED_AUTH_METHODS = ["none"
|
|
2424
|
+
this.CIMD_ALLOWED_AUTH_METHODS = ["none"];
|
|
2251
2425
|
}
|
|
2252
2426
|
/**
|
|
2253
2427
|
* Validates that a field is a string or undefined
|
|
@@ -2364,15 +2538,27 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2364
2538
|
if (contentLength && parseInt(contentLength, 10) > OAuthProviderImpl.CIMD_MAX_SIZE_BYTES) throw new Error(`Client metadata exceeds size limit: ${contentLength} bytes (max ${OAuthProviderImpl.CIMD_MAX_SIZE_BYTES})`);
|
|
2365
2539
|
const rawMetadata = await this.readJsonWithSizeLimit(response, OAuthProviderImpl.CIMD_MAX_SIZE_BYTES);
|
|
2366
2540
|
const clientId = OAuthProviderImpl.validateStringField(rawMetadata.client_id, "client_id");
|
|
2541
|
+
const clientName = OAuthProviderImpl.validateStringField(rawMetadata.client_name, "client_name");
|
|
2367
2542
|
const redirectUris = OAuthProviderImpl.validateStringArray(rawMetadata.redirect_uris, "redirect_uris");
|
|
2368
|
-
const
|
|
2543
|
+
const declaredAuthMethod = OAuthProviderImpl.validateStringField(rawMetadata.token_endpoint_auth_method, "token_endpoint_auth_method");
|
|
2544
|
+
const authMethodChoices = OAuthProviderImpl.validateStringArray(rawMetadata.token_endpoint_auth_methods_supported, "token_endpoint_auth_methods_supported");
|
|
2545
|
+
const tokenEndpointAuthMethod = declaredAuthMethod ?? (authMethodChoices?.includes("none") ? "none" : void 0);
|
|
2369
2546
|
if (clientId !== metadataUrl) throw new Error(`client_id "${clientId}" does not match metadata URL "${metadataUrl}"`);
|
|
2547
|
+
if (!clientName?.trim()) throw new Error("client_name is required and must not be empty");
|
|
2370
2548
|
if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
|
|
2371
|
-
if (
|
|
2549
|
+
if (declaredAuthMethod && !OAuthProviderImpl.CIMD_ALLOWED_AUTH_METHODS.includes(declaredAuthMethod) || authMethodChoices && !authMethodChoices.some((method) => OAuthProviderImpl.CIMD_ALLOWED_AUTH_METHODS.includes(method))) throw new Error(`CIMD client does not support an accepted token endpoint authentication method. Supported methods: ${OAuthProviderImpl.CIMD_ALLOWED_AUTH_METHODS.join(", ")}`);
|
|
2550
|
+
const grantTypes = OAuthProviderImpl.validateStringArray(rawMetadata.grant_types, "grant_types") || [GrantType.AUTHORIZATION_CODE];
|
|
2551
|
+
const responseTypes = OAuthProviderImpl.validateStringArray(rawMetadata.response_types, "response_types") || ["code"];
|
|
2552
|
+
const effectiveAuthMethod = tokenEndpointAuthMethod || "none";
|
|
2553
|
+
validateClientCapabilities(this.serverCapabilities, {
|
|
2554
|
+
tokenEndpointAuthMethod: effectiveAuthMethod,
|
|
2555
|
+
grantTypes,
|
|
2556
|
+
responseTypes
|
|
2557
|
+
});
|
|
2372
2558
|
return {
|
|
2373
2559
|
clientId,
|
|
2374
2560
|
redirectUris,
|
|
2375
|
-
clientName
|
|
2561
|
+
clientName,
|
|
2376
2562
|
clientUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.client_uri, "client_uri"),
|
|
2377
2563
|
logoUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.logo_uri, "logo_uri"),
|
|
2378
2564
|
policyUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.policy_uri, "policy_uri"),
|
|
@@ -2380,9 +2566,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2380
2566
|
jwksUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.jwks_uri, "jwks_uri"),
|
|
2381
2567
|
i18n: OAuthProviderImpl.extractI18nFields(rawMetadata),
|
|
2382
2568
|
contacts: OAuthProviderImpl.validateStringArray(rawMetadata.contacts, "contacts"),
|
|
2383
|
-
grantTypes
|
|
2384
|
-
responseTypes
|
|
2385
|
-
tokenEndpointAuthMethod:
|
|
2569
|
+
grantTypes,
|
|
2570
|
+
responseTypes,
|
|
2571
|
+
tokenEndpointAuthMethod: effectiveAuthMethod
|
|
2386
2572
|
};
|
|
2387
2573
|
} finally {
|
|
2388
2574
|
clearTimeout(timeoutId);
|
|
@@ -2426,8 +2612,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2426
2612
|
/**
|
|
2427
2613
|
* Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
|
|
2428
2614
|
*/
|
|
2429
|
-
buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription) {
|
|
2430
|
-
let header = `Bearer realm="OAuth", resource_metadata="${resourceMetadataUrl}"
|
|
2615
|
+
buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription, requiredScopes = []) {
|
|
2616
|
+
let header = `Bearer realm="OAuth", resource_metadata="${resourceMetadataUrl}"`;
|
|
2617
|
+
if (error) header += `, error="${error}"`;
|
|
2618
|
+
const challengeScopes = requiredScopes.length > 0 ? this.normalizeProtectedResourceScopes(requiredScopes) : this.getProtectedResourceScopes();
|
|
2619
|
+
if (challengeScopes.length > 0) header += `, scope="${challengeScopes.join(" ")}"`;
|
|
2431
2620
|
if (errorDescription) header += `, error_description="${errorDescription}"`;
|
|
2432
2621
|
return header;
|
|
2433
2622
|
}
|
|
@@ -2439,7 +2628,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2439
2628
|
* so the public response stays RFC-compliant and free of information leak
|
|
2440
2629
|
* while the deployer can still observe which check failed.
|
|
2441
2630
|
*/
|
|
2442
|
-
createErrorResponse(code, options, internal) {
|
|
2631
|
+
createErrorResponse(code, options, internal, request) {
|
|
2443
2632
|
const { description } = options;
|
|
2444
2633
|
const responseStatus = options.statusCode ?? 400;
|
|
2445
2634
|
const responseHeaders = {
|
|
@@ -2451,7 +2640,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2451
2640
|
description,
|
|
2452
2641
|
status: responseStatus,
|
|
2453
2642
|
headers: responseHeaders,
|
|
2454
|
-
...internal ? { internal } : {}
|
|
2643
|
+
...internal ? { internal } : {},
|
|
2644
|
+
...request ? { request } : {}
|
|
2455
2645
|
});
|
|
2456
2646
|
if (customErrorResponse) return customErrorResponse;
|
|
2457
2647
|
const body = JSON.stringify({
|
|
@@ -2468,11 +2658,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2468
2658
|
}
|
|
2469
2659
|
};
|
|
2470
2660
|
/**
|
|
2471
|
-
* Structured OAuth 2.0 error.
|
|
2661
|
+
* Structured OAuth 2.0 token-endpoint error.
|
|
2472
2662
|
*
|
|
2473
|
-
* Throw from a `tokenExchangeCallback`
|
|
2474
|
-
*
|
|
2475
|
-
* `/token` error response (`{ error, error_description }`) instead of a
|
|
2663
|
+
* Throw from a `tokenExchangeCallback` or any code it calls to surface a
|
|
2664
|
+
* standard OAuth token response (`{ error, error_description }`) instead of a
|
|
2476
2665
|
* generic `500 Internal Server Error`.
|
|
2477
2666
|
*
|
|
2478
2667
|
* Anything thrown that is **not** an `OAuthError` continues to surface as
|
|
@@ -2522,6 +2711,61 @@ var OAuthError = class extends Error {
|
|
|
2522
2711
|
}
|
|
2523
2712
|
};
|
|
2524
2713
|
/**
|
|
2714
|
+
* Intentional public error from an external bearer-token validator.
|
|
2715
|
+
*
|
|
2716
|
+
* Throw only from `resolveExternalToken` when an expected validation outcome
|
|
2717
|
+
* should become a structured protected-resource response. Ordinary errors and
|
|
2718
|
+
* {@link OAuthError} retain their pre-existing behavior and propagate as
|
|
2719
|
+
* unexpected failures.
|
|
2720
|
+
*/
|
|
2721
|
+
var ExternalTokenError = class extends Error {
|
|
2722
|
+
/**
|
|
2723
|
+
* Creates an intentional external-token validation error.
|
|
2724
|
+
* @param code - Standard OAuth error code to return
|
|
2725
|
+
* @param options - Public response details
|
|
2726
|
+
*/
|
|
2727
|
+
constructor(code, options) {
|
|
2728
|
+
super(options.description);
|
|
2729
|
+
this.name = "ExternalTokenError";
|
|
2730
|
+
this.code = code;
|
|
2731
|
+
this.description = options.description;
|
|
2732
|
+
this.statusCode = options.statusCode;
|
|
2733
|
+
this.headers = options.headers;
|
|
2734
|
+
this.requiredScopes = options.requiredScopes;
|
|
2735
|
+
}
|
|
2736
|
+
};
|
|
2737
|
+
/**
|
|
2738
|
+
* Thrown when fetching a Client ID Metadata Document (CIMD) fails — the
|
|
2739
|
+
* server-to-server fetch errored, timed out, or returned an invalid document.
|
|
2740
|
+
* Distinct from a client that simply does not exist, which is reported as a
|
|
2741
|
+
* null client lookup result.
|
|
2742
|
+
*
|
|
2743
|
+
* At the token endpoint the provider handles this itself: the wire response
|
|
2744
|
+
* stays a generic `invalid_client` / "Client not found", and the failure is
|
|
2745
|
+
* reported through the `onError` hook's `internal` field (category
|
|
2746
|
+
* `client-id-metadata-document`) together with the originating `request`.
|
|
2747
|
+
*
|
|
2748
|
+
* `OAuthHelpers` methods that look up clients (`lookupClient`, and methods
|
|
2749
|
+
* built on it such as `exchangeToken`) let this error propagate to the
|
|
2750
|
+
* caller. Callers that previously relied on a `null` result for these
|
|
2751
|
+
* failures should catch it to preserve their error contract.
|
|
2752
|
+
*/
|
|
2753
|
+
var CimdFetchError = class extends Error {
|
|
2754
|
+
/**
|
|
2755
|
+
* Creates an error for a failed CIMD fetch or validation.
|
|
2756
|
+
* @param metadataUrl - The CIMD URL that could not be resolved
|
|
2757
|
+
* @param cause - The underlying fetch or validation failure
|
|
2758
|
+
*/
|
|
2759
|
+
constructor(metadataUrl, cause) {
|
|
2760
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
2761
|
+
super(`CIMD fetch failed for ${metadataUrl}: ${detail}`);
|
|
2762
|
+
this.reason = "metadata_resolution_failed";
|
|
2763
|
+
this.name = "CimdFetchError";
|
|
2764
|
+
this.metadataUrl = metadataUrl;
|
|
2765
|
+
this.detail = detail;
|
|
2766
|
+
}
|
|
2767
|
+
};
|
|
2768
|
+
/**
|
|
2525
2769
|
* Default expiration time for access tokens (1 hour in seconds)
|
|
2526
2770
|
*/
|
|
2527
2771
|
const DEFAULT_ACCESS_TOKEN_TTL = 3600;
|
|
@@ -2573,10 +2817,6 @@ function getRevokeExistingGrantsBatchSize(batchSize) {
|
|
|
2573
2817
|
*/
|
|
2574
2818
|
const TOKEN_LENGTH = 32;
|
|
2575
2819
|
/**
|
|
2576
|
-
* RFC 6749 Section 3.3 scope-token grammar.
|
|
2577
|
-
*/
|
|
2578
|
-
const OAUTH_SCOPE_TOKEN_PATTERN = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
|
|
2579
|
-
/**
|
|
2580
2820
|
* Validates a resource URI per RFC 8707 Section 2
|
|
2581
2821
|
* @param uri - The URI string to validate
|
|
2582
2822
|
* @returns true if valid, false otherwise
|
|
@@ -2606,6 +2846,7 @@ function audienceMatches(resourceServerUrl, audienceValue) {
|
|
|
2606
2846
|
const resource = new URL(resourceServerUrl);
|
|
2607
2847
|
const audience = new URL(audienceValue);
|
|
2608
2848
|
if (resource.origin !== audience.origin) return false;
|
|
2849
|
+
if (audience.search && resource.search !== audience.search) return false;
|
|
2609
2850
|
if (audience.pathname === "/" || audience.pathname === "") return true;
|
|
2610
2851
|
return resource.pathname === audience.pathname || resource.pathname.startsWith(audience.pathname + "/");
|
|
2611
2852
|
} catch {
|
|
@@ -2624,6 +2865,10 @@ function parseResourceParameter(value) {
|
|
|
2624
2865
|
for (const uri of uris) if (typeof uri !== "string" || !validateResourceUri(uri)) return;
|
|
2625
2866
|
return value;
|
|
2626
2867
|
}
|
|
2868
|
+
/** Whether a request or audience names one exact configured resource. */
|
|
2869
|
+
function isExactResource(value, configuredResource) {
|
|
2870
|
+
return value === configuredResource || Array.isArray(value) && value.length === 1 && value[0] === configuredResource;
|
|
2871
|
+
}
|
|
2627
2872
|
/**
|
|
2628
2873
|
* Checks if a requested resource matches a granted resource.
|
|
2629
2874
|
* When originOnly is true, compares only the origin (scheme + host + port),
|
|
@@ -2646,6 +2891,29 @@ async function hashSecret(secret) {
|
|
|
2646
2891
|
return generateTokenId(secret);
|
|
2647
2892
|
}
|
|
2648
2893
|
/**
|
|
2894
|
+
* Parses RFC 6749 HTTP Basic client credentials.
|
|
2895
|
+
*/
|
|
2896
|
+
function parseBasicAuthorizationHeader(header) {
|
|
2897
|
+
if (!header) return { kind: "not-basic" };
|
|
2898
|
+
const schemeEnd = header.search(/[ \t]/);
|
|
2899
|
+
if ((schemeEnd === -1 ? header : header.slice(0, schemeEnd)).toLowerCase() !== "basic") return { kind: "not-basic" };
|
|
2900
|
+
if (schemeEnd === -1) return { kind: "malformed" };
|
|
2901
|
+
const encodedCredentials = header.slice(schemeEnd).trim();
|
|
2902
|
+
if (!encodedCredentials || /[ \t]/.test(encodedCredentials)) return { kind: "malformed" };
|
|
2903
|
+
try {
|
|
2904
|
+
const credentials = atob(encodedCredentials);
|
|
2905
|
+
const separatorIndex = credentials.indexOf(":");
|
|
2906
|
+
if (separatorIndex === -1) return { kind: "malformed" };
|
|
2907
|
+
return {
|
|
2908
|
+
kind: "credentials",
|
|
2909
|
+
clientId: decodeFormUrlEncodedComponent(credentials.slice(0, separatorIndex)),
|
|
2910
|
+
clientSecret: decodeFormUrlEncodedComponent(credentials.slice(separatorIndex + 1))
|
|
2911
|
+
};
|
|
2912
|
+
} catch {
|
|
2913
|
+
return { kind: "malformed" };
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
/**
|
|
2649
2917
|
* Decodes an application/x-www-form-urlencoded component.
|
|
2650
2918
|
* @param value - The encoded component value
|
|
2651
2919
|
* @returns The decoded component value
|
|
@@ -2769,9 +3037,6 @@ function parseJwtJsonPart(encoded) {
|
|
|
2769
3037
|
throw new Error("Malformed JWT part");
|
|
2770
3038
|
}
|
|
2771
3039
|
}
|
|
2772
|
-
function isValidOAuthScopeToken(scopeToken) {
|
|
2773
|
-
return OAUTH_SCOPE_TOKEN_PATTERN.test(scopeToken);
|
|
2774
|
-
}
|
|
2775
3040
|
/**
|
|
2776
3041
|
* Gets WebCrypto import and verify parameters for supported JOSE algorithms.
|
|
2777
3042
|
*/
|
|
@@ -2943,6 +3208,8 @@ var OAuthHelpersImpl = class {
|
|
|
2943
3208
|
* Parses an OAuth authorization request from the HTTP request
|
|
2944
3209
|
* @param request - The HTTP request containing OAuth parameters
|
|
2945
3210
|
* @returns The parsed authorization request parameters
|
|
3211
|
+
* @throws Error when the response type is missing, unsupported, or not registered for the client
|
|
3212
|
+
* @throws CimdFetchError when the client ID is a CIMD URL whose document cannot be resolved
|
|
2946
3213
|
*/
|
|
2947
3214
|
async parseAuthRequest(request) {
|
|
2948
3215
|
const url = new URL(request.url);
|
|
@@ -2952,20 +3219,26 @@ var OAuthHelpersImpl = class {
|
|
|
2952
3219
|
const scope = (url.searchParams.get("scope") || "").split(" ").filter(Boolean);
|
|
2953
3220
|
const state = url.searchParams.get("state") || "";
|
|
2954
3221
|
const codeChallenge = url.searchParams.get("code_challenge") || void 0;
|
|
2955
|
-
const codeChallengeMethod = url.searchParams.get("code_challenge_method") ||
|
|
3222
|
+
const codeChallengeMethod = url.searchParams.get("code_challenge_method") || void 0;
|
|
3223
|
+
const issuer = this.provider.getAuthorizationServerIssuer(url);
|
|
2956
3224
|
const resourceParams = url.searchParams.getAll("resource");
|
|
2957
3225
|
const resourceParam = resourceParams.length > 0 ? resourceParams.length === 1 ? resourceParams[0] : resourceParams : void 0;
|
|
2958
3226
|
validateRedirectUriScheme(redirectUri);
|
|
2959
|
-
|
|
3227
|
+
let resource = parseResourceParameter(resourceParam);
|
|
2960
3228
|
if (resourceParam && !resource) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
|
|
2961
|
-
|
|
2962
|
-
if (
|
|
3229
|
+
const configuredResource = this.provider.options.resourceMetadata?.resource;
|
|
3230
|
+
if (configuredResource && !isExactResource(resource, configuredResource)) throw new Error(`The resource parameter must exactly match ${configuredResource}`);
|
|
3231
|
+
resource ??= url.origin;
|
|
2963
3232
|
if (clientId) {
|
|
2964
3233
|
const clientInfo = await this.lookupClient(clientId);
|
|
2965
3234
|
if (!clientInfo) throw new Error(`Invalid client. The clientId provided does not match to this client.`);
|
|
2966
|
-
if (
|
|
2967
|
-
|
|
2968
|
-
|
|
3235
|
+
if (!redirectUri || !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) throw new Error(`Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.`);
|
|
3236
|
+
validateAuthorizationResponseType(this.provider.serverCapabilities, responseType, clientInfo.responseTypes);
|
|
3237
|
+
validateAuthorizationPkce(this.provider.serverCapabilities, {
|
|
3238
|
+
responseType,
|
|
3239
|
+
codeChallenge,
|
|
3240
|
+
codeChallengeMethod
|
|
3241
|
+
}, clientInfo);
|
|
2969
3242
|
}
|
|
2970
3243
|
return {
|
|
2971
3244
|
responseType,
|
|
@@ -2975,13 +3248,20 @@ var OAuthHelpersImpl = class {
|
|
|
2975
3248
|
state,
|
|
2976
3249
|
codeChallenge,
|
|
2977
3250
|
codeChallengeMethod,
|
|
2978
|
-
resource
|
|
3251
|
+
resource,
|
|
3252
|
+
issuer
|
|
2979
3253
|
};
|
|
2980
3254
|
}
|
|
2981
3255
|
/**
|
|
2982
3256
|
* Looks up a client by its client ID
|
|
2983
3257
|
* @param clientId - The client ID to look up
|
|
2984
|
-
* @returns A Promise resolving to the client info, or null if not
|
|
3258
|
+
* @returns A Promise resolving to the client info, or null if the client does not
|
|
3259
|
+
* exist. Null means definitive absence; failures to determine the answer throw
|
|
3260
|
+
* instead (KV errors propagate, and a CIMD metadata fetch failure throws
|
|
3261
|
+
* `CimdFetchError`), so an upstream outage cannot masquerade as an unregistered
|
|
3262
|
+
* client.
|
|
3263
|
+
* @throws CimdFetchError when the client ID is a CIMD URL and fetching or
|
|
3264
|
+
* validating the metadata document fails.
|
|
2985
3265
|
*/
|
|
2986
3266
|
async lookupClient(clientId) {
|
|
2987
3267
|
return await this.provider.getClient(this.env, clientId);
|
|
@@ -2992,12 +3272,19 @@ var OAuthHelpersImpl = class {
|
|
|
2992
3272
|
* - For implicit flow: generating an access token directly
|
|
2993
3273
|
* @param options - Options specifying the grant details
|
|
2994
3274
|
* @returns A Promise resolving to an object containing the redirect URL
|
|
3275
|
+
* @throws Error when the request's response type is not permitted
|
|
3276
|
+
* @throws CimdFetchError when the client ID is a CIMD URL whose document cannot be resolved
|
|
2995
3277
|
*/
|
|
2996
3278
|
async completeAuthorization(options) {
|
|
2997
3279
|
const { clientId, redirectUri } = options.request;
|
|
2998
3280
|
if (!clientId || !redirectUri) throw new Error("Client ID and Redirect URI are required in the authorization request.");
|
|
2999
3281
|
const clientInfo = await this.lookupClient(clientId);
|
|
3000
3282
|
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.");
|
|
3283
|
+
validateAuthorizationResponseType(this.provider.serverCapabilities, options.request.responseType, clientInfo.responseTypes);
|
|
3284
|
+
const configuredResource = this.provider.options.resourceMetadata?.resource;
|
|
3285
|
+
if (configuredResource && !isExactResource(options.request.resource, configuredResource)) throw new Error(`The resource parameter must exactly match ${configuredResource}`);
|
|
3286
|
+
const effectiveResource = options.request.resource ?? options.request.issuer;
|
|
3287
|
+
validateAuthorizationPkce(this.provider.serverCapabilities, options.request, clientInfo);
|
|
3001
3288
|
let grantsToRevoke = [];
|
|
3002
3289
|
if (options.revokeExistingGrants !== false) {
|
|
3003
3290
|
const batchSize = getRevokeExistingGrantsBatchSize(options.revokeExistingGrantsBatchSize);
|
|
@@ -3021,8 +3308,8 @@ var OAuthHelpersImpl = class {
|
|
|
3021
3308
|
const accessTokenTTL = this.provider.options.accessTokenTTL || DEFAULT_ACCESS_TOKEN_TTL;
|
|
3022
3309
|
const accessTokenExpiresAt = now + accessTokenTTL;
|
|
3023
3310
|
const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, encryptionKey);
|
|
3024
|
-
const audience = parseResourceParameter(
|
|
3025
|
-
if (
|
|
3311
|
+
const audience = parseResourceParameter(effectiveResource);
|
|
3312
|
+
if (effectiveResource && !audience) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
|
|
3026
3313
|
const grant = {
|
|
3027
3314
|
id: grantId,
|
|
3028
3315
|
clientId: options.request.clientId,
|
|
@@ -3031,7 +3318,7 @@ var OAuthHelpersImpl = class {
|
|
|
3031
3318
|
metadata: options.metadata,
|
|
3032
3319
|
encryptedProps: encryptedData,
|
|
3033
3320
|
createdAt: now,
|
|
3034
|
-
resource:
|
|
3321
|
+
resource: effectiveResource
|
|
3035
3322
|
};
|
|
3036
3323
|
const grantKey = `grant:${options.userId}:${grantId}`;
|
|
3037
3324
|
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant));
|
|
@@ -3058,6 +3345,7 @@ var OAuthHelpersImpl = class {
|
|
|
3058
3345
|
fragment.set("expires_in", accessTokenTTL.toString());
|
|
3059
3346
|
fragment.set("scope", options.scope.join(" "));
|
|
3060
3347
|
if (options.request.state) fragment.set("state", options.request.state);
|
|
3348
|
+
if (options.request.issuer) fragment.set("iss", options.request.issuer);
|
|
3061
3349
|
redirectUrl.hash = fragment.toString();
|
|
3062
3350
|
try {
|
|
3063
3351
|
await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
|
|
@@ -3080,13 +3368,14 @@ var OAuthHelpersImpl = class {
|
|
|
3080
3368
|
authCodeWrappedKey,
|
|
3081
3369
|
codeChallenge: options.request.codeChallenge,
|
|
3082
3370
|
codeChallengeMethod: options.request.codeChallengeMethod,
|
|
3083
|
-
resource:
|
|
3371
|
+
resource: effectiveResource
|
|
3084
3372
|
};
|
|
3085
3373
|
const grantKey = `grant:${options.userId}:${grantId}`;
|
|
3086
3374
|
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), { expirationTtl: 600 });
|
|
3087
3375
|
const redirectUrl = new URL(options.request.redirectUri);
|
|
3088
3376
|
redirectUrl.searchParams.set("code", authCode);
|
|
3089
3377
|
if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
|
|
3378
|
+
if (options.request.issuer) redirectUrl.searchParams.set("iss", options.request.issuer);
|
|
3090
3379
|
try {
|
|
3091
3380
|
await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
|
|
3092
3381
|
} catch {}
|
|
@@ -3279,6 +3568,7 @@ var OAuthHelpersImpl = class {
|
|
|
3279
3568
|
* Implements OAuth 2.0 Token Exchange (RFC 8693)
|
|
3280
3569
|
* @param options - Options for token exchange including subject token and optional modifications
|
|
3281
3570
|
* @returns Promise resolving to token response with new access token
|
|
3571
|
+
* @throws CimdFetchError when the grant's client ID is a CIMD URL whose document cannot be resolved
|
|
3282
3572
|
*/
|
|
3283
3573
|
async exchangeToken(options) {
|
|
3284
3574
|
const tokenSummary = await this.unwrapToken(options.subjectToken);
|
|
@@ -3381,4 +3671,4 @@ var OAuthHelpersImpl = class {
|
|
|
3381
3671
|
var oauth_provider_default = OAuthProvider;
|
|
3382
3672
|
|
|
3383
3673
|
//#endregion
|
|
3384
|
-
export { GrantType, OAuthError, OAuthProvider, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
|
|
3674
|
+
export { CimdFetchError, ExternalTokenError, GrantType, OAuthError, OAuthProvider, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
|