@cloudflare/workers-oauth-provider 0.8.3 → 0.9.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 +356 -451
- package/dist/oauth-provider.d.ts +140 -29
- package/dist/oauth-provider.js +430 -149
- 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
|
|
@@ -1016,55 +1122,50 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1016
1122
|
});
|
|
1017
1123
|
body[key] = allValues.length > 1 ? allValues : value;
|
|
1018
1124
|
}
|
|
1019
|
-
const
|
|
1125
|
+
const basicAuthorization = parseBasicAuthorizationHeader(request.headers.get("Authorization"));
|
|
1126
|
+
const basicAuthenticationAttempted = basicAuthorization.kind !== "not-basic";
|
|
1020
1127
|
let clientId = "";
|
|
1021
1128
|
let clientSecret = "";
|
|
1022
|
-
if (
|
|
1023
|
-
if (
|
|
1129
|
+
if (basicAuthenticationAttempted) {
|
|
1130
|
+
if (formData.has("client_id") || formData.has("client_secret")) return this.createErrorResponse("invalid_request", {
|
|
1024
1131
|
description: "Client must not use multiple authentication methods",
|
|
1025
1132
|
statusCode: 400
|
|
1026
1133
|
});
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
description: "Client authentication failed: invalid Basic credentials",
|
|
1031
|
-
statusCode: 401
|
|
1032
|
-
});
|
|
1033
|
-
const id = credentials.substring(0, separatorIndex);
|
|
1034
|
-
const secret = credentials.substring(separatorIndex + 1);
|
|
1035
|
-
clientId = decodeFormUrlEncodedComponent(id);
|
|
1036
|
-
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;
|
|
1037
1137
|
} else {
|
|
1038
1138
|
clientId = body.client_id;
|
|
1039
1139
|
clientSecret = body.client_secret || "";
|
|
1040
1140
|
}
|
|
1041
|
-
if (!clientId) return this.
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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);
|
|
1063
1163
|
}
|
|
1164
|
+
const isRevocationRequest = !body.grant_type && !!body.token;
|
|
1064
1165
|
return {
|
|
1065
1166
|
body,
|
|
1066
1167
|
clientInfo,
|
|
1067
|
-
isRevocationRequest
|
|
1168
|
+
isRevocationRequest
|
|
1068
1169
|
};
|
|
1069
1170
|
}
|
|
1070
1171
|
/**
|
|
@@ -1130,6 +1231,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1130
1231
|
newResponse.headers.set("Access-Control-Allow-Origin", origin);
|
|
1131
1232
|
newResponse.headers.set("Access-Control-Allow-Methods", "*");
|
|
1132
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(", "));
|
|
1133
1237
|
newResponse.headers.set("Access-Control-Max-Age", "86400");
|
|
1134
1238
|
return newResponse;
|
|
1135
1239
|
}
|
|
@@ -1144,15 +1248,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1144
1248
|
const authorizeEndpoint = this.getFullEndpointUrl(this.options.authorizeEndpoint, requestUrl);
|
|
1145
1249
|
let registrationEndpoint = void 0;
|
|
1146
1250
|
if (this.options.clientRegistrationEndpoint) registrationEndpoint = this.getFullEndpointUrl(this.options.clientRegistrationEndpoint, requestUrl);
|
|
1147
|
-
const responseTypesSupported =
|
|
1148
|
-
|
|
1149
|
-
const
|
|
1150
|
-
if (this.options.allowTokenExchangeGrant) grantTypesSupported.push(GrantType.TOKEN_EXCHANGE);
|
|
1151
|
-
const authorizationGrantProfilesSupported = [];
|
|
1152
|
-
if (this.options.enterpriseManagedAuthorization) {
|
|
1153
|
-
grantTypesSupported.push(GrantType.JWT_BEARER);
|
|
1154
|
-
authorizationGrantProfilesSupported.push(EMA_ID_JAG_GRANT_PROFILE);
|
|
1155
|
-
}
|
|
1251
|
+
const responseTypesSupported = this.serverCapabilities.responseTypes;
|
|
1252
|
+
const grantTypesSupported = this.serverCapabilities.grantTypes;
|
|
1253
|
+
const authorizationGrantProfilesSupported = this.options.enterpriseManagedAuthorization ? [EMA_ID_JAG_GRANT_PROFILE] : [];
|
|
1156
1254
|
const metadata = {
|
|
1157
1255
|
issuer: new URL(tokenEndpoint).origin,
|
|
1158
1256
|
authorization_endpoint: authorizeEndpoint,
|
|
@@ -1163,17 +1261,22 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1163
1261
|
response_modes_supported: this.options.allowImplicitFlow ? ["query", "fragment"] : ["query"],
|
|
1164
1262
|
grant_types_supported: grantTypesSupported,
|
|
1165
1263
|
...authorizationGrantProfilesSupported.length > 0 ? { authorization_grant_profiles_supported: authorizationGrantProfilesSupported } : {},
|
|
1166
|
-
token_endpoint_auth_methods_supported:
|
|
1167
|
-
"client_secret_basic",
|
|
1168
|
-
"client_secret_post",
|
|
1169
|
-
"none"
|
|
1170
|
-
],
|
|
1264
|
+
token_endpoint_auth_methods_supported: this.serverCapabilities.tokenEndpointAuthMethods,
|
|
1171
1265
|
revocation_endpoint: tokenEndpoint,
|
|
1172
|
-
code_challenge_methods_supported: this.
|
|
1266
|
+
code_challenge_methods_supported: this.serverCapabilities.codeChallengeMethods,
|
|
1267
|
+
authorization_response_iss_parameter_supported: true,
|
|
1173
1268
|
client_id_metadata_document_supported: !!this.options.clientIdMetadataDocumentEnabled && this.hasGlobalFetchStrictlyPublic()
|
|
1174
1269
|
};
|
|
1175
1270
|
return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
|
|
1176
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
|
+
}
|
|
1177
1280
|
/**
|
|
1178
1281
|
* Handles the OAuth Protected Resource Metadata endpoint
|
|
1179
1282
|
* Implements RFC 9728 for OAuth Protected Resource Metadata
|
|
@@ -1184,10 +1287,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1184
1287
|
const rm = this.options.resourceMetadata;
|
|
1185
1288
|
const tokenEndpointUrl = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
|
|
1186
1289
|
const authServerOrigin = new URL(tokenEndpointUrl).origin;
|
|
1290
|
+
const resourceScopes = this.getProtectedResourceScopes();
|
|
1187
1291
|
const metadata = {
|
|
1188
1292
|
resource: rm?.resource ?? this.deriveResourceIdentifier(requestUrl),
|
|
1189
1293
|
authorization_servers: rm?.authorization_servers ?? [authServerOrigin],
|
|
1190
|
-
scopes_supported:
|
|
1294
|
+
...resourceScopes.length > 0 ? { scopes_supported: resourceScopes } : {},
|
|
1191
1295
|
bearer_methods_supported: rm?.bearer_methods_supported ?? ["header"]
|
|
1192
1296
|
};
|
|
1193
1297
|
if (rm?.resource_name) metadata.resource_name = rm.resource_name;
|
|
@@ -1216,20 +1320,44 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1216
1320
|
}
|
|
1217
1321
|
}
|
|
1218
1322
|
/**
|
|
1219
|
-
* Build a structured OAuth
|
|
1323
|
+
* Build a structured OAuth token-endpoint response from an OAuth error.
|
|
1220
1324
|
*
|
|
1221
|
-
* The supported form is throwing this package's exported `OAuthError
|
|
1222
|
-
* Anything else is re-thrown so
|
|
1223
|
-
*
|
|
1224
|
-
* Use `headers['Retry-After']` for rate-limit / transient-failure backoff
|
|
1225
|
-
* hints (see RFC 7231 §7.1.3 — either an integer seconds value or an
|
|
1226
|
-
* HTTP-date is allowed).
|
|
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.
|
|
1227
1328
|
*/
|
|
1228
1329
|
createOAuthErrorResponse(error) {
|
|
1229
1330
|
if (!(error instanceof OAuthError)) return void 0;
|
|
1230
1331
|
return this.createErrorResponse(error.code, error.options);
|
|
1231
1332
|
}
|
|
1232
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
|
+
/**
|
|
1233
1361
|
* Handles the authorization code grant type
|
|
1234
1362
|
* Exchanges an authorization code for access and refresh tokens
|
|
1235
1363
|
* @param body - The parsed request body
|
|
@@ -1257,6 +1385,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1257
1385
|
} catch {}
|
|
1258
1386
|
return this.createErrorResponse("invalid_grant", { description: "Authorization code already used" });
|
|
1259
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
|
+
}
|
|
1260
1394
|
const isPkceEnabled = !!grantData.codeChallenge;
|
|
1261
1395
|
if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" });
|
|
1262
1396
|
if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", { description: "Invalid redirect URI" });
|
|
@@ -1264,7 +1398,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1264
1398
|
if (isPkceEnabled) {
|
|
1265
1399
|
if (!codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier is required for PKCE" });
|
|
1266
1400
|
let calculatedChallenge;
|
|
1267
|
-
if (
|
|
1401
|
+
if (codeChallengeMethod === "S256") {
|
|
1268
1402
|
const data = new TextEncoder().encode(codeVerifier);
|
|
1269
1403
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
1270
1404
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
@@ -1272,14 +1406,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1272
1406
|
} else calculatedChallenge = codeVerifier;
|
|
1273
1407
|
if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" });
|
|
1274
1408
|
}
|
|
1275
|
-
const
|
|
1276
|
-
if (body.resource && grantData.resource) {
|
|
1277
|
-
const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
|
|
1278
|
-
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
1279
|
-
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" });
|
|
1280
|
-
}
|
|
1281
|
-
const audience = parseResourceParameter(body.resource || grantData.resource);
|
|
1282
|
-
if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
1409
|
+
const audience = this.resolveTokenResource(body.resource, grantData.resource);
|
|
1283
1410
|
let accessTokenTTL = this.options.accessTokenTTL;
|
|
1284
1411
|
let refreshTokenTTL = this.options.refreshTokenTTL;
|
|
1285
1412
|
const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
|
|
@@ -1390,6 +1517,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1390
1517
|
const now$1 = Math.floor(Date.now() / 1e3);
|
|
1391
1518
|
if (grantData.expiresAt - now$1 < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" });
|
|
1392
1519
|
}
|
|
1520
|
+
const audience = this.resolveTokenResource(body.resource, grantData.resource);
|
|
1393
1521
|
const newAccessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
1394
1522
|
const accessTokenId = await generateTokenId(newAccessToken);
|
|
1395
1523
|
let accessTokenTTL = this.options.accessTokenTTL;
|
|
@@ -1461,14 +1589,6 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1461
1589
|
grantData.refreshTokenId = newRefreshTokenId;
|
|
1462
1590
|
grantData.refreshTokenWrappedKey = newRefreshTokenWrappedKey;
|
|
1463
1591
|
await this.saveGrantWithTTL(env, grantKey, grantData, now);
|
|
1464
|
-
const originOnly = !!this.options.resourceMatchOriginOnly;
|
|
1465
|
-
if (body.resource && grantData.resource) {
|
|
1466
|
-
const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
|
|
1467
|
-
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
1468
|
-
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" });
|
|
1469
|
-
}
|
|
1470
|
-
const audience = parseResourceParameter(body.resource || grantData.resource);
|
|
1471
|
-
if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
1472
1592
|
const accessTokenData = {
|
|
1473
1593
|
id: accessTokenId,
|
|
1474
1594
|
grantId,
|
|
@@ -1525,18 +1645,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1525
1645
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
1526
1646
|
if (!grantData) throw new OAuthError("invalid_grant", { description: "Grant not found" });
|
|
1527
1647
|
let tokenScopes = this.downscope(requestedScopes, tokenSummary.scope);
|
|
1528
|
-
const
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
if (grantData.resource) {
|
|
1532
|
-
const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
|
|
1533
|
-
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
1534
|
-
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" });
|
|
1535
|
-
}
|
|
1536
|
-
const parsedResource = parseResourceParameter(requestedResource);
|
|
1537
|
-
if (!parsedResource) throw new OAuthError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
1538
|
-
newAudience = parsedResource;
|
|
1539
|
-
}
|
|
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);
|
|
1540
1651
|
const now = Math.floor(Date.now() / 1e3);
|
|
1541
1652
|
const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
|
|
1542
1653
|
if (subjectTokenRemainingLifetime < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_grant", { description: "Subject token is too close to expiry to exchange" });
|
|
@@ -1943,7 +2054,21 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1943
2054
|
statusCode: 400
|
|
1944
2055
|
});
|
|
1945
2056
|
}
|
|
1946
|
-
|
|
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
|
+
}
|
|
1947
2072
|
const isPublicClient = authMethod === "none";
|
|
1948
2073
|
if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" });
|
|
1949
2074
|
const clientId = generateRandomString(16);
|
|
@@ -1969,12 +2094,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1969
2094
|
jwksUri: OAuthProviderImpl.validateOptionalUriField(clientMetadata.jwks_uri, "jwks_uri"),
|
|
1970
2095
|
i18n: OAuthProviderImpl.extractI18nFields(clientMetadata),
|
|
1971
2096
|
contacts: OAuthProviderImpl.validateStringArray(clientMetadata.contacts),
|
|
1972
|
-
grantTypes
|
|
1973
|
-
|
|
1974
|
-
GrantType.REFRESH_TOKEN,
|
|
1975
|
-
...this.options.allowTokenExchangeGrant ? [GrantType.TOKEN_EXCHANGE] : []
|
|
1976
|
-
],
|
|
1977
|
-
responseTypes: OAuthProviderImpl.validateStringArray(clientMetadata.response_types) || ["code"],
|
|
2097
|
+
grantTypes,
|
|
2098
|
+
responseTypes,
|
|
1978
2099
|
registrationDate: Math.floor(Date.now() / 1e3),
|
|
1979
2100
|
tokenEndpointAuthMethod: authMethod
|
|
1980
2101
|
};
|
|
@@ -2016,7 +2137,6 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2016
2137
|
grant_types: clientInfo.grantTypes,
|
|
2017
2138
|
response_types: clientInfo.responseTypes,
|
|
2018
2139
|
token_endpoint_auth_method: clientInfo.tokenEndpointAuthMethod,
|
|
2019
|
-
registration_client_uri: `${this.options.clientRegistrationEndpoint}/${clientId}`,
|
|
2020
2140
|
client_id_issued_at: clientInfo.registrationDate
|
|
2021
2141
|
};
|
|
2022
2142
|
if (clientInfo.i18n) {
|
|
@@ -2046,10 +2166,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2046
2166
|
const url = new URL(request.url);
|
|
2047
2167
|
const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource${url.pathname}`;
|
|
2048
2168
|
const authHeader = request.headers.get("Authorization");
|
|
2049
|
-
if (!authHeader || !authHeader.startsWith("Bearer ")) return
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2169
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) return new Response(null, {
|
|
2170
|
+
status: 401,
|
|
2171
|
+
headers: {
|
|
2172
|
+
...NO_CACHE_HEADERS,
|
|
2173
|
+
"WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl)
|
|
2174
|
+
}
|
|
2053
2175
|
});
|
|
2054
2176
|
const accessToken = authHeader.substring(7);
|
|
2055
2177
|
const parts = accessToken.split(":");
|
|
@@ -2068,6 +2190,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2068
2190
|
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
|
|
2069
2191
|
});
|
|
2070
2192
|
if (tokenData) {
|
|
2193
|
+
const configuredResource = this.options.resourceMetadata?.resource;
|
|
2194
|
+
if (configuredResource && !isExactResource(tokenData.audience, configuredResource)) return this.createErrorResponse("invalid_token", {
|
|
2195
|
+
description: "Access token is not bound to the configured resource",
|
|
2196
|
+
statusCode: 401,
|
|
2197
|
+
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
|
|
2198
|
+
});
|
|
2071
2199
|
const now = Math.floor(Date.now() / 1e3);
|
|
2072
2200
|
if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", {
|
|
2073
2201
|
description: "Access token expired",
|
|
@@ -2076,7 +2204,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2076
2204
|
});
|
|
2077
2205
|
if (tokenData.audience) {
|
|
2078
2206
|
const requestUrl = new URL(request.url);
|
|
2079
|
-
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
|
|
2207
|
+
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
|
|
2080
2208
|
if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
|
|
2081
2209
|
description: "Token audience does not match resource server",
|
|
2082
2210
|
statusCode: 401,
|
|
@@ -2085,19 +2213,32 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2085
2213
|
}
|
|
2086
2214
|
ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
|
|
2087
2215
|
} else if (this.options.resolveExternalToken) {
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2216
|
+
let ext;
|
|
2217
|
+
try {
|
|
2218
|
+
ext = await this.options.resolveExternalToken({
|
|
2219
|
+
token: accessToken,
|
|
2220
|
+
request,
|
|
2221
|
+
env
|
|
2222
|
+
});
|
|
2223
|
+
} catch (error) {
|
|
2224
|
+
const response = this.createExternalTokenErrorResponse(error, resourceMetadataUrl);
|
|
2225
|
+
if (response) return response;
|
|
2226
|
+
throw error;
|
|
2227
|
+
}
|
|
2093
2228
|
if (!ext) return this.createErrorResponse("invalid_token", {
|
|
2094
2229
|
description: "Invalid access token",
|
|
2095
2230
|
statusCode: 401,
|
|
2096
2231
|
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
|
|
2097
2232
|
});
|
|
2233
|
+
const configuredResource = this.options.resourceMetadata?.resource;
|
|
2234
|
+
if (configuredResource && !isExactResource(ext.audience, configuredResource)) return this.createErrorResponse("invalid_token", {
|
|
2235
|
+
description: "External access token is not bound to the configured resource",
|
|
2236
|
+
statusCode: 401,
|
|
2237
|
+
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
|
|
2238
|
+
});
|
|
2098
2239
|
if (ext.audience) {
|
|
2099
2240
|
const requestUrl = new URL(request.url);
|
|
2100
|
-
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
|
|
2241
|
+
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
|
|
2101
2242
|
if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
|
|
2102
2243
|
description: "Token audience does not match resource server",
|
|
2103
2244
|
statusCode: 401,
|
|
@@ -2164,7 +2305,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2164
2305
|
*
|
|
2165
2306
|
* @param env - Cloudflare Worker environment variables
|
|
2166
2307
|
* @param clientId - The client ID to look up (can be a regular ID or an HTTPS URL for CIMD)
|
|
2167
|
-
* @returns The client information, or null if not
|
|
2308
|
+
* @returns The client information, or null if the client does not exist. Null means
|
|
2309
|
+
* definitive absence; failures to determine the answer throw instead (KV errors
|
|
2310
|
+
* propagate, and a CIMD metadata fetch failure throws `CimdFetchError`), so an
|
|
2311
|
+
* upstream outage is distinguishable from an unregistered client.
|
|
2168
2312
|
*/
|
|
2169
2313
|
async getClient(env, clientId) {
|
|
2170
2314
|
if (this.isClientMetadataUrl(clientId)) {
|
|
@@ -2177,13 +2321,34 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2177
2321
|
return await this.fetchClientMetadataDocument(clientId);
|
|
2178
2322
|
} catch (error) {
|
|
2179
2323
|
console.warn(`CIMD fetch failed for ${clientId}:`, error instanceof Error ? error.message : error);
|
|
2180
|
-
|
|
2324
|
+
throw new CimdFetchError(clientId, error);
|
|
2181
2325
|
}
|
|
2182
2326
|
}
|
|
2183
2327
|
const clientKey = `client:${clientId}`;
|
|
2184
2328
|
return env.OAUTH_KV.get(clientKey, { type: "json" });
|
|
2185
2329
|
}
|
|
2186
2330
|
/**
|
|
2331
|
+
* Resolves an access-token audience from a token request and its authorization grant.
|
|
2332
|
+
* Explicit resource configuration requires one exact value in both places. Without
|
|
2333
|
+
* configuration, RFC 8707 downscoping is allowed and omission inherits the grant.
|
|
2334
|
+
*/
|
|
2335
|
+
resolveTokenResource(requestedResource, grantedResource) {
|
|
2336
|
+
const requestedAudience = parseResourceParameter(requestedResource);
|
|
2337
|
+
if (requestedResource && !requestedAudience) throw new OAuthError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
2338
|
+
const grantedAudience = parseResourceParameter(grantedResource);
|
|
2339
|
+
if (grantedResource && !grantedAudience) throw new OAuthError("invalid_target", { description: "The authorization grant contains an invalid resource" });
|
|
2340
|
+
const configuredResource = this.options.resourceMetadata?.resource;
|
|
2341
|
+
if (configuredResource && (!isExactResource(grantedResource, configuredResource) || !isExactResource(requestedResource, configuredResource))) throw new OAuthError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` });
|
|
2342
|
+
if (!configuredResource && requestedResource && !grantedResource) throw new OAuthError("invalid_target", { description: "Requested resource was not included in the authorization request" });
|
|
2343
|
+
const originOnly = configuredResource ? false : !!this.options.resourceMatchOriginOnly;
|
|
2344
|
+
if (requestedResource && grantedResource) {
|
|
2345
|
+
const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
|
|
2346
|
+
const grantedResources = Array.isArray(grantedResource) ? grantedResource : [grantedResource];
|
|
2347
|
+
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" });
|
|
2348
|
+
}
|
|
2349
|
+
return requestedAudience ?? grantedAudience;
|
|
2350
|
+
}
|
|
2351
|
+
/**
|
|
2187
2352
|
* Creates and stores an access token
|
|
2188
2353
|
* @param params - Options for creating the access token
|
|
2189
2354
|
* @returns The access token string
|
|
@@ -2255,7 +2420,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2255
2420
|
this.CIMD_FETCH_TIMEOUT_MS = 1e4;
|
|
2256
2421
|
}
|
|
2257
2422
|
static {
|
|
2258
|
-
this.CIMD_ALLOWED_AUTH_METHODS = ["none"
|
|
2423
|
+
this.CIMD_ALLOWED_AUTH_METHODS = ["none"];
|
|
2259
2424
|
}
|
|
2260
2425
|
/**
|
|
2261
2426
|
* Validates that a field is a string or undefined
|
|
@@ -2372,15 +2537,27 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2372
2537
|
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})`);
|
|
2373
2538
|
const rawMetadata = await this.readJsonWithSizeLimit(response, OAuthProviderImpl.CIMD_MAX_SIZE_BYTES);
|
|
2374
2539
|
const clientId = OAuthProviderImpl.validateStringField(rawMetadata.client_id, "client_id");
|
|
2540
|
+
const clientName = OAuthProviderImpl.validateStringField(rawMetadata.client_name, "client_name");
|
|
2375
2541
|
const redirectUris = OAuthProviderImpl.validateStringArray(rawMetadata.redirect_uris, "redirect_uris");
|
|
2376
|
-
const
|
|
2542
|
+
const declaredAuthMethod = OAuthProviderImpl.validateStringField(rawMetadata.token_endpoint_auth_method, "token_endpoint_auth_method");
|
|
2543
|
+
const authMethodChoices = OAuthProviderImpl.validateStringArray(rawMetadata.token_endpoint_auth_methods_supported, "token_endpoint_auth_methods_supported");
|
|
2544
|
+
const tokenEndpointAuthMethod = declaredAuthMethod ?? (authMethodChoices?.includes("none") ? "none" : void 0);
|
|
2377
2545
|
if (clientId !== metadataUrl) throw new Error(`client_id "${clientId}" does not match metadata URL "${metadataUrl}"`);
|
|
2546
|
+
if (!clientName?.trim()) throw new Error("client_name is required and must not be empty");
|
|
2378
2547
|
if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
|
|
2379
|
-
if (
|
|
2548
|
+
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(", ")}`);
|
|
2549
|
+
const grantTypes = OAuthProviderImpl.validateStringArray(rawMetadata.grant_types, "grant_types") || [GrantType.AUTHORIZATION_CODE];
|
|
2550
|
+
const responseTypes = OAuthProviderImpl.validateStringArray(rawMetadata.response_types, "response_types") || ["code"];
|
|
2551
|
+
const effectiveAuthMethod = tokenEndpointAuthMethod || "none";
|
|
2552
|
+
validateClientCapabilities(this.serverCapabilities, {
|
|
2553
|
+
tokenEndpointAuthMethod: effectiveAuthMethod,
|
|
2554
|
+
grantTypes,
|
|
2555
|
+
responseTypes
|
|
2556
|
+
});
|
|
2380
2557
|
return {
|
|
2381
2558
|
clientId,
|
|
2382
2559
|
redirectUris,
|
|
2383
|
-
clientName
|
|
2560
|
+
clientName,
|
|
2384
2561
|
clientUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.client_uri, "client_uri"),
|
|
2385
2562
|
logoUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.logo_uri, "logo_uri"),
|
|
2386
2563
|
policyUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.policy_uri, "policy_uri"),
|
|
@@ -2388,9 +2565,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2388
2565
|
jwksUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.jwks_uri, "jwks_uri"),
|
|
2389
2566
|
i18n: OAuthProviderImpl.extractI18nFields(rawMetadata),
|
|
2390
2567
|
contacts: OAuthProviderImpl.validateStringArray(rawMetadata.contacts, "contacts"),
|
|
2391
|
-
grantTypes
|
|
2392
|
-
responseTypes
|
|
2393
|
-
tokenEndpointAuthMethod:
|
|
2568
|
+
grantTypes,
|
|
2569
|
+
responseTypes,
|
|
2570
|
+
tokenEndpointAuthMethod: effectiveAuthMethod
|
|
2394
2571
|
};
|
|
2395
2572
|
} finally {
|
|
2396
2573
|
clearTimeout(timeoutId);
|
|
@@ -2434,8 +2611,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2434
2611
|
/**
|
|
2435
2612
|
* Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
|
|
2436
2613
|
*/
|
|
2437
|
-
buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription) {
|
|
2438
|
-
let header = `Bearer realm="OAuth", resource_metadata="${resourceMetadataUrl}"
|
|
2614
|
+
buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription, requiredScopes = []) {
|
|
2615
|
+
let header = `Bearer realm="OAuth", resource_metadata="${resourceMetadataUrl}"`;
|
|
2616
|
+
if (error) header += `, error="${error}"`;
|
|
2617
|
+
const challengeScopes = requiredScopes.length > 0 ? this.normalizeProtectedResourceScopes(requiredScopes) : this.getProtectedResourceScopes();
|
|
2618
|
+
if (challengeScopes.length > 0) header += `, scope="${challengeScopes.join(" ")}"`;
|
|
2439
2619
|
if (errorDescription) header += `, error_description="${errorDescription}"`;
|
|
2440
2620
|
return header;
|
|
2441
2621
|
}
|
|
@@ -2447,7 +2627,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2447
2627
|
* so the public response stays RFC-compliant and free of information leak
|
|
2448
2628
|
* while the deployer can still observe which check failed.
|
|
2449
2629
|
*/
|
|
2450
|
-
createErrorResponse(code, options, internal) {
|
|
2630
|
+
createErrorResponse(code, options, internal, request) {
|
|
2451
2631
|
const { description } = options;
|
|
2452
2632
|
const responseStatus = options.statusCode ?? 400;
|
|
2453
2633
|
const responseHeaders = {
|
|
@@ -2459,7 +2639,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2459
2639
|
description,
|
|
2460
2640
|
status: responseStatus,
|
|
2461
2641
|
headers: responseHeaders,
|
|
2462
|
-
...internal ? { internal } : {}
|
|
2642
|
+
...internal ? { internal } : {},
|
|
2643
|
+
...request ? { request } : {}
|
|
2463
2644
|
});
|
|
2464
2645
|
if (customErrorResponse) return customErrorResponse;
|
|
2465
2646
|
const body = JSON.stringify({
|
|
@@ -2476,11 +2657,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2476
2657
|
}
|
|
2477
2658
|
};
|
|
2478
2659
|
/**
|
|
2479
|
-
* Structured OAuth 2.0 error.
|
|
2660
|
+
* Structured OAuth 2.0 token-endpoint error.
|
|
2480
2661
|
*
|
|
2481
|
-
* Throw from a `tokenExchangeCallback`
|
|
2482
|
-
*
|
|
2483
|
-
* `/token` error response (`{ error, error_description }`) instead of a
|
|
2662
|
+
* Throw from a `tokenExchangeCallback` or any code it calls to surface a
|
|
2663
|
+
* standard OAuth token response (`{ error, error_description }`) instead of a
|
|
2484
2664
|
* generic `500 Internal Server Error`.
|
|
2485
2665
|
*
|
|
2486
2666
|
* Anything thrown that is **not** an `OAuthError` continues to surface as
|
|
@@ -2530,6 +2710,61 @@ var OAuthError = class extends Error {
|
|
|
2530
2710
|
}
|
|
2531
2711
|
};
|
|
2532
2712
|
/**
|
|
2713
|
+
* Intentional public error from an external bearer-token validator.
|
|
2714
|
+
*
|
|
2715
|
+
* Throw only from `resolveExternalToken` when an expected validation outcome
|
|
2716
|
+
* should become a structured protected-resource response. Ordinary errors and
|
|
2717
|
+
* {@link OAuthError} retain their pre-existing behavior and propagate as
|
|
2718
|
+
* unexpected failures.
|
|
2719
|
+
*/
|
|
2720
|
+
var ExternalTokenError = class extends Error {
|
|
2721
|
+
/**
|
|
2722
|
+
* Creates an intentional external-token validation error.
|
|
2723
|
+
* @param code - Standard OAuth error code to return
|
|
2724
|
+
* @param options - Public response details
|
|
2725
|
+
*/
|
|
2726
|
+
constructor(code, options) {
|
|
2727
|
+
super(options.description);
|
|
2728
|
+
this.name = "ExternalTokenError";
|
|
2729
|
+
this.code = code;
|
|
2730
|
+
this.description = options.description;
|
|
2731
|
+
this.statusCode = options.statusCode;
|
|
2732
|
+
this.headers = options.headers;
|
|
2733
|
+
this.requiredScopes = options.requiredScopes;
|
|
2734
|
+
}
|
|
2735
|
+
};
|
|
2736
|
+
/**
|
|
2737
|
+
* Thrown when fetching a Client ID Metadata Document (CIMD) fails — the
|
|
2738
|
+
* server-to-server fetch errored, timed out, or returned an invalid document.
|
|
2739
|
+
* Distinct from a client that simply does not exist, which is reported as a
|
|
2740
|
+
* null client lookup result.
|
|
2741
|
+
*
|
|
2742
|
+
* At the token endpoint the provider handles this itself: the wire response
|
|
2743
|
+
* stays a generic `invalid_client` / "Client not found", and the failure is
|
|
2744
|
+
* reported through the `onError` hook's `internal` field (category
|
|
2745
|
+
* `client-id-metadata-document`) together with the originating `request`.
|
|
2746
|
+
*
|
|
2747
|
+
* `OAuthHelpers` methods that look up clients (`lookupClient`, and methods
|
|
2748
|
+
* built on it such as `exchangeToken`) let this error propagate to the
|
|
2749
|
+
* caller. Callers that previously relied on a `null` result for these
|
|
2750
|
+
* failures should catch it to preserve their error contract.
|
|
2751
|
+
*/
|
|
2752
|
+
var CimdFetchError = class extends Error {
|
|
2753
|
+
/**
|
|
2754
|
+
* Creates an error for a failed CIMD fetch or validation.
|
|
2755
|
+
* @param metadataUrl - The CIMD URL that could not be resolved
|
|
2756
|
+
* @param cause - The underlying fetch or validation failure
|
|
2757
|
+
*/
|
|
2758
|
+
constructor(metadataUrl, cause) {
|
|
2759
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
2760
|
+
super(`CIMD fetch failed for ${metadataUrl}: ${detail}`);
|
|
2761
|
+
this.reason = "metadata_resolution_failed";
|
|
2762
|
+
this.name = "CimdFetchError";
|
|
2763
|
+
this.metadataUrl = metadataUrl;
|
|
2764
|
+
this.detail = detail;
|
|
2765
|
+
}
|
|
2766
|
+
};
|
|
2767
|
+
/**
|
|
2533
2768
|
* Default expiration time for access tokens (1 hour in seconds)
|
|
2534
2769
|
*/
|
|
2535
2770
|
const DEFAULT_ACCESS_TOKEN_TTL = 3600;
|
|
@@ -2581,10 +2816,6 @@ function getRevokeExistingGrantsBatchSize(batchSize) {
|
|
|
2581
2816
|
*/
|
|
2582
2817
|
const TOKEN_LENGTH = 32;
|
|
2583
2818
|
/**
|
|
2584
|
-
* RFC 6749 Section 3.3 scope-token grammar.
|
|
2585
|
-
*/
|
|
2586
|
-
const OAUTH_SCOPE_TOKEN_PATTERN = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
|
|
2587
|
-
/**
|
|
2588
2819
|
* Validates a resource URI per RFC 8707 Section 2
|
|
2589
2820
|
* @param uri - The URI string to validate
|
|
2590
2821
|
* @returns true if valid, false otherwise
|
|
@@ -2614,6 +2845,7 @@ function audienceMatches(resourceServerUrl, audienceValue) {
|
|
|
2614
2845
|
const resource = new URL(resourceServerUrl);
|
|
2615
2846
|
const audience = new URL(audienceValue);
|
|
2616
2847
|
if (resource.origin !== audience.origin) return false;
|
|
2848
|
+
if (audience.search && resource.search !== audience.search) return false;
|
|
2617
2849
|
if (audience.pathname === "/" || audience.pathname === "") return true;
|
|
2618
2850
|
return resource.pathname === audience.pathname || resource.pathname.startsWith(audience.pathname + "/");
|
|
2619
2851
|
} catch {
|
|
@@ -2632,6 +2864,10 @@ function parseResourceParameter(value) {
|
|
|
2632
2864
|
for (const uri of uris) if (typeof uri !== "string" || !validateResourceUri(uri)) return;
|
|
2633
2865
|
return value;
|
|
2634
2866
|
}
|
|
2867
|
+
/** Whether a request or audience names one exact configured resource. */
|
|
2868
|
+
function isExactResource(value, configuredResource) {
|
|
2869
|
+
return value === configuredResource || Array.isArray(value) && value.length === 1 && value[0] === configuredResource;
|
|
2870
|
+
}
|
|
2635
2871
|
/**
|
|
2636
2872
|
* Checks if a requested resource matches a granted resource.
|
|
2637
2873
|
* When originOnly is true, compares only the origin (scheme + host + port),
|
|
@@ -2654,6 +2890,29 @@ async function hashSecret(secret) {
|
|
|
2654
2890
|
return generateTokenId(secret);
|
|
2655
2891
|
}
|
|
2656
2892
|
/**
|
|
2893
|
+
* Parses RFC 6749 HTTP Basic client credentials.
|
|
2894
|
+
*/
|
|
2895
|
+
function parseBasicAuthorizationHeader(header) {
|
|
2896
|
+
if (!header) return { kind: "not-basic" };
|
|
2897
|
+
const schemeEnd = header.search(/[ \t]/);
|
|
2898
|
+
if ((schemeEnd === -1 ? header : header.slice(0, schemeEnd)).toLowerCase() !== "basic") return { kind: "not-basic" };
|
|
2899
|
+
if (schemeEnd === -1) return { kind: "malformed" };
|
|
2900
|
+
const encodedCredentials = header.slice(schemeEnd).trim();
|
|
2901
|
+
if (!encodedCredentials || /[ \t]/.test(encodedCredentials)) return { kind: "malformed" };
|
|
2902
|
+
try {
|
|
2903
|
+
const credentials = atob(encodedCredentials);
|
|
2904
|
+
const separatorIndex = credentials.indexOf(":");
|
|
2905
|
+
if (separatorIndex === -1) return { kind: "malformed" };
|
|
2906
|
+
return {
|
|
2907
|
+
kind: "credentials",
|
|
2908
|
+
clientId: decodeFormUrlEncodedComponent(credentials.slice(0, separatorIndex)),
|
|
2909
|
+
clientSecret: decodeFormUrlEncodedComponent(credentials.slice(separatorIndex + 1))
|
|
2910
|
+
};
|
|
2911
|
+
} catch {
|
|
2912
|
+
return { kind: "malformed" };
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
/**
|
|
2657
2916
|
* Decodes an application/x-www-form-urlencoded component.
|
|
2658
2917
|
* @param value - The encoded component value
|
|
2659
2918
|
* @returns The decoded component value
|
|
@@ -2777,9 +3036,6 @@ function parseJwtJsonPart(encoded) {
|
|
|
2777
3036
|
throw new Error("Malformed JWT part");
|
|
2778
3037
|
}
|
|
2779
3038
|
}
|
|
2780
|
-
function isValidOAuthScopeToken(scopeToken) {
|
|
2781
|
-
return OAUTH_SCOPE_TOKEN_PATTERN.test(scopeToken);
|
|
2782
|
-
}
|
|
2783
3039
|
/**
|
|
2784
3040
|
* Gets WebCrypto import and verify parameters for supported JOSE algorithms.
|
|
2785
3041
|
*/
|
|
@@ -2951,6 +3207,8 @@ var OAuthHelpersImpl = class {
|
|
|
2951
3207
|
* Parses an OAuth authorization request from the HTTP request
|
|
2952
3208
|
* @param request - The HTTP request containing OAuth parameters
|
|
2953
3209
|
* @returns The parsed authorization request parameters
|
|
3210
|
+
* @throws Error when the response type is missing, unsupported, or not registered for the client
|
|
3211
|
+
* @throws CimdFetchError when the client ID is a CIMD URL whose document cannot be resolved
|
|
2954
3212
|
*/
|
|
2955
3213
|
async parseAuthRequest(request) {
|
|
2956
3214
|
const url = new URL(request.url);
|
|
@@ -2960,20 +3218,26 @@ var OAuthHelpersImpl = class {
|
|
|
2960
3218
|
const scope = (url.searchParams.get("scope") || "").split(" ").filter(Boolean);
|
|
2961
3219
|
const state = url.searchParams.get("state") || "";
|
|
2962
3220
|
const codeChallenge = url.searchParams.get("code_challenge") || void 0;
|
|
2963
|
-
const codeChallengeMethod = url.searchParams.get("code_challenge_method") ||
|
|
3221
|
+
const codeChallengeMethod = url.searchParams.get("code_challenge_method") || void 0;
|
|
3222
|
+
const issuer = this.provider.getAuthorizationServerIssuer(url);
|
|
2964
3223
|
const resourceParams = url.searchParams.getAll("resource");
|
|
2965
3224
|
const resourceParam = resourceParams.length > 0 ? resourceParams.length === 1 ? resourceParams[0] : resourceParams : void 0;
|
|
2966
3225
|
validateRedirectUriScheme(redirectUri);
|
|
2967
|
-
|
|
3226
|
+
let resource = parseResourceParameter(resourceParam);
|
|
2968
3227
|
if (resourceParam && !resource) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
|
|
2969
|
-
|
|
2970
|
-
if (
|
|
3228
|
+
const configuredResource = this.provider.options.resourceMetadata?.resource;
|
|
3229
|
+
if (configuredResource && !isExactResource(resource, configuredResource)) throw new Error(`The resource parameter must exactly match ${configuredResource}`);
|
|
3230
|
+
resource ??= url.origin;
|
|
2971
3231
|
if (clientId) {
|
|
2972
3232
|
const clientInfo = await this.lookupClient(clientId);
|
|
2973
3233
|
if (!clientInfo) throw new Error(`Invalid client. The clientId provided does not match to this client.`);
|
|
2974
|
-
if (
|
|
2975
|
-
|
|
2976
|
-
|
|
3234
|
+
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.`);
|
|
3235
|
+
validateAuthorizationResponseType(this.provider.serverCapabilities, responseType, clientInfo.responseTypes);
|
|
3236
|
+
validateAuthorizationPkce(this.provider.serverCapabilities, {
|
|
3237
|
+
responseType,
|
|
3238
|
+
codeChallenge,
|
|
3239
|
+
codeChallengeMethod
|
|
3240
|
+
}, clientInfo);
|
|
2977
3241
|
}
|
|
2978
3242
|
return {
|
|
2979
3243
|
responseType,
|
|
@@ -2983,13 +3247,20 @@ var OAuthHelpersImpl = class {
|
|
|
2983
3247
|
state,
|
|
2984
3248
|
codeChallenge,
|
|
2985
3249
|
codeChallengeMethod,
|
|
2986
|
-
resource
|
|
3250
|
+
resource,
|
|
3251
|
+
issuer
|
|
2987
3252
|
};
|
|
2988
3253
|
}
|
|
2989
3254
|
/**
|
|
2990
3255
|
* Looks up a client by its client ID
|
|
2991
3256
|
* @param clientId - The client ID to look up
|
|
2992
|
-
* @returns A Promise resolving to the client info, or null if not
|
|
3257
|
+
* @returns A Promise resolving to the client info, or null if the client does not
|
|
3258
|
+
* exist. Null means definitive absence; failures to determine the answer throw
|
|
3259
|
+
* instead (KV errors propagate, and a CIMD metadata fetch failure throws
|
|
3260
|
+
* `CimdFetchError`), so an upstream outage cannot masquerade as an unregistered
|
|
3261
|
+
* client.
|
|
3262
|
+
* @throws CimdFetchError when the client ID is a CIMD URL and fetching or
|
|
3263
|
+
* validating the metadata document fails.
|
|
2993
3264
|
*/
|
|
2994
3265
|
async lookupClient(clientId) {
|
|
2995
3266
|
return await this.provider.getClient(this.env, clientId);
|
|
@@ -3000,12 +3271,19 @@ var OAuthHelpersImpl = class {
|
|
|
3000
3271
|
* - For implicit flow: generating an access token directly
|
|
3001
3272
|
* @param options - Options specifying the grant details
|
|
3002
3273
|
* @returns A Promise resolving to an object containing the redirect URL
|
|
3274
|
+
* @throws Error when the request's response type is not permitted
|
|
3275
|
+
* @throws CimdFetchError when the client ID is a CIMD URL whose document cannot be resolved
|
|
3003
3276
|
*/
|
|
3004
3277
|
async completeAuthorization(options) {
|
|
3005
3278
|
const { clientId, redirectUri } = options.request;
|
|
3006
3279
|
if (!clientId || !redirectUri) throw new Error("Client ID and Redirect URI are required in the authorization request.");
|
|
3007
3280
|
const clientInfo = await this.lookupClient(clientId);
|
|
3008
3281
|
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.");
|
|
3282
|
+
validateAuthorizationResponseType(this.provider.serverCapabilities, options.request.responseType, clientInfo.responseTypes);
|
|
3283
|
+
const configuredResource = this.provider.options.resourceMetadata?.resource;
|
|
3284
|
+
if (configuredResource && !isExactResource(options.request.resource, configuredResource)) throw new Error(`The resource parameter must exactly match ${configuredResource}`);
|
|
3285
|
+
const effectiveResource = options.request.resource ?? options.request.issuer;
|
|
3286
|
+
validateAuthorizationPkce(this.provider.serverCapabilities, options.request, clientInfo);
|
|
3009
3287
|
let grantsToRevoke = [];
|
|
3010
3288
|
if (options.revokeExistingGrants !== false) {
|
|
3011
3289
|
const batchSize = getRevokeExistingGrantsBatchSize(options.revokeExistingGrantsBatchSize);
|
|
@@ -3029,8 +3307,8 @@ var OAuthHelpersImpl = class {
|
|
|
3029
3307
|
const accessTokenTTL = this.provider.options.accessTokenTTL || DEFAULT_ACCESS_TOKEN_TTL;
|
|
3030
3308
|
const accessTokenExpiresAt = now + accessTokenTTL;
|
|
3031
3309
|
const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, encryptionKey);
|
|
3032
|
-
const audience = parseResourceParameter(
|
|
3033
|
-
if (
|
|
3310
|
+
const audience = parseResourceParameter(effectiveResource);
|
|
3311
|
+
if (effectiveResource && !audience) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
|
|
3034
3312
|
const grant = {
|
|
3035
3313
|
id: grantId,
|
|
3036
3314
|
clientId: options.request.clientId,
|
|
@@ -3039,7 +3317,7 @@ var OAuthHelpersImpl = class {
|
|
|
3039
3317
|
metadata: options.metadata,
|
|
3040
3318
|
encryptedProps: encryptedData,
|
|
3041
3319
|
createdAt: now,
|
|
3042
|
-
resource:
|
|
3320
|
+
resource: effectiveResource
|
|
3043
3321
|
};
|
|
3044
3322
|
const grantKey = `grant:${options.userId}:${grantId}`;
|
|
3045
3323
|
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant));
|
|
@@ -3066,6 +3344,7 @@ var OAuthHelpersImpl = class {
|
|
|
3066
3344
|
fragment.set("expires_in", accessTokenTTL.toString());
|
|
3067
3345
|
fragment.set("scope", options.scope.join(" "));
|
|
3068
3346
|
if (options.request.state) fragment.set("state", options.request.state);
|
|
3347
|
+
if (options.request.issuer) fragment.set("iss", options.request.issuer);
|
|
3069
3348
|
redirectUrl.hash = fragment.toString();
|
|
3070
3349
|
try {
|
|
3071
3350
|
await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
|
|
@@ -3088,13 +3367,14 @@ var OAuthHelpersImpl = class {
|
|
|
3088
3367
|
authCodeWrappedKey,
|
|
3089
3368
|
codeChallenge: options.request.codeChallenge,
|
|
3090
3369
|
codeChallengeMethod: options.request.codeChallengeMethod,
|
|
3091
|
-
resource:
|
|
3370
|
+
resource: effectiveResource
|
|
3092
3371
|
};
|
|
3093
3372
|
const grantKey = `grant:${options.userId}:${grantId}`;
|
|
3094
3373
|
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), { expirationTtl: 600 });
|
|
3095
3374
|
const redirectUrl = new URL(options.request.redirectUri);
|
|
3096
3375
|
redirectUrl.searchParams.set("code", authCode);
|
|
3097
3376
|
if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
|
|
3377
|
+
if (options.request.issuer) redirectUrl.searchParams.set("iss", options.request.issuer);
|
|
3098
3378
|
try {
|
|
3099
3379
|
await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
|
|
3100
3380
|
} catch {}
|
|
@@ -3287,6 +3567,7 @@ var OAuthHelpersImpl = class {
|
|
|
3287
3567
|
* Implements OAuth 2.0 Token Exchange (RFC 8693)
|
|
3288
3568
|
* @param options - Options for token exchange including subject token and optional modifications
|
|
3289
3569
|
* @returns Promise resolving to token response with new access token
|
|
3570
|
+
* @throws CimdFetchError when the grant's client ID is a CIMD URL whose document cannot be resolved
|
|
3290
3571
|
*/
|
|
3291
3572
|
async exchangeToken(options) {
|
|
3292
3573
|
const tokenSummary = await this.unwrapToken(options.subjectToken);
|
|
@@ -3389,4 +3670,4 @@ var OAuthHelpersImpl = class {
|
|
|
3389
3670
|
var oauth_provider_default = OAuthProvider;
|
|
3390
3671
|
|
|
3391
3672
|
//#endregion
|
|
3392
|
-
export { GrantType, OAuthError, OAuthProvider, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
|
|
3673
|
+
export { CimdFetchError, ExternalTokenError, GrantType, OAuthError, OAuthProvider, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
|