@cloudflare/workers-oauth-provider 0.8.3 → 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 +140 -29
- package/dist/oauth-provider.js +430 -148
- 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
|
};
|
|
@@ -2046,10 +2167,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2046
2167
|
const url = new URL(request.url);
|
|
2047
2168
|
const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource${url.pathname}`;
|
|
2048
2169
|
const authHeader = request.headers.get("Authorization");
|
|
2049
|
-
if (!authHeader || !authHeader.startsWith("Bearer ")) return
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
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
|
+
}
|
|
2053
2176
|
});
|
|
2054
2177
|
const accessToken = authHeader.substring(7);
|
|
2055
2178
|
const parts = accessToken.split(":");
|
|
@@ -2068,6 +2191,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2068
2191
|
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
|
|
2069
2192
|
});
|
|
2070
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
|
+
});
|
|
2071
2200
|
const now = Math.floor(Date.now() / 1e3);
|
|
2072
2201
|
if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", {
|
|
2073
2202
|
description: "Access token expired",
|
|
@@ -2076,7 +2205,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2076
2205
|
});
|
|
2077
2206
|
if (tokenData.audience) {
|
|
2078
2207
|
const requestUrl = new URL(request.url);
|
|
2079
|
-
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
|
|
2208
|
+
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
|
|
2080
2209
|
if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
|
|
2081
2210
|
description: "Token audience does not match resource server",
|
|
2082
2211
|
statusCode: 401,
|
|
@@ -2085,19 +2214,32 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2085
2214
|
}
|
|
2086
2215
|
ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
|
|
2087
2216
|
} else if (this.options.resolveExternalToken) {
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
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
|
+
}
|
|
2093
2229
|
if (!ext) return this.createErrorResponse("invalid_token", {
|
|
2094
2230
|
description: "Invalid access token",
|
|
2095
2231
|
statusCode: 401,
|
|
2096
2232
|
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
|
|
2097
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
|
+
});
|
|
2098
2240
|
if (ext.audience) {
|
|
2099
2241
|
const requestUrl = new URL(request.url);
|
|
2100
|
-
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
|
|
2242
|
+
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
|
|
2101
2243
|
if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
|
|
2102
2244
|
description: "Token audience does not match resource server",
|
|
2103
2245
|
statusCode: 401,
|
|
@@ -2164,7 +2306,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2164
2306
|
*
|
|
2165
2307
|
* @param env - Cloudflare Worker environment variables
|
|
2166
2308
|
* @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
|
|
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.
|
|
2168
2313
|
*/
|
|
2169
2314
|
async getClient(env, clientId) {
|
|
2170
2315
|
if (this.isClientMetadataUrl(clientId)) {
|
|
@@ -2177,13 +2322,34 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2177
2322
|
return await this.fetchClientMetadataDocument(clientId);
|
|
2178
2323
|
} catch (error) {
|
|
2179
2324
|
console.warn(`CIMD fetch failed for ${clientId}:`, error instanceof Error ? error.message : error);
|
|
2180
|
-
|
|
2325
|
+
throw new CimdFetchError(clientId, error);
|
|
2181
2326
|
}
|
|
2182
2327
|
}
|
|
2183
2328
|
const clientKey = `client:${clientId}`;
|
|
2184
2329
|
return env.OAUTH_KV.get(clientKey, { type: "json" });
|
|
2185
2330
|
}
|
|
2186
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
|
+
/**
|
|
2187
2353
|
* Creates and stores an access token
|
|
2188
2354
|
* @param params - Options for creating the access token
|
|
2189
2355
|
* @returns The access token string
|
|
@@ -2255,7 +2421,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2255
2421
|
this.CIMD_FETCH_TIMEOUT_MS = 1e4;
|
|
2256
2422
|
}
|
|
2257
2423
|
static {
|
|
2258
|
-
this.CIMD_ALLOWED_AUTH_METHODS = ["none"
|
|
2424
|
+
this.CIMD_ALLOWED_AUTH_METHODS = ["none"];
|
|
2259
2425
|
}
|
|
2260
2426
|
/**
|
|
2261
2427
|
* Validates that a field is a string or undefined
|
|
@@ -2372,15 +2538,27 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2372
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})`);
|
|
2373
2539
|
const rawMetadata = await this.readJsonWithSizeLimit(response, OAuthProviderImpl.CIMD_MAX_SIZE_BYTES);
|
|
2374
2540
|
const clientId = OAuthProviderImpl.validateStringField(rawMetadata.client_id, "client_id");
|
|
2541
|
+
const clientName = OAuthProviderImpl.validateStringField(rawMetadata.client_name, "client_name");
|
|
2375
2542
|
const redirectUris = OAuthProviderImpl.validateStringArray(rawMetadata.redirect_uris, "redirect_uris");
|
|
2376
|
-
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);
|
|
2377
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");
|
|
2378
2548
|
if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
|
|
2379
|
-
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
|
+
});
|
|
2380
2558
|
return {
|
|
2381
2559
|
clientId,
|
|
2382
2560
|
redirectUris,
|
|
2383
|
-
clientName
|
|
2561
|
+
clientName,
|
|
2384
2562
|
clientUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.client_uri, "client_uri"),
|
|
2385
2563
|
logoUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.logo_uri, "logo_uri"),
|
|
2386
2564
|
policyUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.policy_uri, "policy_uri"),
|
|
@@ -2388,9 +2566,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2388
2566
|
jwksUri: OAuthProviderImpl.validateOptionalUriField(rawMetadata.jwks_uri, "jwks_uri"),
|
|
2389
2567
|
i18n: OAuthProviderImpl.extractI18nFields(rawMetadata),
|
|
2390
2568
|
contacts: OAuthProviderImpl.validateStringArray(rawMetadata.contacts, "contacts"),
|
|
2391
|
-
grantTypes
|
|
2392
|
-
responseTypes
|
|
2393
|
-
tokenEndpointAuthMethod:
|
|
2569
|
+
grantTypes,
|
|
2570
|
+
responseTypes,
|
|
2571
|
+
tokenEndpointAuthMethod: effectiveAuthMethod
|
|
2394
2572
|
};
|
|
2395
2573
|
} finally {
|
|
2396
2574
|
clearTimeout(timeoutId);
|
|
@@ -2434,8 +2612,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2434
2612
|
/**
|
|
2435
2613
|
* Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
|
|
2436
2614
|
*/
|
|
2437
|
-
buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription) {
|
|
2438
|
-
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(" ")}"`;
|
|
2439
2620
|
if (errorDescription) header += `, error_description="${errorDescription}"`;
|
|
2440
2621
|
return header;
|
|
2441
2622
|
}
|
|
@@ -2447,7 +2628,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2447
2628
|
* so the public response stays RFC-compliant and free of information leak
|
|
2448
2629
|
* while the deployer can still observe which check failed.
|
|
2449
2630
|
*/
|
|
2450
|
-
createErrorResponse(code, options, internal) {
|
|
2631
|
+
createErrorResponse(code, options, internal, request) {
|
|
2451
2632
|
const { description } = options;
|
|
2452
2633
|
const responseStatus = options.statusCode ?? 400;
|
|
2453
2634
|
const responseHeaders = {
|
|
@@ -2459,7 +2640,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2459
2640
|
description,
|
|
2460
2641
|
status: responseStatus,
|
|
2461
2642
|
headers: responseHeaders,
|
|
2462
|
-
...internal ? { internal } : {}
|
|
2643
|
+
...internal ? { internal } : {},
|
|
2644
|
+
...request ? { request } : {}
|
|
2463
2645
|
});
|
|
2464
2646
|
if (customErrorResponse) return customErrorResponse;
|
|
2465
2647
|
const body = JSON.stringify({
|
|
@@ -2476,11 +2658,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
2476
2658
|
}
|
|
2477
2659
|
};
|
|
2478
2660
|
/**
|
|
2479
|
-
* Structured OAuth 2.0 error.
|
|
2661
|
+
* Structured OAuth 2.0 token-endpoint error.
|
|
2480
2662
|
*
|
|
2481
|
-
* Throw from a `tokenExchangeCallback`
|
|
2482
|
-
*
|
|
2483
|
-
* `/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
|
|
2484
2665
|
* generic `500 Internal Server Error`.
|
|
2485
2666
|
*
|
|
2486
2667
|
* Anything thrown that is **not** an `OAuthError` continues to surface as
|
|
@@ -2530,6 +2711,61 @@ var OAuthError = class extends Error {
|
|
|
2530
2711
|
}
|
|
2531
2712
|
};
|
|
2532
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
|
+
/**
|
|
2533
2769
|
* Default expiration time for access tokens (1 hour in seconds)
|
|
2534
2770
|
*/
|
|
2535
2771
|
const DEFAULT_ACCESS_TOKEN_TTL = 3600;
|
|
@@ -2581,10 +2817,6 @@ function getRevokeExistingGrantsBatchSize(batchSize) {
|
|
|
2581
2817
|
*/
|
|
2582
2818
|
const TOKEN_LENGTH = 32;
|
|
2583
2819
|
/**
|
|
2584
|
-
* RFC 6749 Section 3.3 scope-token grammar.
|
|
2585
|
-
*/
|
|
2586
|
-
const OAUTH_SCOPE_TOKEN_PATTERN = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
|
|
2587
|
-
/**
|
|
2588
2820
|
* Validates a resource URI per RFC 8707 Section 2
|
|
2589
2821
|
* @param uri - The URI string to validate
|
|
2590
2822
|
* @returns true if valid, false otherwise
|
|
@@ -2614,6 +2846,7 @@ function audienceMatches(resourceServerUrl, audienceValue) {
|
|
|
2614
2846
|
const resource = new URL(resourceServerUrl);
|
|
2615
2847
|
const audience = new URL(audienceValue);
|
|
2616
2848
|
if (resource.origin !== audience.origin) return false;
|
|
2849
|
+
if (audience.search && resource.search !== audience.search) return false;
|
|
2617
2850
|
if (audience.pathname === "/" || audience.pathname === "") return true;
|
|
2618
2851
|
return resource.pathname === audience.pathname || resource.pathname.startsWith(audience.pathname + "/");
|
|
2619
2852
|
} catch {
|
|
@@ -2632,6 +2865,10 @@ function parseResourceParameter(value) {
|
|
|
2632
2865
|
for (const uri of uris) if (typeof uri !== "string" || !validateResourceUri(uri)) return;
|
|
2633
2866
|
return value;
|
|
2634
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
|
+
}
|
|
2635
2872
|
/**
|
|
2636
2873
|
* Checks if a requested resource matches a granted resource.
|
|
2637
2874
|
* When originOnly is true, compares only the origin (scheme + host + port),
|
|
@@ -2654,6 +2891,29 @@ async function hashSecret(secret) {
|
|
|
2654
2891
|
return generateTokenId(secret);
|
|
2655
2892
|
}
|
|
2656
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
|
+
/**
|
|
2657
2917
|
* Decodes an application/x-www-form-urlencoded component.
|
|
2658
2918
|
* @param value - The encoded component value
|
|
2659
2919
|
* @returns The decoded component value
|
|
@@ -2777,9 +3037,6 @@ function parseJwtJsonPart(encoded) {
|
|
|
2777
3037
|
throw new Error("Malformed JWT part");
|
|
2778
3038
|
}
|
|
2779
3039
|
}
|
|
2780
|
-
function isValidOAuthScopeToken(scopeToken) {
|
|
2781
|
-
return OAUTH_SCOPE_TOKEN_PATTERN.test(scopeToken);
|
|
2782
|
-
}
|
|
2783
3040
|
/**
|
|
2784
3041
|
* Gets WebCrypto import and verify parameters for supported JOSE algorithms.
|
|
2785
3042
|
*/
|
|
@@ -2951,6 +3208,8 @@ var OAuthHelpersImpl = class {
|
|
|
2951
3208
|
* Parses an OAuth authorization request from the HTTP request
|
|
2952
3209
|
* @param request - The HTTP request containing OAuth parameters
|
|
2953
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
|
|
2954
3213
|
*/
|
|
2955
3214
|
async parseAuthRequest(request) {
|
|
2956
3215
|
const url = new URL(request.url);
|
|
@@ -2960,20 +3219,26 @@ var OAuthHelpersImpl = class {
|
|
|
2960
3219
|
const scope = (url.searchParams.get("scope") || "").split(" ").filter(Boolean);
|
|
2961
3220
|
const state = url.searchParams.get("state") || "";
|
|
2962
3221
|
const codeChallenge = url.searchParams.get("code_challenge") || void 0;
|
|
2963
|
-
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);
|
|
2964
3224
|
const resourceParams = url.searchParams.getAll("resource");
|
|
2965
3225
|
const resourceParam = resourceParams.length > 0 ? resourceParams.length === 1 ? resourceParams[0] : resourceParams : void 0;
|
|
2966
3226
|
validateRedirectUriScheme(redirectUri);
|
|
2967
|
-
|
|
3227
|
+
let resource = parseResourceParameter(resourceParam);
|
|
2968
3228
|
if (resourceParam && !resource) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
|
|
2969
|
-
|
|
2970
|
-
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;
|
|
2971
3232
|
if (clientId) {
|
|
2972
3233
|
const clientInfo = await this.lookupClient(clientId);
|
|
2973
3234
|
if (!clientInfo) throw new Error(`Invalid client. The clientId provided does not match to this client.`);
|
|
2974
|
-
if (
|
|
2975
|
-
|
|
2976
|
-
|
|
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);
|
|
2977
3242
|
}
|
|
2978
3243
|
return {
|
|
2979
3244
|
responseType,
|
|
@@ -2983,13 +3248,20 @@ var OAuthHelpersImpl = class {
|
|
|
2983
3248
|
state,
|
|
2984
3249
|
codeChallenge,
|
|
2985
3250
|
codeChallengeMethod,
|
|
2986
|
-
resource
|
|
3251
|
+
resource,
|
|
3252
|
+
issuer
|
|
2987
3253
|
};
|
|
2988
3254
|
}
|
|
2989
3255
|
/**
|
|
2990
3256
|
* Looks up a client by its client ID
|
|
2991
3257
|
* @param clientId - The client ID to look up
|
|
2992
|
-
* @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.
|
|
2993
3265
|
*/
|
|
2994
3266
|
async lookupClient(clientId) {
|
|
2995
3267
|
return await this.provider.getClient(this.env, clientId);
|
|
@@ -3000,12 +3272,19 @@ var OAuthHelpersImpl = class {
|
|
|
3000
3272
|
* - For implicit flow: generating an access token directly
|
|
3001
3273
|
* @param options - Options specifying the grant details
|
|
3002
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
|
|
3003
3277
|
*/
|
|
3004
3278
|
async completeAuthorization(options) {
|
|
3005
3279
|
const { clientId, redirectUri } = options.request;
|
|
3006
3280
|
if (!clientId || !redirectUri) throw new Error("Client ID and Redirect URI are required in the authorization request.");
|
|
3007
3281
|
const clientInfo = await this.lookupClient(clientId);
|
|
3008
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);
|
|
3009
3288
|
let grantsToRevoke = [];
|
|
3010
3289
|
if (options.revokeExistingGrants !== false) {
|
|
3011
3290
|
const batchSize = getRevokeExistingGrantsBatchSize(options.revokeExistingGrantsBatchSize);
|
|
@@ -3029,8 +3308,8 @@ var OAuthHelpersImpl = class {
|
|
|
3029
3308
|
const accessTokenTTL = this.provider.options.accessTokenTTL || DEFAULT_ACCESS_TOKEN_TTL;
|
|
3030
3309
|
const accessTokenExpiresAt = now + accessTokenTTL;
|
|
3031
3310
|
const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, encryptionKey);
|
|
3032
|
-
const audience = parseResourceParameter(
|
|
3033
|
-
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");
|
|
3034
3313
|
const grant = {
|
|
3035
3314
|
id: grantId,
|
|
3036
3315
|
clientId: options.request.clientId,
|
|
@@ -3039,7 +3318,7 @@ var OAuthHelpersImpl = class {
|
|
|
3039
3318
|
metadata: options.metadata,
|
|
3040
3319
|
encryptedProps: encryptedData,
|
|
3041
3320
|
createdAt: now,
|
|
3042
|
-
resource:
|
|
3321
|
+
resource: effectiveResource
|
|
3043
3322
|
};
|
|
3044
3323
|
const grantKey = `grant:${options.userId}:${grantId}`;
|
|
3045
3324
|
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant));
|
|
@@ -3066,6 +3345,7 @@ var OAuthHelpersImpl = class {
|
|
|
3066
3345
|
fragment.set("expires_in", accessTokenTTL.toString());
|
|
3067
3346
|
fragment.set("scope", options.scope.join(" "));
|
|
3068
3347
|
if (options.request.state) fragment.set("state", options.request.state);
|
|
3348
|
+
if (options.request.issuer) fragment.set("iss", options.request.issuer);
|
|
3069
3349
|
redirectUrl.hash = fragment.toString();
|
|
3070
3350
|
try {
|
|
3071
3351
|
await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
|
|
@@ -3088,13 +3368,14 @@ var OAuthHelpersImpl = class {
|
|
|
3088
3368
|
authCodeWrappedKey,
|
|
3089
3369
|
codeChallenge: options.request.codeChallenge,
|
|
3090
3370
|
codeChallengeMethod: options.request.codeChallengeMethod,
|
|
3091
|
-
resource:
|
|
3371
|
+
resource: effectiveResource
|
|
3092
3372
|
};
|
|
3093
3373
|
const grantKey = `grant:${options.userId}:${grantId}`;
|
|
3094
3374
|
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), { expirationTtl: 600 });
|
|
3095
3375
|
const redirectUrl = new URL(options.request.redirectUri);
|
|
3096
3376
|
redirectUrl.searchParams.set("code", authCode);
|
|
3097
3377
|
if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
|
|
3378
|
+
if (options.request.issuer) redirectUrl.searchParams.set("iss", options.request.issuer);
|
|
3098
3379
|
try {
|
|
3099
3380
|
await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
|
|
3100
3381
|
} catch {}
|
|
@@ -3287,6 +3568,7 @@ var OAuthHelpersImpl = class {
|
|
|
3287
3568
|
* Implements OAuth 2.0 Token Exchange (RFC 8693)
|
|
3288
3569
|
* @param options - Options for token exchange including subject token and optional modifications
|
|
3289
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
|
|
3290
3572
|
*/
|
|
3291
3573
|
async exchangeToken(options) {
|
|
3292
3574
|
const tokenSummary = await this.unwrapToken(options.subjectToken);
|
|
@@ -3389,4 +3671,4 @@ var OAuthHelpersImpl = class {
|
|
|
3389
3671
|
var oauth_provider_default = OAuthProvider;
|
|
3390
3672
|
|
|
3391
3673
|
//#endregion
|
|
3392
|
-
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 };
|