@cloudflare/workers-oauth-provider 0.10.3 → 1.0.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 +144 -50
- package/dist/oauth-provider.d.ts +334 -89
- package/dist/oauth-provider.js +1396 -310
- package/docs/advanced-configuration.md +370 -0
- package/docs/migration-1.0.md +97 -0
- package/docs/resource-servers.md +153 -0
- package/package.json +4 -2
- package/skills/migrate-to-1.0/SKILL.md +35 -0
package/dist/oauth-provider.js
CHANGED
|
@@ -581,6 +581,8 @@ const EMA_DEFAULT_MAX_ASSERTION_LIFETIME_SECONDS = 300;
|
|
|
581
581
|
* values to amplify load on the IdP's JWKS endpoint.
|
|
582
582
|
*/
|
|
583
583
|
const EMA_JWKS_FORCE_REFRESH_COOLDOWN_SECONDS = 30;
|
|
584
|
+
/** Floor for the `jti` replay marker's KV TTL; KV rejects a sub-60s expiration. */
|
|
585
|
+
const EMA_JTI_MIN_TTL_SECONDS = 60;
|
|
584
586
|
/** Default JWT signing algorithm assumed for a trusted issuer. */
|
|
585
587
|
const EMA_DEFAULT_JWT_ALGORITHM = "RS256";
|
|
586
588
|
/** JWT signing algorithms supported by the built-in WebCrypto verifier. */
|
|
@@ -698,7 +700,7 @@ const EMA_JTI_KV_PREFIX = "enterprise-jti:";
|
|
|
698
700
|
/** Create the default KV-backed JTI store. KV TTL handles cleanup. */
|
|
699
701
|
function createKvJtiStore() {
|
|
700
702
|
return { async markUsed({ issuer, jti, exp, now, env }) {
|
|
701
|
-
const ttl = Math.max(
|
|
703
|
+
const ttl = Math.max(EMA_JTI_MIN_TTL_SECONDS, exp - now);
|
|
702
704
|
const key = `${EMA_JTI_KV_PREFIX}${await sha256Hex(`${issuer}\n${jti}`)}`;
|
|
703
705
|
if (await env.OAUTH_KV.get(key)) return err({
|
|
704
706
|
reason: "replayed",
|
|
@@ -909,6 +911,96 @@ async function verifyIdJagSignature(input) {
|
|
|
909
911
|
}
|
|
910
912
|
}
|
|
911
913
|
|
|
914
|
+
//#endregion
|
|
915
|
+
//#region src/oauth-resource.ts
|
|
916
|
+
/**
|
|
917
|
+
* Loopback hosts where a plain `http` scheme is accepted for local development.
|
|
918
|
+
* Matches RFC 8252 §7.3 loopback handling: 127.0.0.0/8, ::1, and `localhost`.
|
|
919
|
+
*/
|
|
920
|
+
function isLoopbackHostname(hostname) {
|
|
921
|
+
const host = hostname.toLowerCase();
|
|
922
|
+
if (host === "localhost" || host === "::1" || host === "[::1]") return true;
|
|
923
|
+
const ipv4 = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
924
|
+
return ipv4 !== null && ipv4.slice(1).every((octet) => Number(octet) <= 255);
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Whether a canonical resource, issuer, or endpoint URL uses an accepted
|
|
928
|
+
* scheme: `https`, or `http` on a loopback host so `wrangler dev` works on
|
|
929
|
+
* http://localhost. Workers are always served over `https`, so there is no
|
|
930
|
+
* deployment in which cleartext OAuth traffic to a remote host is correct,
|
|
931
|
+
* and no option exists to allow it.
|
|
932
|
+
*/
|
|
933
|
+
function hasAcceptedCanonicalScheme(url) {
|
|
934
|
+
return url.protocol === "https:" || url.protocol === "http:" && isLoopbackHostname(url.hostname);
|
|
935
|
+
}
|
|
936
|
+
/** Validate an RFC 3986-safe HTTP(S) resource identifier for RFC 8707. */
|
|
937
|
+
function validateResourceUri(uri) {
|
|
938
|
+
if (!uri || typeof uri !== "string") return false;
|
|
939
|
+
if (!/^[\x21-\x7e]+$/.test(uri) || /["\\#]/.test(uri) || !/^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#]+(?:\/[^?#]*)?(?:\?[^#]*)?$/.test(uri) || /%(?![0-9A-Fa-f]{2})/.test(uri) || !/^[A-Za-z0-9:/?@!$&'()*+,;=._~%\[\]-]+$/.test(uri)) return false;
|
|
940
|
+
try {
|
|
941
|
+
const parsed = new URL(uri);
|
|
942
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
943
|
+
} catch {
|
|
944
|
+
return false;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Whether a requested resource identifies a granted or configured resource.
|
|
949
|
+
* Only ASCII case in the URI scheme and host is ignored, and an empty path is
|
|
950
|
+
* equivalent to `/` (RFC 3986 §6.2.3), so a client that round-trips
|
|
951
|
+
* `https://example.com` through a URL parser as `https://example.com/` still
|
|
952
|
+
* names it. Port, path, query, a trailing slash after a path segment, user
|
|
953
|
+
* information, and every other byte remain significant.
|
|
954
|
+
*/
|
|
955
|
+
function resourceMatches(requested, granted) {
|
|
956
|
+
const foldedRequested = foldResourceSchemeAndHost(requested);
|
|
957
|
+
const foldedGranted = foldResourceSchemeAndHost(granted);
|
|
958
|
+
if (foldedRequested === void 0 || foldedGranted === void 0) return false;
|
|
959
|
+
return normalizeEmptyPath(foldedRequested) === normalizeEmptyPath(foldedGranted);
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Fold only the URI components whose comparison is ASCII case-insensitive: the
|
|
963
|
+
* scheme and the host. A configured identifier is canonical when folding it is
|
|
964
|
+
* the identity.
|
|
965
|
+
*/
|
|
966
|
+
function foldResourceSchemeAndHost(resource) {
|
|
967
|
+
const schemeSeparator = resource.indexOf("://");
|
|
968
|
+
if (schemeSeparator <= 0) return void 0;
|
|
969
|
+
const authorityStart = schemeSeparator + 3;
|
|
970
|
+
const authorityEndOffset = resource.slice(authorityStart).search(/[/?#]/);
|
|
971
|
+
const authorityEnd = authorityEndOffset === -1 ? resource.length : authorityStart + authorityEndOffset;
|
|
972
|
+
const authority = resource.slice(authorityStart, authorityEnd);
|
|
973
|
+
const hostStart = authority.lastIndexOf("@") + 1;
|
|
974
|
+
let hostEnd;
|
|
975
|
+
if (authority[hostStart] === "[") {
|
|
976
|
+
const closingBracket = authority.indexOf("]", hostStart + 1);
|
|
977
|
+
if (closingBracket === -1) return void 0;
|
|
978
|
+
hostEnd = closingBracket + 1;
|
|
979
|
+
} else {
|
|
980
|
+
const portSeparator = authority.indexOf(":", hostStart);
|
|
981
|
+
hostEnd = portSeparator === -1 ? authority.length : portSeparator;
|
|
982
|
+
}
|
|
983
|
+
const asciiLower = (value) => value.replace(/[A-Z]/g, (character) => character.toLowerCase());
|
|
984
|
+
return asciiLower(resource.slice(0, schemeSeparator)) + "://" + authority.slice(0, hostStart) + asciiLower(authority.slice(hostStart, hostEnd)) + authority.slice(hostEnd) + resource.slice(authorityEnd);
|
|
985
|
+
}
|
|
986
|
+
/** RFC 3986 §6.2.3: for http and https an empty path is equivalent to "/". */
|
|
987
|
+
function normalizeEmptyPath(resource) {
|
|
988
|
+
const authorityStart = resource.indexOf("://") + 3;
|
|
989
|
+
const pathOffset = resource.slice(authorityStart).search(/[/?#]/);
|
|
990
|
+
if (pathOffset === -1) return `${resource}/`;
|
|
991
|
+
const pathStart = authorityStart + pathOffset;
|
|
992
|
+
return resource[pathStart] === "/" ? resource : `${resource.slice(0, pathStart)}/${resource.slice(pathStart)}`;
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Whether a request carries every query parameter of a canonical resource. A
|
|
996
|
+
* query-bearing resource identifier names a more specific resource: a request
|
|
997
|
+
* may add parameters of its own but must preserve the resource's.
|
|
998
|
+
*/
|
|
999
|
+
function requestCarriesResourceQuery(requestUrl, resourceUrl) {
|
|
1000
|
+
for (const [name, value] of resourceUrl.searchParams) if (!requestUrl.searchParams.getAll(name).includes(value)) return false;
|
|
1001
|
+
return true;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
912
1004
|
//#endregion
|
|
913
1005
|
//#region src/ema/validators.ts
|
|
914
1006
|
/**
|
|
@@ -1034,7 +1126,7 @@ function isWellFormedTrustedIssuer(issuer) {
|
|
|
1034
1126
|
* - `scope` (if present) conforms to RFC 6749 §3.3 grammar
|
|
1035
1127
|
*/
|
|
1036
1128
|
function validateIdJagClaims(input) {
|
|
1037
|
-
const { rawClaims, trustedIssuer, expectedAudience, clientId, configuredResource
|
|
1129
|
+
const { rawClaims, trustedIssuer, expectedAudience, clientId, configuredResource } = input;
|
|
1038
1130
|
const { now, clockSkewSeconds, maxAssertionLifetimeSeconds } = input;
|
|
1039
1131
|
const iss = readRequiredString(rawClaims, "iss");
|
|
1040
1132
|
if (!iss.ok) return iss;
|
|
@@ -1074,7 +1166,7 @@ function validateIdJagClaims(input) {
|
|
|
1074
1166
|
reason: "resource_invalid",
|
|
1075
1167
|
resource: resource.value
|
|
1076
1168
|
});
|
|
1077
|
-
if (!resourceMatches(resource.value, configuredResource
|
|
1169
|
+
if (!resourceMatches(resource.value, configuredResource)) return err({
|
|
1078
1170
|
reason: "resource_mismatch",
|
|
1079
1171
|
expected: configuredResource,
|
|
1080
1172
|
got: resource.value
|
|
@@ -1226,6 +1318,237 @@ function readNumericDateClaim(claims, claimName) {
|
|
|
1226
1318
|
return ok(value);
|
|
1227
1319
|
}
|
|
1228
1320
|
|
|
1321
|
+
//#endregion
|
|
1322
|
+
//#region src/oauth-resource-server.ts
|
|
1323
|
+
const PROTECTED_RESOURCE_WELL_KNOWN_PREFIX$1 = "/.well-known/oauth-protected-resource";
|
|
1324
|
+
const NO_CACHE_HEADERS$1 = {
|
|
1325
|
+
"Cache-Control": "no-store",
|
|
1326
|
+
Pragma: "no-cache"
|
|
1327
|
+
};
|
|
1328
|
+
/**
|
|
1329
|
+
* The response for a valid token that lacks the scopes an operation needs (RFC 6750 §3.1,
|
|
1330
|
+
* MCP scope challenge handling): `403` with `WWW-Authenticate: Bearer error="insufficient_scope"`,
|
|
1331
|
+
* the `scope` the operation requires and the `resource_metadata` URL the client already knows.
|
|
1332
|
+
* Name every scope the operation needs in one response; clients treat the list as complete.
|
|
1333
|
+
*
|
|
1334
|
+
* ```ts
|
|
1335
|
+
* if (!ctx.auth.scope.includes('calendar:write')) return insufficientScope(ctx.auth, ['calendar:write']);
|
|
1336
|
+
* ```
|
|
1337
|
+
*/
|
|
1338
|
+
function insufficientScope(auth, scope, description) {
|
|
1339
|
+
if (!Array.isArray(scope) || scope.length === 0 || scope.some((value) => !isValidScopeToken(value))) throw new TypeError("insufficientScope requires at least one valid OAuth scope token");
|
|
1340
|
+
let challenge = `Bearer realm="OAuth", error="insufficient_scope", scope="${[...new Set(scope)].join(" ")}"`;
|
|
1341
|
+
challenge += `, resource_metadata="${getResourceMetadataUrl(auth.audience)}"`;
|
|
1342
|
+
if (description !== void 0) {
|
|
1343
|
+
const quotable = description.replace(/["\\]/g, "").replace(/[^\x21-\x7e]+/g, " ").trim();
|
|
1344
|
+
if (quotable) challenge += `, error_description="${quotable}"`;
|
|
1345
|
+
}
|
|
1346
|
+
return new Response(JSON.stringify({
|
|
1347
|
+
error: "insufficient_scope",
|
|
1348
|
+
...description !== void 0 ? { error_description: description } : {}
|
|
1349
|
+
}), {
|
|
1350
|
+
status: 403,
|
|
1351
|
+
headers: {
|
|
1352
|
+
...NO_CACHE_HEADERS$1,
|
|
1353
|
+
"Content-Type": "application/json",
|
|
1354
|
+
"WWW-Authenticate": challenge
|
|
1355
|
+
}
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* One OAuth protected resource, in the authorization server's Worker or its own. Publishes
|
|
1360
|
+
* RFC 9728 metadata, challenges unauthenticated requests, validates the bearer token through
|
|
1361
|
+
* the configured `validateToken`, enforces audience and expiry on what it returns, and routes
|
|
1362
|
+
* only the canonical resource and its descendants to the application handler.
|
|
1363
|
+
*
|
|
1364
|
+
* `export default new OAuthResourceServer({ … })`, or dispatch to `fetch()` from your own
|
|
1365
|
+
* router, exactly as with `OAuthAuthorizationServer`.
|
|
1366
|
+
*/
|
|
1367
|
+
var OAuthResourceServer = class {
|
|
1368
|
+
#options;
|
|
1369
|
+
#validated;
|
|
1370
|
+
constructor(options) {
|
|
1371
|
+
this.#validated = validateOptions(options);
|
|
1372
|
+
this.#options = options;
|
|
1373
|
+
}
|
|
1374
|
+
async fetch(request, env, ctx) {
|
|
1375
|
+
const options = this.#options;
|
|
1376
|
+
const validated = this.#validated;
|
|
1377
|
+
const url = new URL(request.url);
|
|
1378
|
+
if (isProtectedResourceMetadataPath(url)) {
|
|
1379
|
+
if (request.method === "OPTIONS") return addCorsHeaders(new Response(null, {
|
|
1380
|
+
status: 204,
|
|
1381
|
+
headers: { "Content-Length": "0" }
|
|
1382
|
+
}), request);
|
|
1383
|
+
if (!isMetadataUrlRequest(url, validated.metadataUrl)) return addCorsHeaders(new Response(null, { status: 404 }), request);
|
|
1384
|
+
if (request.method !== "GET" && request.method !== "HEAD") return addCorsHeaders(new Response(null, {
|
|
1385
|
+
status: 405,
|
|
1386
|
+
headers: { Allow: "GET, HEAD, OPTIONS" }
|
|
1387
|
+
}), request);
|
|
1388
|
+
const metadata = Response.json(validated.metadata, { headers: NO_CACHE_HEADERS$1 });
|
|
1389
|
+
return addCorsHeaders(request.method === "HEAD" ? new Response(null, {
|
|
1390
|
+
status: 200,
|
|
1391
|
+
headers: metadata.headers
|
|
1392
|
+
}) : metadata, request);
|
|
1393
|
+
}
|
|
1394
|
+
if (!isCanonicalResourceRequest(url, validated.resourceUrl)) return new Response(null, { status: 404 });
|
|
1395
|
+
if (request.method === "OPTIONS") return addCorsHeaders(new Response(null, {
|
|
1396
|
+
status: 204,
|
|
1397
|
+
headers: { "Content-Length": "0" }
|
|
1398
|
+
}), request);
|
|
1399
|
+
const token = parseBearerToken(request.headers.get("Authorization"));
|
|
1400
|
+
if (!token) return addCorsHeaders(createBearerChallenge(url, validated, false), request);
|
|
1401
|
+
let validation;
|
|
1402
|
+
try {
|
|
1403
|
+
validation = await options.validateToken(env, request)(validated.resource, token);
|
|
1404
|
+
} catch {
|
|
1405
|
+
return addCorsHeaders(createValidationUnavailableResponse(), request);
|
|
1406
|
+
}
|
|
1407
|
+
if (!isValidTokenValidation(validation, validated.resource)) return addCorsHeaders(createBearerChallenge(url, validated, true), request);
|
|
1408
|
+
const context = ctx;
|
|
1409
|
+
context.props = validation.props;
|
|
1410
|
+
context.auth = {
|
|
1411
|
+
token,
|
|
1412
|
+
audience: validated.resource,
|
|
1413
|
+
...validation.expiresAt !== void 0 ? { expiresAt: validation.expiresAt } : {},
|
|
1414
|
+
scope: [...validation.scope ?? []],
|
|
1415
|
+
...validation.userId !== void 0 ? { userId: validation.userId } : {},
|
|
1416
|
+
...validation.clientId !== void 0 ? { clientId: validation.clientId } : {}
|
|
1417
|
+
};
|
|
1418
|
+
const handler = options.handler;
|
|
1419
|
+
return addCorsHeaders(isEntrypointClass(handler) ? await new handler(context, env).fetch(request) : await handler.fetch(request, env, context), request);
|
|
1420
|
+
}
|
|
1421
|
+
};
|
|
1422
|
+
function validateOptions(options) {
|
|
1423
|
+
if (!options || typeof options !== "object") throw new TypeError("OAuth resource server options are required");
|
|
1424
|
+
if (!options.handler || !isEntrypointClass(options.handler) && typeof options.handler.fetch !== "function") throw new TypeError("handler must provide a fetch function or extend WorkerEntrypoint");
|
|
1425
|
+
if (typeof options.validateToken !== "function") throw new TypeError("validateToken must be a function");
|
|
1426
|
+
const resource = options.resourceMetadata?.resource;
|
|
1427
|
+
const resourceUrl = parseCanonicalUrl(resource);
|
|
1428
|
+
if (!resourceUrl) throw new TypeError("resourceMetadata.resource must be a canonical absolute HTTPS URI without a fragment (http is accepted only on a loopback host)");
|
|
1429
|
+
if (isProtectedResourceMetadataPath(resourceUrl)) throw new TypeError(`resourceMetadata.resource must not be inside the ${PROTECTED_RESOURCE_WELL_KNOWN_PREFIX$1} namespace`);
|
|
1430
|
+
const authorizationServers = options.resourceMetadata.authorization_servers;
|
|
1431
|
+
if (!Array.isArray(authorizationServers) || authorizationServers.length === 0) throw new TypeError("resourceMetadata.authorization_servers must contain at least one issuer");
|
|
1432
|
+
for (const issuer of authorizationServers) {
|
|
1433
|
+
const issuerUrl = parseCanonicalUrl(issuer);
|
|
1434
|
+
if (!issuerUrl || issuerUrl.search || issuerUrl.hash) throw new TypeError("resourceMetadata.authorization_servers must contain canonical HTTPS issuer URLs (http is accepted only on a loopback host)");
|
|
1435
|
+
}
|
|
1436
|
+
const bearerMethods = options.resourceMetadata.bearer_methods_supported;
|
|
1437
|
+
if (bearerMethods !== void 0 && (bearerMethods.length !== 1 || bearerMethods[0] !== "header")) throw new TypeError("resourceMetadata.bearer_methods_supported only supports 'header'");
|
|
1438
|
+
const configuredScopes = options.resourceMetadata.scopes_supported ?? [];
|
|
1439
|
+
if (configuredScopes.some((scope) => !isValidScopeToken(scope))) throw new TypeError("resourceMetadata.scopes_supported must contain valid OAuth scope tokens");
|
|
1440
|
+
const resourceScopes = [...new Set(configuredScopes)].filter((scope) => scope !== "offline_access");
|
|
1441
|
+
return {
|
|
1442
|
+
resource,
|
|
1443
|
+
resourceUrl,
|
|
1444
|
+
metadataUrl: new URL(getResourceMetadataUrl(resource)),
|
|
1445
|
+
metadata: {
|
|
1446
|
+
resource,
|
|
1447
|
+
authorization_servers: [...authorizationServers],
|
|
1448
|
+
...resourceScopes.length ? { scopes_supported: resourceScopes } : {},
|
|
1449
|
+
bearer_methods_supported: bearerMethods ? [...bearerMethods] : ["header"],
|
|
1450
|
+
...options.resourceMetadata.resource_name !== void 0 ? { resource_name: options.resourceMetadata.resource_name } : {}
|
|
1451
|
+
}
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
function parseCanonicalUrl(value) {
|
|
1455
|
+
if (typeof value !== "string" || !validateResourceUri(value)) return null;
|
|
1456
|
+
let parsed;
|
|
1457
|
+
try {
|
|
1458
|
+
parsed = new URL(value);
|
|
1459
|
+
} catch {
|
|
1460
|
+
return null;
|
|
1461
|
+
}
|
|
1462
|
+
if (!hasAcceptedCanonicalScheme(parsed) || parsed.username || parsed.password || parsed.protocol !== parsed.protocol.toLowerCase() || parsed.hostname !== parsed.hostname.toLowerCase()) return null;
|
|
1463
|
+
if (parsed.href !== value && parsed.origin !== value) return null;
|
|
1464
|
+
return parsed;
|
|
1465
|
+
}
|
|
1466
|
+
function isValidScopeToken(scope) {
|
|
1467
|
+
return typeof scope === "string" && scope.length > 0 && /^[\x21\x23-\x5b\x5d-\x7e]+$/.test(scope);
|
|
1468
|
+
}
|
|
1469
|
+
function getResourceMetadataUrl(resource) {
|
|
1470
|
+
const parsed = new URL(resource);
|
|
1471
|
+
const suffix = parsed.pathname === "/" ? "" : parsed.pathname;
|
|
1472
|
+
return `${parsed.origin}${PROTECTED_RESOURCE_WELL_KNOWN_PREFIX$1}${suffix}${parsed.search}`;
|
|
1473
|
+
}
|
|
1474
|
+
/** The same test the combined provider applies to its handlers. */
|
|
1475
|
+
function isEntrypointClass(handler) {
|
|
1476
|
+
return typeof handler === "function" && handler.prototype instanceof WorkerEntrypoint;
|
|
1477
|
+
}
|
|
1478
|
+
function isProtectedResourceMetadataPath(url) {
|
|
1479
|
+
return url.pathname === PROTECTED_RESOURCE_WELL_KNOWN_PREFIX$1 || url.pathname.startsWith(`${PROTECTED_RESOURCE_WELL_KNOWN_PREFIX$1}/`);
|
|
1480
|
+
}
|
|
1481
|
+
function isMetadataUrlRequest(requestUrl, metadataUrl) {
|
|
1482
|
+
return requestUrl.origin === metadataUrl.origin && requestUrl.pathname === metadataUrl.pathname && requestCarriesResourceQuery(requestUrl, metadataUrl);
|
|
1483
|
+
}
|
|
1484
|
+
function isCanonicalResourceRequest(requestUrl, resourceUrl) {
|
|
1485
|
+
if (requestUrl.origin !== resourceUrl.origin) return false;
|
|
1486
|
+
if (!requestCarriesResourceQuery(requestUrl, resourceUrl)) return false;
|
|
1487
|
+
const resourcePath = resourceUrl.pathname;
|
|
1488
|
+
if (requestUrl.pathname === resourcePath) return true;
|
|
1489
|
+
if (resourcePath === "/") return requestUrl.pathname.startsWith("/");
|
|
1490
|
+
const descendantPrefix = resourcePath.endsWith("/") ? resourcePath : `${resourcePath}/`;
|
|
1491
|
+
return requestUrl.pathname.startsWith(descendantPrefix);
|
|
1492
|
+
}
|
|
1493
|
+
function parseBearerToken(authorization) {
|
|
1494
|
+
if (!authorization) return null;
|
|
1495
|
+
return /^Bearer[\t ]+([^\s,]+)$/i.exec(authorization)?.[1] || null;
|
|
1496
|
+
}
|
|
1497
|
+
function isValidTokenValidation(validation, canonicalResource) {
|
|
1498
|
+
if (!validation || typeof validation !== "object") return false;
|
|
1499
|
+
if (typeof validation.audience !== "string" || !resourceMatches(validation.audience, canonicalResource)) return false;
|
|
1500
|
+
if (!Object.prototype.hasOwnProperty.call(validation, "props")) return false;
|
|
1501
|
+
if (validation.expiresAt !== void 0) {
|
|
1502
|
+
if (typeof validation.expiresAt !== "number" || !Number.isFinite(validation.expiresAt)) return false;
|
|
1503
|
+
if (validation.expiresAt <= Date.now() / 1e3) return false;
|
|
1504
|
+
}
|
|
1505
|
+
if (validation.scope !== void 0) {
|
|
1506
|
+
if (!Array.isArray(validation.scope)) return false;
|
|
1507
|
+
if (validation.scope.some((scope) => typeof scope !== "string" || !isValidScopeToken(scope))) return false;
|
|
1508
|
+
}
|
|
1509
|
+
if (validation.userId !== void 0 && typeof validation.userId !== "string") return false;
|
|
1510
|
+
if (validation.clientId !== void 0 && typeof validation.clientId !== "string") return false;
|
|
1511
|
+
return true;
|
|
1512
|
+
}
|
|
1513
|
+
function createBearerChallenge(requestUrl, validated, invalidToken) {
|
|
1514
|
+
let challenge = "Bearer realm=\"OAuth\"";
|
|
1515
|
+
if (isCanonicalResourceRequest(requestUrl, validated.resourceUrl)) challenge += `, resource_metadata="${validated.metadataUrl.href}"`;
|
|
1516
|
+
if (invalidToken) challenge += ", error=\"invalid_token\"";
|
|
1517
|
+
const scopes = validated.metadata.scopes_supported;
|
|
1518
|
+
if (scopes && scopes.length > 0) challenge += `, scope="${scopes.join(" ")}"`;
|
|
1519
|
+
return new Response(null, {
|
|
1520
|
+
status: 401,
|
|
1521
|
+
headers: {
|
|
1522
|
+
...NO_CACHE_HEADERS$1,
|
|
1523
|
+
"WWW-Authenticate": challenge
|
|
1524
|
+
}
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
function createValidationUnavailableResponse() {
|
|
1528
|
+
return new Response(null, {
|
|
1529
|
+
status: 503,
|
|
1530
|
+
headers: NO_CACHE_HEADERS$1
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
function addCorsHeaders(response, request) {
|
|
1534
|
+
const origin = request.headers.get("Origin");
|
|
1535
|
+
if (!origin) return response;
|
|
1536
|
+
const withCors = new Response(response.body, response);
|
|
1537
|
+
withCors.headers.set("Access-Control-Allow-Origin", origin);
|
|
1538
|
+
withCors.headers.set("Access-Control-Allow-Methods", "*");
|
|
1539
|
+
withCors.headers.set("Access-Control-Allow-Headers", "Authorization, *");
|
|
1540
|
+
appendHeaderValue$1(withCors.headers, "Vary", "Origin");
|
|
1541
|
+
appendHeaderValue$1(withCors.headers, "Access-Control-Expose-Headers", "WWW-Authenticate");
|
|
1542
|
+
appendHeaderValue$1(withCors.headers, "Access-Control-Expose-Headers", "Retry-After");
|
|
1543
|
+
withCors.headers.set("Access-Control-Max-Age", "86400");
|
|
1544
|
+
return withCors;
|
|
1545
|
+
}
|
|
1546
|
+
function appendHeaderValue$1(headers, name, value) {
|
|
1547
|
+
const values = (headers.get(name) ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
1548
|
+
if (!values.some((item) => item.toLowerCase() === value.toLowerCase())) values.push(value);
|
|
1549
|
+
headers.set(name, values.join(", "));
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1229
1552
|
//#endregion
|
|
1230
1553
|
//#region src/oauth-provider.ts
|
|
1231
1554
|
const PROTECTED_RESOURCE_WELL_KNOWN_PREFIX = "/.well-known/oauth-protected-resource";
|
|
@@ -1254,7 +1577,7 @@ let GrantType = /* @__PURE__ */ function(GrantType$1) {
|
|
|
1254
1577
|
return GrantType$1;
|
|
1255
1578
|
}({});
|
|
1256
1579
|
function toPublicClientInfo(client) {
|
|
1257
|
-
const { authMethodExplicit: _explicit, ...publicClient } = client;
|
|
1580
|
+
const { authMethodExplicit: _explicit, registrationExpiresAt: _expiry, ...publicClient } = client;
|
|
1258
1581
|
return publicClient;
|
|
1259
1582
|
}
|
|
1260
1583
|
function isClientAuthMethodAllowed(client, presentedMethod, isClientMetadataDocument) {
|
|
@@ -1300,6 +1623,66 @@ var OAuthProvider = class {
|
|
|
1300
1623
|
}
|
|
1301
1624
|
};
|
|
1302
1625
|
/**
|
|
1626
|
+
* The authorization-server role on its own. It serves discovery, token, revocation and
|
|
1627
|
+
* registration endpoints from `fetch()`, exposes the interactive authorization flow through
|
|
1628
|
+
* `getOAuthApi()`, and validates its access tokens for any declared resource through
|
|
1629
|
+
* `validateToken()`. Resources are hosted, in this Worker or another, by
|
|
1630
|
+
* `OAuthResourceServer`, whose `validateToken` points back here.
|
|
1631
|
+
*/
|
|
1632
|
+
var OAuthAuthorizationServer = class {
|
|
1633
|
+
#impl;
|
|
1634
|
+
constructor(options) {
|
|
1635
|
+
const { issuer, resources, defaultResource, legacyGrantResource, authorizeEndpoint, tokenEndpoint, clientRegistrationEndpoint, ...commonOptions } = options;
|
|
1636
|
+
this.#impl = new OAuthProviderImpl({
|
|
1637
|
+
...commonOptions,
|
|
1638
|
+
defaultHandler: { fetch: () => new Response(null, { status: 404 }) },
|
|
1639
|
+
authorizationServer: {
|
|
1640
|
+
issuer,
|
|
1641
|
+
resources,
|
|
1642
|
+
authorizeEndpoint,
|
|
1643
|
+
tokenEndpoint,
|
|
1644
|
+
clientRegistrationEndpoint,
|
|
1645
|
+
defaultResource,
|
|
1646
|
+
legacyGrantResource
|
|
1647
|
+
}
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
/**
|
|
1651
|
+
* Validate an access token for one declared resource: the token's KV record is loaded, its
|
|
1652
|
+
* audience checked against `resource`, and its props decrypted. Resolves `null` for a token
|
|
1653
|
+
* that is not valid for that resource, and rejects for a resource this server does not
|
|
1654
|
+
* declare.
|
|
1655
|
+
*
|
|
1656
|
+
* This is what a resource server calls, whether it shares this Worker or reaches it over a
|
|
1657
|
+
* Service Binding. For the latter, expose it from a `WorkerEntrypoint`:
|
|
1658
|
+
*
|
|
1659
|
+
* ```ts
|
|
1660
|
+
* export default class AuthServer extends WorkerEntrypoint<Env> {
|
|
1661
|
+
* fetch(request: Request) {
|
|
1662
|
+
* return authorizationServer.fetch(request, this.env, this.ctx);
|
|
1663
|
+
* }
|
|
1664
|
+
* validateToken(resource: string, token: string) {
|
|
1665
|
+
* return authorizationServer.validateToken(resource, token, this.env);
|
|
1666
|
+
* }
|
|
1667
|
+
* }
|
|
1668
|
+
* ```
|
|
1669
|
+
*/
|
|
1670
|
+
validateToken(resource, token, env) {
|
|
1671
|
+
return this.#impl.validateAccessToken(token, resource, env);
|
|
1672
|
+
}
|
|
1673
|
+
/** Serve protocol-owned authorization-server endpoints, excluding the application authorization UI. */
|
|
1674
|
+
fetch(request, env, ctx) {
|
|
1675
|
+
return this.#impl.fetchAuthorizationServer(request, env, ctx);
|
|
1676
|
+
}
|
|
1677
|
+
/** Obtain helpers for scheduled jobs or custom code outside a fetch dispatch. */
|
|
1678
|
+
getOAuthApi(env) {
|
|
1679
|
+
return this.#impl.createOAuthHelpers(env);
|
|
1680
|
+
}
|
|
1681
|
+
purgeExpiredData(env, options) {
|
|
1682
|
+
return this.#impl.createOAuthHelpers(env).purgeExpiredData(options);
|
|
1683
|
+
}
|
|
1684
|
+
};
|
|
1685
|
+
/**
|
|
1303
1686
|
* Gets OAuthHelpers for the given environment
|
|
1304
1687
|
* @param options - Configuration options for the OAuth provider
|
|
1305
1688
|
* @param env - Cloudflare Worker environment variables
|
|
@@ -1322,37 +1705,95 @@ var OAuthProviderImpl = class {
|
|
|
1322
1705
|
* @param options - Configuration options for the provider
|
|
1323
1706
|
*/
|
|
1324
1707
|
constructor(options) {
|
|
1708
|
+
this.errorResponses = /* @__PURE__ */ new WeakSet();
|
|
1325
1709
|
this.typedApiHandlers = [];
|
|
1326
|
-
const hasSingleHandlerConfig = !!(options.apiRoute && options.apiHandler);
|
|
1327
|
-
const hasMultiHandlerConfig = !!options.apiHandlers;
|
|
1328
|
-
if (hasSingleHandlerConfig && hasMultiHandlerConfig) throw new TypeError("Cannot use both apiRoute/apiHandler and apiHandlers. Use either apiRoute + apiHandler OR apiHandlers, not both.");
|
|
1329
|
-
if (!hasSingleHandlerConfig && !hasMultiHandlerConfig) throw new TypeError("Must provide either apiRoute + apiHandler OR apiHandlers. No API route configuration provided.");
|
|
1330
1710
|
this.typedDefaultHandler = this.validateHandler(options.defaultHandler, "defaultHandler");
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
}
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1711
|
+
const roleBased = "authorizationServer" in options;
|
|
1712
|
+
if (!roleBased && "resourceMatchOriginOnly" in options) throw new TypeError("resourceMatchOriginOnly was removed in 1.0. Configure resourceMetadata.resource; audiences are compared exactly against that canonical resource. See the migration guide.");
|
|
1713
|
+
let normalizedOptions;
|
|
1714
|
+
let configuredResourceServers;
|
|
1715
|
+
if (roleBased) {
|
|
1716
|
+
const { authorizationServer: authorizationServer$1, ...commonOptions } = options;
|
|
1717
|
+
this.validateAuthorizationServerIssuer(authorizationServer$1.issuer);
|
|
1718
|
+
this.explicitIssuer = authorizationServer$1.issuer;
|
|
1719
|
+
normalizedOptions = {
|
|
1720
|
+
...commonOptions,
|
|
1721
|
+
authorizeEndpoint: authorizationServer$1.authorizeEndpoint,
|
|
1722
|
+
tokenEndpoint: authorizationServer$1.tokenEndpoint,
|
|
1723
|
+
clientRegistrationEndpoint: authorizationServer$1.clientRegistrationEndpoint
|
|
1724
|
+
};
|
|
1725
|
+
const declared = authorizationServer$1.resources;
|
|
1726
|
+
if (!Array.isArray(declared) || declared.length === 0 || declared.some((value) => typeof value !== "string")) throw new TypeError("resources must list at least one canonical protected resource identifier");
|
|
1727
|
+
const duplicate = declared.find((resource, index) => declared.slice(0, index).some((earlier) => isExactResource(earlier, resource)));
|
|
1728
|
+
if (duplicate !== void 0) throw new TypeError(`resources must be unique; duplicate ${duplicate}`);
|
|
1729
|
+
configuredResourceServers = declared.map((resource) => ({ resourceMetadata: { resource } }));
|
|
1730
|
+
} else {
|
|
1731
|
+
this.explicitIssuer = void 0;
|
|
1732
|
+
normalizedOptions = options;
|
|
1733
|
+
configuredResourceServers = [{
|
|
1734
|
+
resourceMetadata: options.resourceMetadata,
|
|
1735
|
+
resolveExternalToken: options.resolveExternalToken
|
|
1736
|
+
}];
|
|
1344
1737
|
}
|
|
1345
|
-
this.validateEndpoint(options.authorizeEndpoint, "authorizeEndpoint");
|
|
1346
|
-
this.validateEndpoint(options.tokenEndpoint, "tokenEndpoint");
|
|
1347
|
-
if (options.clientRegistrationEndpoint) this.validateEndpoint(options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
|
|
1348
1738
|
this.options = {
|
|
1349
1739
|
accessTokenTTL: DEFAULT_ACCESS_TOKEN_TTL,
|
|
1350
1740
|
refreshTokenTTL: DEFAULT_REFRESH_TOKEN_TTL,
|
|
1351
1741
|
clientRegistrationTTL: DEFAULT_CLIENT_REGISTRATION_TTL,
|
|
1352
1742
|
onError: ({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`),
|
|
1353
|
-
...
|
|
1743
|
+
...normalizedOptions
|
|
1354
1744
|
};
|
|
1355
|
-
|
|
1745
|
+
this.validateEndpoint(this.options.authorizeEndpoint, "authorizeEndpoint");
|
|
1746
|
+
this.validateEndpoint(this.options.tokenEndpoint, "tokenEndpoint");
|
|
1747
|
+
if (this.options.clientRegistrationEndpoint) this.validateEndpoint(this.options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
|
|
1748
|
+
if (roleBased) this.validateAuthorizationServerRouteIsolation();
|
|
1749
|
+
this.resourceServers = configuredResourceServers.map(({ resourceMetadata, resolveExternalToken }) => {
|
|
1750
|
+
const metadata = this.snapshotResourceMetadata(resourceMetadata);
|
|
1751
|
+
this.validateResourceMetadataOptions(metadata);
|
|
1752
|
+
return {
|
|
1753
|
+
resourceMetadata: metadata,
|
|
1754
|
+
resolveExternalToken
|
|
1755
|
+
};
|
|
1756
|
+
});
|
|
1757
|
+
for (const [index, server] of this.resourceServers.entries()) if (this.resourceServers.slice(0, index).some((other) => isExactResource(other.resourceMetadata.resource, server.resourceMetadata.resource))) throw new TypeError(`resources must be unique; duplicate ${server.resourceMetadata.resource}`);
|
|
1758
|
+
if (!roleBased) {
|
|
1759
|
+
const legacyOptions = options;
|
|
1760
|
+
const hasSingleHandlerConfig = !!(legacyOptions.apiRoute && legacyOptions.apiHandler);
|
|
1761
|
+
const hasMultiHandlerConfig = !!legacyOptions.apiHandlers;
|
|
1762
|
+
if (hasSingleHandlerConfig && hasMultiHandlerConfig) throw new TypeError("Cannot use both apiRoute/apiHandler and apiHandlers. Use either apiRoute + apiHandler OR apiHandlers, not both.");
|
|
1763
|
+
if (!hasSingleHandlerConfig && !hasMultiHandlerConfig) throw new TypeError("Must provide either apiRoute + apiHandler OR apiHandlers. No API route configuration provided.");
|
|
1764
|
+
const resourceServer = this.resourceServers[0];
|
|
1765
|
+
if (hasSingleHandlerConfig) {
|
|
1766
|
+
const handler = this.validateHandler(legacyOptions.apiHandler, "apiHandler");
|
|
1767
|
+
(Array.isArray(legacyOptions.apiRoute) ? legacyOptions.apiRoute : [legacyOptions.apiRoute]).forEach((route, index) => {
|
|
1768
|
+
this.validateEndpoint(route, Array.isArray(legacyOptions.apiRoute) ? `apiRoute[${index}]` : "apiRoute");
|
|
1769
|
+
this.typedApiHandlers.push({
|
|
1770
|
+
route,
|
|
1771
|
+
handler,
|
|
1772
|
+
resourceServer
|
|
1773
|
+
});
|
|
1774
|
+
});
|
|
1775
|
+
} else for (const [route, rawHandler] of Object.entries(legacyOptions.apiHandlers)) {
|
|
1776
|
+
this.validateEndpoint(route, `apiHandlers key: ${route}`);
|
|
1777
|
+
const handler = this.validateHandler(rawHandler, `apiHandlers[${route}]`);
|
|
1778
|
+
this.typedApiHandlers.push({
|
|
1779
|
+
route,
|
|
1780
|
+
handler,
|
|
1781
|
+
resourceServer
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
for (const { route } of this.typedApiHandlers) this.assertRouteCoveredByResource(route, resourceServer.resourceMetadata.resource);
|
|
1785
|
+
}
|
|
1786
|
+
const resources = this.resourceServers.map((server) => server.resourceMetadata.resource);
|
|
1787
|
+
const soleResource = resources.length === 1 ? resources[0] : void 0;
|
|
1788
|
+
const authorizationServer = roleBased ? options.authorizationServer : void 0;
|
|
1789
|
+
this.configuredDefaultAuthorizationResource = authorizationServer?.defaultResource ?? soleResource;
|
|
1790
|
+
this.configuredLegacyGrantResource = authorizationServer?.legacyGrantResource ?? soleResource;
|
|
1791
|
+
if (roleBased) {
|
|
1792
|
+
this.getDefaultAuthorizationResource();
|
|
1793
|
+
this.getLegacyGrantResource();
|
|
1794
|
+
}
|
|
1795
|
+
if (!isValidAccessTokenTTL(this.options.accessTokenTTL)) throw new TypeError(`accessTokenTTL must be an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds (Cloudflare KV's minimum expiration window).`);
|
|
1796
|
+
if (this.options.refreshTokenIdleTTL !== void 0 && !isValidAccessTokenTTL(this.options.refreshTokenIdleTTL)) throw new TypeError(`refreshTokenIdleTTL must be an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds (Cloudflare KV's minimum expiration window).`);
|
|
1356
1797
|
this.serverCapabilities = buildOAuthServerCapabilities({
|
|
1357
1798
|
allowImplicitFlow: !!this.options.allowImplicitFlow,
|
|
1358
1799
|
allowPlainPKCE: this.options.allowPlainPKCE === true,
|
|
@@ -1360,7 +1801,6 @@ var OAuthProviderImpl = class {
|
|
|
1360
1801
|
enterpriseManagedAuthorization: !!this.options.enterpriseManagedAuthorization
|
|
1361
1802
|
});
|
|
1362
1803
|
validateAuthorizationServerScopes(this.options.scopesSupported);
|
|
1363
|
-
this.validateResourceMetadataOptions(this.options.resourceMetadata);
|
|
1364
1804
|
this.validateEmaOptions(this.options.enterpriseManagedAuthorization);
|
|
1365
1805
|
if (this.options.enterpriseManagedAuthorization) {
|
|
1366
1806
|
this.jwksProvider = createDefaultJwksProvider({ cacheTtlSeconds: this.options.enterpriseManagedAuthorization.jwksCacheTtlSeconds });
|
|
@@ -1376,11 +1816,90 @@ var OAuthProviderImpl = class {
|
|
|
1376
1816
|
validateEndpoint(endpoint, name) {
|
|
1377
1817
|
if (this.isPath(endpoint)) {
|
|
1378
1818
|
if (!endpoint.startsWith("/")) throw new TypeError(`${name} path must be an absolute path starting with /`);
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1819
|
+
if (this.explicitIssuer && new URL(endpoint, new URL(this.explicitIssuer).origin).hash) throw new TypeError(`${name} must not contain a fragment`);
|
|
1820
|
+
} else {
|
|
1821
|
+
let parsed;
|
|
1822
|
+
try {
|
|
1823
|
+
parsed = new URL(endpoint);
|
|
1824
|
+
} catch (e) {
|
|
1825
|
+
throw new TypeError(`${name} must be either an absolute path starting with / or a valid URL`);
|
|
1826
|
+
}
|
|
1827
|
+
if (this.explicitIssuer && (!hasAcceptedCanonicalScheme(parsed) || parsed.username || parsed.password || parsed.hash)) throw new TypeError(`${name} must be an absolute HTTPS URL without userinfo or a fragment (http is accepted only on a loopback host)`);
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
/** Validate the explicit issuer used to host-gate the role-based AS. */
|
|
1831
|
+
validateAuthorizationServerIssuer(issuer) {
|
|
1832
|
+
let parsed;
|
|
1833
|
+
try {
|
|
1834
|
+
parsed = new URL(issuer);
|
|
1835
|
+
} catch {
|
|
1836
|
+
throw new TypeError("authorizationServer.issuer must be a canonical absolute HTTPS URL");
|
|
1383
1837
|
}
|
|
1838
|
+
if (!validateResourceUri(issuer) || !hasAcceptedCanonicalScheme(parsed) || parsed.username || parsed.password || parsed.search || parsed.hash || foldResourceSchemeAndHost(issuer) !== issuer || parsed.href !== issuer && parsed.origin !== issuer) throw new TypeError("authorizationServer.issuer must be a canonical absolute HTTPS URL (http is accepted only on a loopback host)");
|
|
1839
|
+
}
|
|
1840
|
+
/** Reject exact collisions between protocol endpoints owned by the AS fetch surface. */
|
|
1841
|
+
validateAuthorizationServerRouteIsolation() {
|
|
1842
|
+
const issuerUrl = new URL(this.explicitIssuer);
|
|
1843
|
+
const discoveryEndpoint = this.getAuthorizationServerMetadataUrl(issuerUrl);
|
|
1844
|
+
const tokenUrl = new URL(this.getFullEndpointUrl(this.options.tokenEndpoint, issuerUrl));
|
|
1845
|
+
if (this.matchEndpoint(tokenUrl, discoveryEndpoint)) throw new TypeError("tokenEndpoint must not collide with the authorization server metadata endpoint");
|
|
1846
|
+
if (!this.options.clientRegistrationEndpoint) return;
|
|
1847
|
+
const registrationUrl = new URL(this.getFullEndpointUrl(this.options.clientRegistrationEndpoint, issuerUrl));
|
|
1848
|
+
if (this.matchEndpoint(registrationUrl, this.options.tokenEndpoint)) throw new TypeError("clientRegistrationEndpoint must not collide with tokenEndpoint");
|
|
1849
|
+
if (this.matchEndpoint(registrationUrl, discoveryEndpoint)) throw new TypeError("clientRegistrationEndpoint must not collide with the authorization server metadata endpoint");
|
|
1850
|
+
}
|
|
1851
|
+
/**
|
|
1852
|
+
* A protected route must be the canonical resource path or a path-boundary descendant of
|
|
1853
|
+
* it. Anything else can never validate a token, because every token is bound to the
|
|
1854
|
+
* canonical resource, so it would be a permanently 401 zone.
|
|
1855
|
+
*/
|
|
1856
|
+
assertRouteCoveredByResource(route, resource) {
|
|
1857
|
+
const resourceUrl = new URL(resource);
|
|
1858
|
+
let routePath;
|
|
1859
|
+
if (this.isPath(route)) routePath = route.split("?")[0];
|
|
1860
|
+
else {
|
|
1861
|
+
const routeUrl = new URL(route);
|
|
1862
|
+
if (routeUrl.origin !== resourceUrl.origin) throw new TypeError(`API route ${route} is not covered by resourceMetadata.resource ${resource}. An absolute protected route must be on the resource's origin.`);
|
|
1863
|
+
for (const [name, value] of resourceUrl.searchParams) {
|
|
1864
|
+
const routeValues = routeUrl.searchParams.getAll(name);
|
|
1865
|
+
if (routeValues.length > 0 && !routeValues.includes(value)) throw new TypeError(`API route ${route} is not covered by resourceMetadata.resource ${resource}. An absolute protected route must carry the resource's query parameters.`);
|
|
1866
|
+
}
|
|
1867
|
+
routePath = routeUrl.pathname;
|
|
1868
|
+
}
|
|
1869
|
+
if (isPathDescendant(routePath, resourceUrl.pathname)) return;
|
|
1870
|
+
throw new TypeError(`API route ${route} is not covered by resourceMetadata.resource ${resource}. Protected routes must be the canonical resource path or a descendant of it; use ${resourceUrl.origin} as the resource to cover every path on that origin.`);
|
|
1871
|
+
}
|
|
1872
|
+
/** Resolve a configured policy value to the registry's canonical spelling. */
|
|
1873
|
+
resolveConfiguredResourceDefault(resource, name) {
|
|
1874
|
+
if (resource === void 0) return void 0;
|
|
1875
|
+
const configured = this.findConfiguredResource(resource);
|
|
1876
|
+
if (!configured) throw new TypeError(`${name} must name one of the configured protected resources`);
|
|
1877
|
+
return configured;
|
|
1878
|
+
}
|
|
1879
|
+
getDefaultAuthorizationResource() {
|
|
1880
|
+
const sole = this.resourceServers.length === 1 ? this.resourceServers[0].resourceMetadata.resource : void 0;
|
|
1881
|
+
return this.resolveConfiguredResourceDefault(this.configuredDefaultAuthorizationResource ?? sole, "defaultResource");
|
|
1882
|
+
}
|
|
1883
|
+
getLegacyGrantResource() {
|
|
1884
|
+
const sole = this.resourceServers.length === 1 ? this.resourceServers[0].resourceMetadata.resource : void 0;
|
|
1885
|
+
return this.resolveConfiguredResourceDefault(this.configuredLegacyGrantResource ?? sole, "legacyGrantResource");
|
|
1886
|
+
}
|
|
1887
|
+
/**
|
|
1888
|
+
* The audience a stored access token is treated as bound to.
|
|
1889
|
+
*
|
|
1890
|
+
* A token issued before resource binding has no stored audience. It keeps
|
|
1891
|
+
* working at the server-selected migration resource (the sole resource, or
|
|
1892
|
+
* `legacyGrantResource` in a multi-resource deployment) until it expires;
|
|
1893
|
+
* refresh binds its grant and returns a bound replacement. The client never
|
|
1894
|
+
* chooses this destination. Without a migration resource the token is
|
|
1895
|
+
* treated as unbound and rejected.
|
|
1896
|
+
*
|
|
1897
|
+
* The destination is deployment policy, not an issuance-time claim: changing
|
|
1898
|
+
* `legacyGrantResource` re-targets every surviving unbound token and grant.
|
|
1899
|
+
* Keep it fixed for the length of the migration window.
|
|
1900
|
+
*/
|
|
1901
|
+
resolveStoredTokenAudience(audience) {
|
|
1902
|
+
return audience === void 0 ? this.getLegacyGrantResource() : audience;
|
|
1384
1903
|
}
|
|
1385
1904
|
/**
|
|
1386
1905
|
* Validates that a handler is either an ExportedHandler or a class extending WorkerEntrypoint
|
|
@@ -1400,10 +1919,22 @@ var OAuthProviderImpl = class {
|
|
|
1400
1919
|
};
|
|
1401
1920
|
throw new TypeError(`${name} must be either an ExportedHandler object with a fetch method or a class extending WorkerEntrypoint`);
|
|
1402
1921
|
}
|
|
1922
|
+
/** Snapshot caller-owned metadata so later mutation cannot change routing or policy. */
|
|
1923
|
+
snapshotResourceMetadata(metadata) {
|
|
1924
|
+
return {
|
|
1925
|
+
...metadata,
|
|
1926
|
+
...metadata?.authorization_servers ? { authorization_servers: [...metadata.authorization_servers] } : {},
|
|
1927
|
+
...metadata?.scopes_supported ? { scopes_supported: [...metadata.scopes_supported] } : {},
|
|
1928
|
+
...metadata?.bearer_methods_supported ? { bearer_methods_supported: [...metadata.bearer_methods_supported] } : {}
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1403
1931
|
/** Validate configured RFC 9728 protected resource metadata. */
|
|
1404
1932
|
validateResourceMetadataOptions(options) {
|
|
1405
|
-
if (!options)
|
|
1406
|
-
if (options.resource !==
|
|
1933
|
+
if (!options || !validateResourceUri(options.resource) || !hasAcceptedCanonicalScheme(new URL(options.resource))) throw new TypeError("resourceMetadata.resource is required and must be an absolute HTTPS URI without a fragment (http is accepted only on a loopback host)");
|
|
1934
|
+
if (foldResourceSchemeAndHost(options.resource) !== options.resource) throw new TypeError("resourceMetadata.resource must use a lowercase scheme and lowercase host");
|
|
1935
|
+
const parsedResource = new URL(options.resource);
|
|
1936
|
+
if (parsedResource.username || parsedResource.password || parsedResource.href !== options.resource && parsedResource.origin !== options.resource) throw new TypeError("resourceMetadata.resource must use canonical URL serialization without userinfo, a default port, or dot segments");
|
|
1937
|
+
if (parsedResource.pathname === PROTECTED_RESOURCE_WELL_KNOWN_PREFIX || parsedResource.pathname.startsWith(`${PROTECTED_RESOURCE_WELL_KNOWN_PREFIX}/`)) throw new TypeError(`resourceMetadata.resource must not be inside the ${PROTECTED_RESOURCE_WELL_KNOWN_PREFIX} namespace`);
|
|
1407
1938
|
if (options.authorization_servers !== void 0) {
|
|
1408
1939
|
if (options.authorization_servers.length === 0) throw new TypeError("resourceMetadata.authorization_servers must contain at least one issuer");
|
|
1409
1940
|
for (const issuer of options.authorization_servers) {
|
|
@@ -1413,7 +1944,7 @@ var OAuthProviderImpl = class {
|
|
|
1413
1944
|
} catch {
|
|
1414
1945
|
throw new TypeError("resourceMetadata.authorization_servers must contain valid HTTPS issuer URLs");
|
|
1415
1946
|
}
|
|
1416
|
-
if (parsed.
|
|
1947
|
+
if (!validateResourceUri(issuer) || !hasAcceptedCanonicalScheme(parsed) || parsed.username || parsed.password || foldResourceSchemeAndHost(issuer) !== issuer || parsed.href !== issuer && parsed.origin !== issuer || issuer.includes("?") || issuer.includes("#")) throw new TypeError("resourceMetadata.authorization_servers must contain valid HTTPS issuer URLs (http is accepted only on a loopback host)");
|
|
1417
1948
|
}
|
|
1418
1949
|
}
|
|
1419
1950
|
if (options.scopes_supported?.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("resourceMetadata.scopes_supported must contain valid OAuth scope tokens");
|
|
@@ -1431,7 +1962,6 @@ var OAuthProviderImpl = class {
|
|
|
1431
1962
|
if (!options) return;
|
|
1432
1963
|
if (typeof options.trustedIssuers !== "function") throw new TypeError("enterpriseManagedAuthorization.trustedIssuers must be a resolver function: (input) => EmaTrustedIssuer | null");
|
|
1433
1964
|
if (typeof options.mapClaims !== "function") throw new TypeError("enterpriseManagedAuthorization.mapClaims must be a function");
|
|
1434
|
-
if (!this.options.resourceMetadata?.resource) throw new TypeError("enterpriseManagedAuthorization requires resourceMetadata.resource to be configured");
|
|
1435
1965
|
if (options.jwksCacheTtlSeconds !== void 0 && options.jwksCacheTtlSeconds <= 0) throw new TypeError("enterpriseManagedAuthorization.jwksCacheTtlSeconds must be greater than 0");
|
|
1436
1966
|
if (options.clockSkewSeconds !== void 0 && options.clockSkewSeconds < 0) throw new TypeError("enterpriseManagedAuthorization.clockSkewSeconds must be non-negative");
|
|
1437
1967
|
if (options.maxAssertionLifetimeSeconds !== void 0 && options.maxAssertionLifetimeSeconds <= 0) throw new TypeError("enterpriseManagedAuthorization.maxAssertionLifetimeSeconds must be greater than 0");
|
|
@@ -1445,20 +1975,38 @@ var OAuthProviderImpl = class {
|
|
|
1445
1975
|
* @returns A Promise resolving to an HTTP Response
|
|
1446
1976
|
*/
|
|
1447
1977
|
async fetch(request, env, ctx) {
|
|
1978
|
+
return this.fetchForRoles(request, env, ctx, "combined");
|
|
1979
|
+
}
|
|
1980
|
+
async fetchAuthorizationServer(request, env, ctx) {
|
|
1981
|
+
return this.fetchForRoles(request, env, ctx, "authorization-server");
|
|
1982
|
+
}
|
|
1983
|
+
async fetchForRoles(request, env, ctx, role) {
|
|
1448
1984
|
const url = new URL(request.url);
|
|
1985
|
+
const servesProtectedResources = role === "combined";
|
|
1986
|
+
const metadataResourceServer = servesProtectedResources ? this.findResourceServerForMetadataUrl(url) : void 0;
|
|
1987
|
+
const apiRoute = servesProtectedResources ? this.findApiRouteForUrl(url) : void 0;
|
|
1449
1988
|
if (request.method === "OPTIONS") {
|
|
1450
|
-
if (this.
|
|
1989
|
+
if (apiRoute !== void 0 || this.isAuthorizationServerMetadataRequest(url) || servesProtectedResources && this.isProtectedResourceMetadataPath(url) || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return this.addCorsHeaders(new Response(null, {
|
|
1451
1990
|
status: 204,
|
|
1452
1991
|
headers: { "Content-Length": "0" }
|
|
1453
1992
|
}), request);
|
|
1454
1993
|
}
|
|
1455
|
-
if (url
|
|
1994
|
+
if (this.isAuthorizationServerMetadataRequest(url)) {
|
|
1995
|
+
if (request.method !== "GET" && request.method !== "HEAD") return this.addCorsHeaders(new Response(null, {
|
|
1996
|
+
status: 405,
|
|
1997
|
+
headers: { Allow: "GET, HEAD, OPTIONS" }
|
|
1998
|
+
}), request);
|
|
1456
1999
|
const response = await this.handleMetadataDiscovery(url);
|
|
1457
|
-
return this.addCorsHeaders(response, request);
|
|
2000
|
+
return this.addCorsHeaders(withoutBodyForHead(request, response), request);
|
|
1458
2001
|
}
|
|
1459
|
-
if (this.
|
|
1460
|
-
|
|
1461
|
-
return this.addCorsHeaders(
|
|
2002
|
+
if (servesProtectedResources && this.isProtectedResourceMetadataPath(url)) {
|
|
2003
|
+
if (!metadataResourceServer) return this.addCorsHeaders(new Response(null, { status: 404 }), request);
|
|
2004
|
+
if (request.method !== "GET" && request.method !== "HEAD") return this.addCorsHeaders(new Response(null, {
|
|
2005
|
+
status: 405,
|
|
2006
|
+
headers: { Allow: "GET, HEAD, OPTIONS" }
|
|
2007
|
+
}), request);
|
|
2008
|
+
const response = this.handleProtectedResourceMetadata(url, metadataResourceServer);
|
|
2009
|
+
return this.addCorsHeaders(withoutBodyForHead(request, response), request);
|
|
1462
2010
|
}
|
|
1463
2011
|
if (this.isTokenEndpoint(url)) {
|
|
1464
2012
|
const parsed = await this.parseTokenEndpointRequest(request, env);
|
|
@@ -1466,16 +2014,18 @@ var OAuthProviderImpl = class {
|
|
|
1466
2014
|
let response;
|
|
1467
2015
|
if (parsed.isRevocationRequest) response = await this.handleRevocationRequest(parsed.body, parsed.clientInfo, env);
|
|
1468
2016
|
else response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env, url, request);
|
|
2017
|
+
if (response.ok && !this.errorResponses.has(response)) await this.renewClientRegistrationIfDue(env, parsed.clientInfo, Math.floor(Date.now() / 1e3));
|
|
1469
2018
|
return this.addCorsHeaders(response, request);
|
|
1470
2019
|
}
|
|
1471
2020
|
if (this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) {
|
|
1472
2021
|
const response = await this.handleClientRegistration(request, env);
|
|
1473
2022
|
return this.addCorsHeaders(response, request);
|
|
1474
2023
|
}
|
|
1475
|
-
if (
|
|
1476
|
-
const response = await this.handleApiRequest(request, env, ctx);
|
|
2024
|
+
if (apiRoute) {
|
|
2025
|
+
const response = await this.handleApiRequest(request, env, ctx, apiRoute);
|
|
1477
2026
|
return this.addCorsHeaders(response, request);
|
|
1478
2027
|
}
|
|
2028
|
+
if (typeof role === "object") return new Response(null, { status: 404 });
|
|
1479
2029
|
if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
|
|
1480
2030
|
if (this.typedDefaultHandler.type === HandlerType.EXPORTED_HANDLER) return this.typedDefaultHandler.handler.fetch(request, env, ctx);
|
|
1481
2031
|
return new this.typedDefaultHandler.handler(ctx, env).fetch(request);
|
|
@@ -1512,6 +2062,20 @@ var OAuthProviderImpl = class {
|
|
|
1512
2062
|
}
|
|
1513
2063
|
};
|
|
1514
2064
|
}
|
|
2065
|
+
async validateAccessToken(token, resource, env) {
|
|
2066
|
+
const configuredResource = this.findConfiguredResource(resource);
|
|
2067
|
+
if (!configuredResource) throw new TypeError("resource must name one registered protected resource");
|
|
2068
|
+
const summary = await this.unwrapToken(token, env);
|
|
2069
|
+
if (!summary || !isExactResource(this.resolveStoredTokenAudience(summary.audience), configuredResource)) return null;
|
|
2070
|
+
return {
|
|
2071
|
+
props: summary.grant.props,
|
|
2072
|
+
audience: configuredResource,
|
|
2073
|
+
expiresAt: summary.expiresAt,
|
|
2074
|
+
scope: summary.scope,
|
|
2075
|
+
userId: summary.userId,
|
|
2076
|
+
clientId: summary.grant.clientId
|
|
2077
|
+
};
|
|
2078
|
+
}
|
|
1515
2079
|
/**
|
|
1516
2080
|
* Determines if an endpoint configuration is a path or a full URL
|
|
1517
2081
|
* @param endpoint - The endpoint configuration
|
|
@@ -1526,12 +2090,26 @@ var OAuthProviderImpl = class {
|
|
|
1526
2090
|
* @param endpoint - The endpoint pattern (full URL or path)
|
|
1527
2091
|
* @returns True if the URL matches the endpoint pattern
|
|
1528
2092
|
*/
|
|
1529
|
-
matchEndpoint(url, endpoint) {
|
|
1530
|
-
if (this.
|
|
1531
|
-
|
|
1532
|
-
const
|
|
1533
|
-
return url.hostname ===
|
|
2093
|
+
matchEndpoint(url, endpoint, allowAdditionalQuery = false) {
|
|
2094
|
+
if (!this.explicitIssuer) {
|
|
2095
|
+
if (this.isPath(endpoint)) return url.pathname === endpoint;
|
|
2096
|
+
const legacyEndpoint = new URL(endpoint);
|
|
2097
|
+
return url.hostname === legacyEndpoint.hostname && url.pathname === legacyEndpoint.pathname;
|
|
2098
|
+
}
|
|
2099
|
+
const endpointUrl = new URL(this.getFullEndpointUrl(endpoint, url));
|
|
2100
|
+
if (url.origin !== endpointUrl.origin || url.pathname !== endpointUrl.pathname) return false;
|
|
2101
|
+
const unmatchedActualQuery = [...url.searchParams.entries()];
|
|
2102
|
+
const configuredQueryNames = /* @__PURE__ */ new Set();
|
|
2103
|
+
for (const [name, value] of endpointUrl.searchParams) {
|
|
2104
|
+
configuredQueryNames.add(name);
|
|
2105
|
+
const match = unmatchedActualQuery.findIndex(([actualName, actualValue]) => {
|
|
2106
|
+
return actualName === name && actualValue === value;
|
|
2107
|
+
});
|
|
2108
|
+
if (match === -1) return false;
|
|
2109
|
+
unmatchedActualQuery.splice(match, 1);
|
|
1534
2110
|
}
|
|
2111
|
+
if (!allowAdditionalQuery) return unmatchedActualQuery.length === 0;
|
|
2112
|
+
return unmatchedActualQuery.every(([name]) => !configuredQueryNames.has(name));
|
|
1535
2113
|
}
|
|
1536
2114
|
/**
|
|
1537
2115
|
* Checks if a URL matches the configured token endpoint
|
|
@@ -1539,7 +2117,10 @@ var OAuthProviderImpl = class {
|
|
|
1539
2117
|
* @returns True if the URL matches the token endpoint
|
|
1540
2118
|
*/
|
|
1541
2119
|
isTokenEndpoint(url) {
|
|
1542
|
-
return this.matchEndpoint(url, this.options.tokenEndpoint);
|
|
2120
|
+
return this.matchEndpoint(url, this.options.tokenEndpoint, true);
|
|
2121
|
+
}
|
|
2122
|
+
isAuthorizationEndpointRequest(url) {
|
|
2123
|
+
return this.matchEndpoint(url, this.options.authorizeEndpoint, true);
|
|
1543
2124
|
}
|
|
1544
2125
|
/**
|
|
1545
2126
|
* Checks if a URL matches the configured client registration endpoint
|
|
@@ -1548,28 +2129,37 @@ var OAuthProviderImpl = class {
|
|
|
1548
2129
|
*/
|
|
1549
2130
|
isClientRegistrationEndpoint(url) {
|
|
1550
2131
|
if (!this.options.clientRegistrationEndpoint) return false;
|
|
1551
|
-
return this.matchEndpoint(url, this.options.clientRegistrationEndpoint);
|
|
2132
|
+
return this.matchEndpoint(url, this.options.clientRegistrationEndpoint, true);
|
|
1552
2133
|
}
|
|
1553
2134
|
/**
|
|
1554
2135
|
* Checks if a URL is a request for OAuth Protected Resource Metadata (RFC 9728).
|
|
1555
|
-
*
|
|
2136
|
+
* Only the well-known URL constructed from the configured canonical resource
|
|
2137
|
+
* may return its document; aliases would violate RFC 9728 §3.3.
|
|
1556
2138
|
*/
|
|
1557
|
-
|
|
1558
|
-
return
|
|
2139
|
+
findResourceServerForMetadataUrl(url) {
|
|
2140
|
+
return this.resourceServers.find((server) => {
|
|
2141
|
+
const expected = new URL(this.getConfiguredResourceMetadataUrl(server.resourceMetadata.resource));
|
|
2142
|
+
return url.origin === expected.origin && url.pathname === expected.pathname && requestCarriesResourceQuery(url, expected);
|
|
2143
|
+
});
|
|
1559
2144
|
}
|
|
1560
|
-
/**
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
2145
|
+
/** Exact RFC 8414 discovery location for the configured issuer. */
|
|
2146
|
+
getAuthorizationServerMetadataUrl(requestUrl) {
|
|
2147
|
+
if (!this.explicitIssuer) return `${requestUrl.origin}/.well-known/oauth-authorization-server`;
|
|
2148
|
+
const issuer = new URL(this.explicitIssuer);
|
|
2149
|
+
const issuerPath = issuer.pathname === "/" ? "" : issuer.pathname.replace(/\/+$/, "");
|
|
2150
|
+
return `${issuer.origin}/.well-known/oauth-authorization-server${issuerPath}`;
|
|
2151
|
+
}
|
|
2152
|
+
isAuthorizationServerMetadataRequest(url) {
|
|
2153
|
+
const expected = new URL(this.getAuthorizationServerMetadataUrl(url));
|
|
2154
|
+
return url.origin === expected.origin && url.pathname === expected.pathname;
|
|
2155
|
+
}
|
|
2156
|
+
/** Whether this instance is the role-based authorization server with a fixed issuer. */
|
|
2157
|
+
get hasExplicitIssuer() {
|
|
2158
|
+
return this.explicitIssuer !== void 0;
|
|
2159
|
+
}
|
|
2160
|
+
/** Whether a URL is in the RFC 9728 protected-resource metadata namespace. */
|
|
2161
|
+
isProtectedResourceMetadataPath(url) {
|
|
2162
|
+
return url.pathname === PROTECTED_RESOURCE_WELL_KNOWN_PREFIX || url.pathname.startsWith(PROTECTED_RESOURCE_WELL_KNOWN_PREFIX + "/");
|
|
1573
2163
|
}
|
|
1574
2164
|
createInvalidClientResponse(description, basicAuthenticationAttempted, internal, request) {
|
|
1575
2165
|
return this.createErrorResponse("invalid_client", {
|
|
@@ -1586,13 +2176,20 @@ var OAuthProviderImpl = class {
|
|
|
1586
2176
|
async parseTokenEndpointRequest(request, env) {
|
|
1587
2177
|
if (request.method !== "POST") return this.createErrorResponse("invalid_request", {
|
|
1588
2178
|
description: "Method not allowed",
|
|
1589
|
-
statusCode: 405
|
|
2179
|
+
statusCode: 405,
|
|
2180
|
+
headers: { Allow: "POST, OPTIONS" }
|
|
2181
|
+
}, {
|
|
2182
|
+
category: "token-endpoint-request",
|
|
2183
|
+
reason: "method_not_allowed"
|
|
1590
2184
|
});
|
|
1591
2185
|
const contentType = request.headers.get("Content-Type") || "";
|
|
1592
2186
|
let body = {};
|
|
1593
2187
|
if (contentType.split(";")[0].trim().toLowerCase() !== "application/x-www-form-urlencoded") return this.createErrorResponse("invalid_request", {
|
|
1594
2188
|
description: "Content-Type must be application/x-www-form-urlencoded",
|
|
1595
2189
|
statusCode: 400
|
|
2190
|
+
}, {
|
|
2191
|
+
category: "token-endpoint-request",
|
|
2192
|
+
reason: "unsupported_content_type"
|
|
1596
2193
|
});
|
|
1597
2194
|
let formData;
|
|
1598
2195
|
try {
|
|
@@ -1601,6 +2198,9 @@ var OAuthProviderImpl = class {
|
|
|
1601
2198
|
return this.createErrorResponse("invalid_request", {
|
|
1602
2199
|
description: "Request body must be valid application/x-www-form-urlencoded data",
|
|
1603
2200
|
statusCode: 400
|
|
2201
|
+
}, {
|
|
2202
|
+
category: "token-endpoint-request",
|
|
2203
|
+
reason: "malformed_request_body"
|
|
1604
2204
|
});
|
|
1605
2205
|
}
|
|
1606
2206
|
const processedKeys = /* @__PURE__ */ new Set();
|
|
@@ -1611,6 +2211,10 @@ var OAuthProviderImpl = class {
|
|
|
1611
2211
|
if (key !== "resource" && allValues.length > 1) return this.createErrorResponse("invalid_request", {
|
|
1612
2212
|
description: `Request parameter "${key}" must not be repeated`,
|
|
1613
2213
|
statusCode: 400
|
|
2214
|
+
}, {
|
|
2215
|
+
category: "token-endpoint-request",
|
|
2216
|
+
reason: "repeated_parameter",
|
|
2217
|
+
detail: { parameter: key }
|
|
1614
2218
|
});
|
|
1615
2219
|
body[key] = allValues.length > 1 ? allValues : value;
|
|
1616
2220
|
}
|
|
@@ -1622,15 +2226,24 @@ var OAuthProviderImpl = class {
|
|
|
1622
2226
|
if (formData.has("client_id") || formData.has("client_secret")) return this.createErrorResponse("invalid_request", {
|
|
1623
2227
|
description: "Client must not use multiple authentication methods",
|
|
1624
2228
|
statusCode: 400
|
|
2229
|
+
}, {
|
|
2230
|
+
category: "client-authentication",
|
|
2231
|
+
reason: "multiple_authentication_methods"
|
|
2232
|
+
});
|
|
2233
|
+
if (basicAuthorization.kind === "malformed") return this.createInvalidClientResponse("Client authentication failed: invalid Basic credentials", basicAuthenticationAttempted, {
|
|
2234
|
+
category: "client-authentication",
|
|
2235
|
+
reason: "malformed_basic_credentials"
|
|
1625
2236
|
});
|
|
1626
|
-
if (basicAuthorization.kind === "malformed") return this.createInvalidClientResponse("Client authentication failed: invalid Basic credentials", basicAuthenticationAttempted);
|
|
1627
2237
|
clientId = basicAuthorization.clientId;
|
|
1628
2238
|
clientSecret = basicAuthorization.clientSecret;
|
|
1629
2239
|
} else {
|
|
1630
2240
|
clientId = body.client_id;
|
|
1631
2241
|
clientSecret = body.client_secret || "";
|
|
1632
2242
|
}
|
|
1633
|
-
if (!clientId) return this.createInvalidClientResponse("Client ID is required", basicAuthenticationAttempted
|
|
2243
|
+
if (!clientId) return this.createInvalidClientResponse("Client ID is required", basicAuthenticationAttempted, {
|
|
2244
|
+
category: "client-authentication",
|
|
2245
|
+
reason: "client_id_missing"
|
|
2246
|
+
});
|
|
1634
2247
|
let clientInfo;
|
|
1635
2248
|
try {
|
|
1636
2249
|
clientInfo = await this.getClient(env, clientId);
|
|
@@ -1645,7 +2258,10 @@ var OAuthProviderImpl = class {
|
|
|
1645
2258
|
}, request);
|
|
1646
2259
|
throw error;
|
|
1647
2260
|
}
|
|
1648
|
-
if (!clientInfo) return this.createInvalidClientResponse("Client not found", basicAuthenticationAttempted
|
|
2261
|
+
if (!clientInfo) return this.createInvalidClientResponse("Client not found", basicAuthenticationAttempted, {
|
|
2262
|
+
category: "client-authentication",
|
|
2263
|
+
reason: "client_not_found"
|
|
2264
|
+
});
|
|
1649
2265
|
const presentedAuthMethod = basicAuthenticationAttempted ? "client_secret_basic" : formData.has("client_secret") ? "client_secret_post" : "none";
|
|
1650
2266
|
const registeredAuthMethod = clientInfo.tokenEndpointAuthMethod;
|
|
1651
2267
|
if (!isClientAuthMethodAllowed(clientInfo, presentedAuthMethod, !!this.options.clientIdMetadataDocumentEnabled && this.isClientMetadataUrl(clientInfo.clientId))) return this.createInvalidClientResponse("Client authentication failed", basicAuthenticationAttempted, {
|
|
@@ -1658,9 +2274,18 @@ var OAuthProviderImpl = class {
|
|
|
1658
2274
|
}
|
|
1659
2275
|
});
|
|
1660
2276
|
if (presentedAuthMethod !== "none") {
|
|
1661
|
-
if (!clientSecret) return this.createInvalidClientResponse("Client authentication failed: missing client_secret", basicAuthenticationAttempted
|
|
1662
|
-
|
|
1663
|
-
|
|
2277
|
+
if (!clientSecret) return this.createInvalidClientResponse("Client authentication failed: missing client_secret", basicAuthenticationAttempted, {
|
|
2278
|
+
category: "client-authentication",
|
|
2279
|
+
reason: "client_secret_missing"
|
|
2280
|
+
});
|
|
2281
|
+
if (!clientInfo.clientSecret) return this.createInvalidClientResponse("Client authentication failed: client has no registered secret", basicAuthenticationAttempted, {
|
|
2282
|
+
category: "client-authentication",
|
|
2283
|
+
reason: "client_secret_not_registered"
|
|
2284
|
+
});
|
|
2285
|
+
if (await hashSecret(clientSecret) !== clientInfo.clientSecret) return this.createInvalidClientResponse("Client authentication failed: invalid client_secret", basicAuthenticationAttempted, {
|
|
2286
|
+
category: "client-authentication",
|
|
2287
|
+
reason: "client_secret_mismatch"
|
|
2288
|
+
});
|
|
1664
2289
|
}
|
|
1665
2290
|
const isRevocationRequest = !body.grant_type && !!body.token;
|
|
1666
2291
|
return {
|
|
@@ -1676,30 +2301,24 @@ var OAuthProviderImpl = class {
|
|
|
1676
2301
|
* @returns True if the URL matches the API route
|
|
1677
2302
|
*/
|
|
1678
2303
|
matchApiRoute(url, route) {
|
|
1679
|
-
|
|
1680
|
-
if (
|
|
1681
|
-
|
|
1682
|
-
|
|
2304
|
+
const pathMatches = (configuredPath, rootMatchesDescendants) => {
|
|
2305
|
+
if (configuredPath === "/") return rootMatchesDescendants || url.pathname === "/";
|
|
2306
|
+
const normalized = configuredPath.endsWith("/") ? configuredPath.slice(0, -1) : configuredPath;
|
|
2307
|
+
return url.pathname === normalized || url.pathname.startsWith(normalized + "/");
|
|
2308
|
+
};
|
|
2309
|
+
if (this.isPath(route)) return pathMatches(route, false);
|
|
2310
|
+
else {
|
|
1683
2311
|
const apiUrl = new URL(route);
|
|
1684
|
-
return url.
|
|
2312
|
+
return url.origin === apiUrl.origin && pathMatches(apiUrl.pathname, true) && requestCarriesResourceQuery(url, apiUrl);
|
|
1685
2313
|
}
|
|
1686
2314
|
}
|
|
1687
2315
|
/**
|
|
1688
|
-
* Checks if a URL is an API request based on the configured API route(s)
|
|
1689
|
-
* @param url - The URL to check
|
|
1690
|
-
* @returns True if the URL matches any of the API routes
|
|
1691
|
-
*/
|
|
1692
|
-
isApiRequest(url) {
|
|
1693
|
-
for (const [route, _] of this.typedApiHandlers) if (this.matchApiRoute(url, route)) return true;
|
|
1694
|
-
return false;
|
|
1695
|
-
}
|
|
1696
|
-
/**
|
|
1697
2316
|
* Finds the appropriate API handler for a URL
|
|
1698
2317
|
* @param url - The URL to find a handler for
|
|
1699
2318
|
* @returns The TypedHandler for the URL, or undefined if no handler matches
|
|
1700
2319
|
*/
|
|
1701
|
-
|
|
1702
|
-
|
|
2320
|
+
findApiRouteForUrl(url) {
|
|
2321
|
+
return this.typedApiHandlers.filter(({ route }) => this.matchApiRoute(url, route)).sort((left, right) => new URL(right.route, url.origin).pathname.length - new URL(left.route, url.origin).pathname.length)[0];
|
|
1703
2322
|
}
|
|
1704
2323
|
/**
|
|
1705
2324
|
* Gets the full URL for an endpoint, using the provided request URL's
|
|
@@ -1709,13 +2328,14 @@ var OAuthProviderImpl = class {
|
|
|
1709
2328
|
* @returns The full URL for the endpoint
|
|
1710
2329
|
*/
|
|
1711
2330
|
getFullEndpointUrl(endpoint, requestUrl) {
|
|
1712
|
-
if (this.isPath(endpoint)) return `${requestUrl.origin}${endpoint}`;
|
|
2331
|
+
if (this.isPath(endpoint)) return `${this.explicitIssuer ? new URL(this.explicitIssuer).origin : requestUrl.origin}${endpoint}`;
|
|
1713
2332
|
else return endpoint;
|
|
1714
2333
|
}
|
|
1715
2334
|
/**
|
|
1716
2335
|
* Gets the authorization server issuer using the same derivation as RFC 8414 metadata.
|
|
1717
2336
|
*/
|
|
1718
2337
|
getAuthorizationServerIssuer(requestUrl) {
|
|
2338
|
+
if (this.explicitIssuer) return this.explicitIssuer;
|
|
1719
2339
|
const tokenEndpoint = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
|
|
1720
2340
|
return new URL(tokenEndpoint).origin;
|
|
1721
2341
|
}
|
|
@@ -1732,6 +2352,7 @@ var OAuthProviderImpl = class {
|
|
|
1732
2352
|
newResponse.headers.set("Access-Control-Allow-Origin", origin);
|
|
1733
2353
|
newResponse.headers.set("Access-Control-Allow-Methods", "*");
|
|
1734
2354
|
newResponse.headers.set("Access-Control-Allow-Headers", "Authorization, *");
|
|
2355
|
+
appendHeaderValue(newResponse.headers, "Vary", "Origin");
|
|
1735
2356
|
const exposedHeaders = (newResponse.headers.get("Access-Control-Expose-Headers") ?? "").split(",").map((name) => name.trim()).filter(Boolean);
|
|
1736
2357
|
for (const requiredHeader of ["WWW-Authenticate", "Retry-After"]) if (!exposedHeaders.some((name) => name.toLowerCase() === requiredHeader.toLowerCase())) exposedHeaders.push(requiredHeader);
|
|
1737
2358
|
newResponse.headers.set("Access-Control-Expose-Headers", exposedHeaders.join(", "));
|
|
@@ -1753,9 +2374,10 @@ var OAuthProviderImpl = class {
|
|
|
1753
2374
|
const grantTypesSupported = this.serverCapabilities.grantTypes;
|
|
1754
2375
|
const authorizationGrantProfilesSupported = this.options.enterpriseManagedAuthorization ? [EMA_ID_JAG_GRANT_PROFILE] : [];
|
|
1755
2376
|
const metadata = {
|
|
1756
|
-
issuer:
|
|
2377
|
+
issuer: this.getAuthorizationServerIssuer(requestUrl),
|
|
1757
2378
|
authorization_endpoint: authorizeEndpoint,
|
|
1758
2379
|
token_endpoint: tokenEndpoint,
|
|
2380
|
+
...this.resourceServers.length > 0 ? { protected_resources: this.resourceServers.map((server) => server.resourceMetadata.resource) } : {},
|
|
1759
2381
|
registration_endpoint: registrationEndpoint,
|
|
1760
2382
|
scopes_supported: this.options.scopesSupported,
|
|
1761
2383
|
response_types_supported: responseTypesSupported,
|
|
@@ -1771,8 +2393,8 @@ var OAuthProviderImpl = class {
|
|
|
1771
2393
|
return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
|
|
1772
2394
|
}
|
|
1773
2395
|
/** Scopes that are baseline requirements of the protected resource itself. */
|
|
1774
|
-
getProtectedResourceScopes() {
|
|
1775
|
-
return this.normalizeProtectedResourceScopes(
|
|
2396
|
+
getProtectedResourceScopes(resourceServer) {
|
|
2397
|
+
return this.normalizeProtectedResourceScopes(resourceServer.resourceMetadata.scopes_supported ?? []);
|
|
1776
2398
|
}
|
|
1777
2399
|
/** Deduplicate resource-facing scopes and remove authorization-server-only capabilities. */
|
|
1778
2400
|
normalizeProtectedResourceScopes(scopes) {
|
|
@@ -1784,18 +2406,17 @@ var OAuthProviderImpl = class {
|
|
|
1784
2406
|
* @param requestUrl - The URL of the incoming request
|
|
1785
2407
|
* @returns Response with protected resource metadata
|
|
1786
2408
|
*/
|
|
1787
|
-
handleProtectedResourceMetadata(requestUrl) {
|
|
1788
|
-
const rm =
|
|
1789
|
-
const
|
|
1790
|
-
const
|
|
1791
|
-
const resourceScopes = this.getProtectedResourceScopes();
|
|
2409
|
+
handleProtectedResourceMetadata(requestUrl, resourceServer) {
|
|
2410
|
+
const rm = resourceServer.resourceMetadata;
|
|
2411
|
+
const authorizationServer = this.getAuthorizationServerIssuer(requestUrl);
|
|
2412
|
+
const resourceScopes = this.getProtectedResourceScopes(resourceServer);
|
|
1792
2413
|
const metadata = {
|
|
1793
|
-
resource: rm
|
|
1794
|
-
authorization_servers: rm
|
|
2414
|
+
resource: rm.resource,
|
|
2415
|
+
authorization_servers: rm.authorization_servers ?? [authorizationServer],
|
|
1795
2416
|
...resourceScopes.length > 0 ? { scopes_supported: resourceScopes } : {},
|
|
1796
|
-
bearer_methods_supported: rm
|
|
2417
|
+
bearer_methods_supported: rm.bearer_methods_supported ?? ["header"]
|
|
1797
2418
|
};
|
|
1798
|
-
if (rm
|
|
2419
|
+
if (rm.resource_name) metadata.resource_name = rm.resource_name;
|
|
1799
2420
|
return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
|
|
1800
2421
|
}
|
|
1801
2422
|
/**
|
|
@@ -1809,11 +2430,23 @@ var OAuthProviderImpl = class {
|
|
|
1809
2430
|
async handleTokenRequest(body, clientInfo, env, requestUrl, request) {
|
|
1810
2431
|
try {
|
|
1811
2432
|
const grantType = body.grant_type;
|
|
2433
|
+
if (!(grantType === GrantType.AUTHORIZATION_CODE || grantType === GrantType.REFRESH_TOKEN || grantType === GrantType.TOKEN_EXCHANGE && !!this.options.allowTokenExchangeGrant || grantType === GrantType.JWT_BEARER && !!this.options.enterpriseManagedAuthorization)) return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" }, {
|
|
2434
|
+
category: "token-endpoint-request",
|
|
2435
|
+
reason: "grant_type_not_supported"
|
|
2436
|
+
});
|
|
2437
|
+
if (grantType !== GrantType.JWT_BEARER && Array.isArray(clientInfo.grantTypes)) {
|
|
2438
|
+
const registered = clientInfo.grantTypes;
|
|
2439
|
+
if (!(registered.includes(grantType) || grantType === GrantType.REFRESH_TOKEN && registered.includes(GrantType.AUTHORIZATION_CODE))) return this.createErrorResponse("unauthorized_client", { description: "The client is not registered for this grant type" }, {
|
|
2440
|
+
category: "token-endpoint-request",
|
|
2441
|
+
reason: "grant_type_not_registered"
|
|
2442
|
+
});
|
|
2443
|
+
}
|
|
2444
|
+
this.validateTokenRequestResourceIndicator(body.resource);
|
|
1812
2445
|
if (grantType === GrantType.AUTHORIZATION_CODE) return await this.handleAuthorizationCodeGrant(body, clientInfo, env);
|
|
1813
2446
|
else if (grantType === GrantType.REFRESH_TOKEN) return await this.handleRefreshTokenGrant(body, clientInfo, env);
|
|
1814
2447
|
else if (grantType === GrantType.TOKEN_EXCHANGE && this.options.allowTokenExchangeGrant) return await this.handleTokenExchangeGrant(body, clientInfo, env);
|
|
1815
2448
|
else if (grantType === GrantType.JWT_BEARER) return await this.handleJwtBearerGrant(body, clientInfo, env, requestUrl, request);
|
|
1816
|
-
|
|
2449
|
+
throw new Error("Unreachable supported grant type");
|
|
1817
2450
|
} catch (error) {
|
|
1818
2451
|
const response = this.createOAuthErrorResponse(error);
|
|
1819
2452
|
if (response) return response;
|
|
@@ -1829,7 +2462,11 @@ var OAuthProviderImpl = class {
|
|
|
1829
2462
|
*/
|
|
1830
2463
|
createOAuthErrorResponse(error) {
|
|
1831
2464
|
if (!(error instanceof OAuthError)) return void 0;
|
|
1832
|
-
return this.createErrorResponse(error.code, error.options
|
|
2465
|
+
return this.createErrorResponse(error.code, error.options, error.options.internal ?? {
|
|
2466
|
+
category: "token-exchange-callback",
|
|
2467
|
+
reason: "callback_error",
|
|
2468
|
+
detail: error
|
|
2469
|
+
});
|
|
1833
2470
|
}
|
|
1834
2471
|
/**
|
|
1835
2472
|
* Build a structured protected-resource response from an external-token error.
|
|
@@ -1838,7 +2475,7 @@ var OAuthProviderImpl = class {
|
|
|
1838
2475
|
* bearer failures receive an RFC 6750 / RFC 9728 challenge unless the caller
|
|
1839
2476
|
* supplied one. Other errors retain the pre-existing behavior and are re-thrown.
|
|
1840
2477
|
*/
|
|
1841
|
-
createExternalTokenErrorResponse(error, resourceMetadataUrl) {
|
|
2478
|
+
createExternalTokenErrorResponse(error, resourceMetadataUrl, resourceServer) {
|
|
1842
2479
|
if (!(error instanceof ExternalTokenError)) return void 0;
|
|
1843
2480
|
const headers = error.headers ?? {};
|
|
1844
2481
|
const hasChallenge = Object.keys(headers).some((name) => name.toLowerCase() === "www-authenticate");
|
|
@@ -1849,13 +2486,17 @@ var OAuthProviderImpl = class {
|
|
|
1849
2486
|
if (requiredScopes.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("ExternalTokenError requiredScopes must contain valid OAuth scope tokens");
|
|
1850
2487
|
challengeHeaders = {
|
|
1851
2488
|
...headers,
|
|
1852
|
-
"WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, error.code, void 0, requiredScopes)
|
|
2489
|
+
"WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, error.code, void 0, requiredScopes, resourceServer)
|
|
1853
2490
|
};
|
|
1854
2491
|
}
|
|
1855
2492
|
return this.createErrorResponse(error.code, {
|
|
1856
2493
|
description: error.description,
|
|
1857
2494
|
statusCode: error.statusCode,
|
|
1858
2495
|
headers: challengeHeaders ?? headers
|
|
2496
|
+
}, {
|
|
2497
|
+
category: "protected-resource",
|
|
2498
|
+
reason: "resolver_rejected",
|
|
2499
|
+
detail: error
|
|
1859
2500
|
});
|
|
1860
2501
|
}
|
|
1861
2502
|
/**
|
|
@@ -1870,34 +2511,72 @@ var OAuthProviderImpl = class {
|
|
|
1870
2511
|
const code = body.code;
|
|
1871
2512
|
const redirectUri = body.redirect_uri;
|
|
1872
2513
|
const codeVerifier = body.code_verifier;
|
|
1873
|
-
if (!code) return this.createErrorResponse("invalid_request", { description: "Authorization code is required" }
|
|
2514
|
+
if (!code) return this.createErrorResponse("invalid_request", { description: "Authorization code is required" }, {
|
|
2515
|
+
category: "authorization-code-grant",
|
|
2516
|
+
reason: "code_missing"
|
|
2517
|
+
});
|
|
1874
2518
|
const codeParts = code.split(":");
|
|
1875
|
-
if (codeParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code format" }
|
|
2519
|
+
if (codeParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code format" }, {
|
|
2520
|
+
category: "authorization-code-grant",
|
|
2521
|
+
reason: "code_malformed"
|
|
2522
|
+
});
|
|
1876
2523
|
const [userId, grantId, _] = codeParts;
|
|
1877
2524
|
const grantKey = `grant:${userId}:${grantId}`;
|
|
1878
2525
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
1879
|
-
if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found or authorization code expired" }
|
|
2526
|
+
if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found or authorization code expired" }, {
|
|
2527
|
+
category: "authorization-code-grant",
|
|
2528
|
+
reason: "grant_not_found"
|
|
2529
|
+
});
|
|
1880
2530
|
const codeHash = await hashSecret(code);
|
|
1881
|
-
if (!grantData.authCodeId || codeHash !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code" }
|
|
1882
|
-
|
|
2531
|
+
if (!grantData.authCodeId || codeHash !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code" }, {
|
|
2532
|
+
category: "authorization-code-grant",
|
|
2533
|
+
reason: "code_mismatch"
|
|
2534
|
+
});
|
|
2535
|
+
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" }, {
|
|
2536
|
+
category: "authorization-code-grant",
|
|
2537
|
+
reason: "client_mismatch"
|
|
2538
|
+
});
|
|
1883
2539
|
if (!grantData.authCodeWrappedKey) {
|
|
1884
2540
|
try {
|
|
1885
2541
|
await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
|
|
1886
2542
|
} catch {}
|
|
1887
|
-
return this.createErrorResponse("invalid_grant", { description: "Authorization code already used" }
|
|
2543
|
+
return this.createErrorResponse("invalid_grant", { description: "Authorization code already used" }, {
|
|
2544
|
+
category: "authorization-code-grant",
|
|
2545
|
+
reason: "code_replayed"
|
|
2546
|
+
});
|
|
1888
2547
|
}
|
|
1889
2548
|
let codeChallengeMethod;
|
|
1890
2549
|
try {
|
|
1891
2550
|
codeChallengeMethod = grantData.codeChallenge ? validatePkceCodeChallengeMethod(this.serverCapabilities, grantData.codeChallengeMethod) : normalizePkceCodeChallengeMethod(grantData.codeChallengeMethod);
|
|
1892
2551
|
} catch (error) {
|
|
1893
|
-
return this.createErrorResponse("invalid_grant", { description: error instanceof Error ? error.message : "Invalid PKCE code_challenge_method" }
|
|
2552
|
+
return this.createErrorResponse("invalid_grant", { description: error instanceof Error ? error.message : "Invalid PKCE code_challenge_method" }, {
|
|
2553
|
+
category: "authorization-code-grant",
|
|
2554
|
+
reason: "pkce_method_invalid",
|
|
2555
|
+
detail: error
|
|
2556
|
+
});
|
|
1894
2557
|
}
|
|
1895
2558
|
const isPkceEnabled = !!grantData.codeChallenge;
|
|
1896
|
-
if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" }
|
|
1897
|
-
|
|
1898
|
-
|
|
2559
|
+
if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" }, {
|
|
2560
|
+
category: "authorization-code-grant",
|
|
2561
|
+
reason: "redirect_uri_missing"
|
|
2562
|
+
});
|
|
2563
|
+
if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", { description: "Invalid redirect URI" }, {
|
|
2564
|
+
category: "authorization-code-grant",
|
|
2565
|
+
reason: "redirect_uri_invalid"
|
|
2566
|
+
});
|
|
2567
|
+
if (redirectUri && grantData.redirectUri !== void 0 && redirectUri !== grantData.redirectUri) return this.createErrorResponse("invalid_grant", { description: "redirect_uri does not match the authorization request" }, {
|
|
2568
|
+
category: "authorization-code-grant",
|
|
2569
|
+
reason: "redirect_uri_mismatch"
|
|
2570
|
+
});
|
|
2571
|
+
if (!isPkceEnabled && codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier provided for a flow that did not use PKCE" }, {
|
|
2572
|
+
category: "authorization-code-grant",
|
|
2573
|
+
reason: "unexpected_code_verifier"
|
|
2574
|
+
});
|
|
1899
2575
|
if (isPkceEnabled) {
|
|
1900
|
-
if (!codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier is required for PKCE" }
|
|
2576
|
+
if (!codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier is required for PKCE" }, {
|
|
2577
|
+
category: "authorization-code-grant",
|
|
2578
|
+
reason: "code_verifier_missing"
|
|
2579
|
+
});
|
|
1901
2580
|
let calculatedChallenge;
|
|
1902
2581
|
if (codeChallengeMethod === "S256") {
|
|
1903
2582
|
const data = new TextEncoder().encode(codeVerifier);
|
|
@@ -1905,9 +2584,13 @@ var OAuthProviderImpl = class {
|
|
|
1905
2584
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
1906
2585
|
calculatedChallenge = base64UrlEncode(String.fromCharCode(...hashArray));
|
|
1907
2586
|
} else calculatedChallenge = codeVerifier;
|
|
1908
|
-
if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" }
|
|
2587
|
+
if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" }, {
|
|
2588
|
+
category: "authorization-code-grant",
|
|
2589
|
+
reason: "code_verifier_mismatch"
|
|
2590
|
+
});
|
|
1909
2591
|
}
|
|
1910
|
-
const
|
|
2592
|
+
const resourceResolution = this.resolveTokenResource(body.resource, grantData);
|
|
2593
|
+
const audience = resourceResolution.audience;
|
|
1911
2594
|
let accessTokenTTL = this.options.accessTokenTTL;
|
|
1912
2595
|
let refreshTokenTTL = this.options.refreshTokenTTL;
|
|
1913
2596
|
const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
|
|
@@ -1922,10 +2605,12 @@ var OAuthProviderImpl = class {
|
|
|
1922
2605
|
const callbackOptions = {
|
|
1923
2606
|
grantType: GrantType.AUTHORIZATION_CODE,
|
|
1924
2607
|
clientId: clientInfo.clientId,
|
|
2608
|
+
subjectClientId: grantData.clientId,
|
|
1925
2609
|
userId,
|
|
1926
2610
|
grantId,
|
|
1927
2611
|
scope: grantData.scope,
|
|
1928
2612
|
requestedScope: tokenScopes,
|
|
2613
|
+
resource: audience,
|
|
1929
2614
|
props: decryptedProps
|
|
1930
2615
|
};
|
|
1931
2616
|
const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
|
|
@@ -1937,6 +2622,10 @@ var OAuthProviderImpl = class {
|
|
|
1937
2622
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
1938
2623
|
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
|
|
1939
2624
|
if ("refreshTokenTTL" in callbackResult) refreshTokenTTL = callbackResult.refreshTokenTTL;
|
|
2625
|
+
if (callbackResult.refreshTokenIdleTTL !== void 0) return this.createErrorResponse("invalid_request", { description: "refreshTokenIdleTTL is only honored during refresh token exchange" }, {
|
|
2626
|
+
category: "authorization-code-grant",
|
|
2627
|
+
reason: "refresh_token_idle_ttl_wrong_grant"
|
|
2628
|
+
});
|
|
1940
2629
|
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
1941
2630
|
}
|
|
1942
2631
|
const grantResult = await encryptProps(grantProps);
|
|
@@ -1951,6 +2640,10 @@ var OAuthProviderImpl = class {
|
|
|
1951
2640
|
accessTokenEncryptionKey = grantEncryptionKey;
|
|
1952
2641
|
}
|
|
1953
2642
|
}
|
|
2643
|
+
if (!isValidAccessTokenTTL(accessTokenTTL)) return this.createErrorResponse("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" }, {
|
|
2644
|
+
category: "authorization-code-grant",
|
|
2645
|
+
reason: "requested_ttl_too_short"
|
|
2646
|
+
});
|
|
1954
2647
|
const now = Math.floor(Date.now() / 1e3);
|
|
1955
2648
|
const useRefreshToken = refreshTokenTTL !== 0;
|
|
1956
2649
|
delete grantData.codeChallenge;
|
|
@@ -1968,6 +2661,7 @@ var OAuthProviderImpl = class {
|
|
|
1968
2661
|
grantData.previousRefreshTokenWrappedKey = void 0;
|
|
1969
2662
|
grantData.expiresAt = expiresAt;
|
|
1970
2663
|
}
|
|
2664
|
+
if (resourceResolution.grantResourceBackfill) grantData.resource = resourceResolution.grantResourceBackfill;
|
|
1971
2665
|
await this.saveGrantWithTTL(env, grantKey, grantData, now);
|
|
1972
2666
|
const tokenResponse = {
|
|
1973
2667
|
access_token: await this.createAccessToken({
|
|
@@ -1983,10 +2677,10 @@ var OAuthProviderImpl = class {
|
|
|
1983
2677
|
}),
|
|
1984
2678
|
token_type: "bearer",
|
|
1985
2679
|
expires_in: accessTokenTTL,
|
|
1986
|
-
scope: tokenScopes.join(" ")
|
|
2680
|
+
scope: tokenScopes.join(" "),
|
|
2681
|
+
resource: audience
|
|
1987
2682
|
};
|
|
1988
2683
|
if (refreshToken) tokenResponse.refresh_token = refreshToken;
|
|
1989
|
-
if (audience) tokenResponse.resource = audience;
|
|
1990
2684
|
return new Response(JSON.stringify(tokenResponse), { headers: {
|
|
1991
2685
|
"Content-Type": "application/json",
|
|
1992
2686
|
...NO_CACHE_HEADERS
|
|
@@ -2002,26 +2696,46 @@ var OAuthProviderImpl = class {
|
|
|
2002
2696
|
*/
|
|
2003
2697
|
async handleRefreshTokenGrant(body, clientInfo, env) {
|
|
2004
2698
|
const refreshToken = body.refresh_token;
|
|
2005
|
-
if (!refreshToken) return this.createErrorResponse("invalid_request", { description: "Refresh token is required" }
|
|
2699
|
+
if (!refreshToken) return this.createErrorResponse("invalid_request", { description: "Refresh token is required" }, {
|
|
2700
|
+
category: "refresh-token-grant",
|
|
2701
|
+
reason: "refresh_token_missing"
|
|
2702
|
+
});
|
|
2006
2703
|
const tokenParts = refreshToken.split(":");
|
|
2007
|
-
if (tokenParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid token format" }
|
|
2704
|
+
if (tokenParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid token format" }, {
|
|
2705
|
+
category: "refresh-token-grant",
|
|
2706
|
+
reason: "refresh_token_malformed"
|
|
2707
|
+
});
|
|
2008
2708
|
const [userId, grantId, _] = tokenParts;
|
|
2009
2709
|
const providedTokenHash = await generateTokenId(refreshToken);
|
|
2010
2710
|
const grantKey = `grant:${userId}:${grantId}`;
|
|
2011
2711
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
2012
|
-
if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found" }
|
|
2712
|
+
if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found" }, {
|
|
2713
|
+
category: "refresh-token-grant",
|
|
2714
|
+
reason: "grant_not_found"
|
|
2715
|
+
});
|
|
2013
2716
|
const isCurrentToken = grantData.refreshTokenId === providedTokenHash;
|
|
2014
2717
|
const isPreviousToken = grantData.previousRefreshTokenId === providedTokenHash;
|
|
2015
|
-
if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", { description: "Invalid refresh token" }
|
|
2016
|
-
|
|
2718
|
+
if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", { description: "Invalid refresh token" }, {
|
|
2719
|
+
category: "refresh-token-grant",
|
|
2720
|
+
reason: "refresh_token_mismatch"
|
|
2721
|
+
});
|
|
2722
|
+
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" }, {
|
|
2723
|
+
category: "refresh-token-grant",
|
|
2724
|
+
reason: "client_mismatch"
|
|
2725
|
+
});
|
|
2017
2726
|
if (grantData.expiresAt !== void 0) {
|
|
2018
2727
|
const now$1 = Math.floor(Date.now() / 1e3);
|
|
2019
|
-
if (grantData.expiresAt - now$1 < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" }
|
|
2728
|
+
if (grantData.expiresAt - now$1 < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" }, {
|
|
2729
|
+
category: "refresh-token-grant",
|
|
2730
|
+
reason: "refresh_token_expired"
|
|
2731
|
+
});
|
|
2020
2732
|
}
|
|
2021
|
-
const
|
|
2733
|
+
const resourceResolution = this.resolveTokenResource(body.resource, grantData);
|
|
2734
|
+
const audience = resourceResolution.audience;
|
|
2022
2735
|
const newAccessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
2023
2736
|
const accessTokenId = await generateTokenId(newAccessToken);
|
|
2024
2737
|
let accessTokenTTL = this.options.accessTokenTTL;
|
|
2738
|
+
let refreshTokenIdleTTL = this.options.refreshTokenIdleTTL;
|
|
2025
2739
|
let wrappedKeyToUse;
|
|
2026
2740
|
if (isCurrentToken) wrappedKeyToUse = grantData.refreshTokenWrappedKey;
|
|
2027
2741
|
else wrappedKeyToUse = grantData.previousRefreshTokenWrappedKey;
|
|
@@ -2038,10 +2752,12 @@ var OAuthProviderImpl = class {
|
|
|
2038
2752
|
const callbackOptions = {
|
|
2039
2753
|
grantType: GrantType.REFRESH_TOKEN,
|
|
2040
2754
|
clientId: clientInfo.clientId,
|
|
2755
|
+
subjectClientId: grantData.clientId,
|
|
2041
2756
|
userId,
|
|
2042
2757
|
grantId,
|
|
2043
2758
|
scope: grantData.scope,
|
|
2044
2759
|
requestedScope: tokenScopes,
|
|
2760
|
+
resource: audience,
|
|
2045
2761
|
props: decryptedProps
|
|
2046
2762
|
};
|
|
2047
2763
|
const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
|
|
@@ -2053,7 +2769,17 @@ var OAuthProviderImpl = class {
|
|
|
2053
2769
|
}
|
|
2054
2770
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
2055
2771
|
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
|
|
2056
|
-
if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", { description: "refreshTokenTTL cannot be changed during refresh token exchange" }
|
|
2772
|
+
if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", { description: "refreshTokenTTL cannot be changed during refresh token exchange" }, {
|
|
2773
|
+
category: "refresh-token-grant",
|
|
2774
|
+
reason: "refresh_token_ttl_immutable"
|
|
2775
|
+
});
|
|
2776
|
+
if (callbackResult.refreshTokenIdleTTL !== void 0) {
|
|
2777
|
+
if (!isValidAccessTokenTTL(callbackResult.refreshTokenIdleTTL)) return this.createErrorResponse("invalid_request", { description: `refreshTokenIdleTTL must be an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds` }, {
|
|
2778
|
+
category: "refresh-token-grant",
|
|
2779
|
+
reason: "refresh_token_idle_ttl_invalid"
|
|
2780
|
+
});
|
|
2781
|
+
refreshTokenIdleTTL = callbackResult.refreshTokenIdleTTL;
|
|
2782
|
+
}
|
|
2057
2783
|
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
2058
2784
|
}
|
|
2059
2785
|
if (grantPropsChanged) {
|
|
@@ -2074,12 +2800,19 @@ var OAuthProviderImpl = class {
|
|
|
2074
2800
|
}
|
|
2075
2801
|
}
|
|
2076
2802
|
const now = Math.floor(Date.now() / 1e3);
|
|
2077
|
-
if (grantData.expiresAt !== void 0 && grantData.expiresAt - now < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" }
|
|
2803
|
+
if (grantData.expiresAt !== void 0 && grantData.expiresAt - now < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" }, {
|
|
2804
|
+
category: "refresh-token-grant",
|
|
2805
|
+
reason: "refresh_token_expired"
|
|
2806
|
+
});
|
|
2807
|
+
if (refreshTokenIdleTTL !== void 0) grantData.expiresAt = now + refreshTokenIdleTTL;
|
|
2078
2808
|
if (grantData.expiresAt !== void 0) {
|
|
2079
2809
|
const remainingRefreshTokenLifetime = grantData.expiresAt - now;
|
|
2080
2810
|
if (remainingRefreshTokenLifetime > 0) accessTokenTTL = Math.min(accessTokenTTL, remainingRefreshTokenLifetime);
|
|
2081
2811
|
}
|
|
2082
|
-
if (accessTokenTTL
|
|
2812
|
+
if (!isValidAccessTokenTTL(accessTokenTTL)) return this.createErrorResponse("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" }, {
|
|
2813
|
+
category: "refresh-token-grant",
|
|
2814
|
+
reason: "requested_ttl_too_short"
|
|
2815
|
+
});
|
|
2083
2816
|
const accessTokenExpiresAt = now + accessTokenTTL;
|
|
2084
2817
|
const accessTokenWrappedKey = await wrapKeyWithToken(newAccessToken, accessTokenEncryptionKey);
|
|
2085
2818
|
const newRefreshToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
@@ -2089,6 +2822,7 @@ var OAuthProviderImpl = class {
|
|
|
2089
2822
|
grantData.previousRefreshTokenWrappedKey = wrappedKeyToUse;
|
|
2090
2823
|
grantData.refreshTokenId = newRefreshTokenId;
|
|
2091
2824
|
grantData.refreshTokenWrappedKey = newRefreshTokenWrappedKey;
|
|
2825
|
+
if (resourceResolution.grantResourceBackfill) grantData.resource = resourceResolution.grantResourceBackfill;
|
|
2092
2826
|
await this.saveGrantWithTTL(env, grantKey, grantData, now);
|
|
2093
2827
|
const accessTokenData = {
|
|
2094
2828
|
id: accessTokenId,
|
|
@@ -2116,9 +2850,9 @@ var OAuthProviderImpl = class {
|
|
|
2116
2850
|
token_type: "bearer",
|
|
2117
2851
|
expires_in: accessTokenTTL,
|
|
2118
2852
|
refresh_token: newRefreshToken,
|
|
2119
|
-
scope: tokenScopes.join(" ")
|
|
2853
|
+
scope: tokenScopes.join(" "),
|
|
2854
|
+
resource: audience
|
|
2120
2855
|
};
|
|
2121
|
-
if (audience) tokenResponse.resource = audience;
|
|
2122
2856
|
return new Response(JSON.stringify(tokenResponse), { headers: {
|
|
2123
2857
|
"Content-Type": "application/json",
|
|
2124
2858
|
...NO_CACHE_HEADERS
|
|
@@ -2132,7 +2866,7 @@ var OAuthProviderImpl = class {
|
|
|
2132
2866
|
* module-private.
|
|
2133
2867
|
* @param subjectToken - The subject token to exchange
|
|
2134
2868
|
* @param requestedScopes - Optional requested scopes, limited to the subject token's scopes
|
|
2135
|
-
* @param requestedResource - Optional resource/audience
|
|
2869
|
+
* @param requestedResource - Optional resource/audience; when present, it must match the configured canonical resource
|
|
2136
2870
|
* @param expiresIn - Optional TTL override in seconds
|
|
2137
2871
|
* @param clientInfo - The client making the exchange request
|
|
2138
2872
|
* @param env - Cloudflare Worker environment variables
|
|
@@ -2141,24 +2875,61 @@ var OAuthProviderImpl = class {
|
|
|
2141
2875
|
*/
|
|
2142
2876
|
async exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env) {
|
|
2143
2877
|
const tokenSummary = await this.unwrapToken(subjectToken, env);
|
|
2144
|
-
if (!tokenSummary) throw new OAuthError("
|
|
2878
|
+
if (!tokenSummary) throw new OAuthError("invalid_request", {
|
|
2879
|
+
description: "Invalid or expired subject token",
|
|
2880
|
+
internal: {
|
|
2881
|
+
category: "token-exchange-grant",
|
|
2882
|
+
reason: "subject_token_invalid"
|
|
2883
|
+
}
|
|
2884
|
+
});
|
|
2145
2885
|
const grantKey = `grant:${tokenSummary.userId}:${tokenSummary.grantId}`;
|
|
2146
2886
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
2147
|
-
if (!grantData) throw new OAuthError("
|
|
2887
|
+
if (!grantData) throw new OAuthError("invalid_request", {
|
|
2888
|
+
description: "Grant not found",
|
|
2889
|
+
internal: {
|
|
2890
|
+
category: "token-exchange-grant",
|
|
2891
|
+
reason: "grant_not_found"
|
|
2892
|
+
}
|
|
2893
|
+
});
|
|
2894
|
+
const crossClientExchange = grantData.clientId !== clientInfo.clientId;
|
|
2895
|
+
const crossClientRejection = () => new OAuthError("invalid_request", {
|
|
2896
|
+
description: "The subject token was issued to a different client",
|
|
2897
|
+
internal: {
|
|
2898
|
+
category: "token-exchange-grant",
|
|
2899
|
+
reason: "cross_client_subject_token"
|
|
2900
|
+
}
|
|
2901
|
+
});
|
|
2902
|
+
if (crossClientExchange && !this.options.tokenExchangeCallback) throw crossClientRejection();
|
|
2148
2903
|
let tokenScopes = this.downscope(requestedScopes, tokenSummary.scope);
|
|
2149
|
-
const
|
|
2150
|
-
if (configuredResource && !isExactResource(tokenSummary.audience, configuredResource)) throw new OAuthError("invalid_target", { description: "Subject token is not bound to the configured resource" });
|
|
2151
|
-
const newAudience = requestedResource === void 0 ? tokenSummary.audience : this.resolveTokenResource(requestedResource, grantData.resource);
|
|
2904
|
+
const newAudience = this.resolveTokenExchangeResource(requestedResource, tokenSummary.audience);
|
|
2152
2905
|
const now = Math.floor(Date.now() / 1e3);
|
|
2153
2906
|
const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
|
|
2154
|
-
if (subjectTokenRemainingLifetime < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("
|
|
2907
|
+
if (subjectTokenRemainingLifetime < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_request", {
|
|
2908
|
+
description: "Subject token is too close to expiry to exchange",
|
|
2909
|
+
internal: {
|
|
2910
|
+
category: "token-exchange-grant",
|
|
2911
|
+
reason: "subject_token_near_expiry"
|
|
2912
|
+
}
|
|
2913
|
+
});
|
|
2155
2914
|
let accessTokenTTL = this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL;
|
|
2156
2915
|
if (expiresIn !== void 0) {
|
|
2157
|
-
if (expiresIn <= 0) throw new OAuthError("invalid_request", {
|
|
2916
|
+
if (expiresIn <= 0) throw new OAuthError("invalid_request", {
|
|
2917
|
+
description: "Invalid expires_in parameter",
|
|
2918
|
+
internal: {
|
|
2919
|
+
category: "token-exchange-grant",
|
|
2920
|
+
reason: "expires_in_invalid"
|
|
2921
|
+
}
|
|
2922
|
+
});
|
|
2158
2923
|
accessTokenTTL = Math.min(expiresIn, subjectTokenRemainingLifetime);
|
|
2159
2924
|
} else accessTokenTTL = Math.min(accessTokenTTL, subjectTokenRemainingLifetime);
|
|
2160
2925
|
const subjectTokenData = await env.OAUTH_KV.get(`token:${tokenSummary.userId}:${tokenSummary.grantId}:${tokenSummary.id}`, { type: "json" });
|
|
2161
|
-
if (!subjectTokenData) throw new OAuthError("
|
|
2926
|
+
if (!subjectTokenData) throw new OAuthError("invalid_request", {
|
|
2927
|
+
description: "Subject token data not found",
|
|
2928
|
+
internal: {
|
|
2929
|
+
category: "token-exchange-grant",
|
|
2930
|
+
reason: "subject_token_data_missing"
|
|
2931
|
+
}
|
|
2932
|
+
});
|
|
2162
2933
|
const encryptionKey = await unwrapKeyWithToken(subjectToken, subjectTokenData.wrappedEncryptionKey);
|
|
2163
2934
|
let accessTokenEncryptionKey = encryptionKey;
|
|
2164
2935
|
let encryptedAccessTokenProps = subjectTokenData.grant.encryptedProps;
|
|
@@ -2167,13 +2938,16 @@ var OAuthProviderImpl = class {
|
|
|
2167
2938
|
const callbackOptions = {
|
|
2168
2939
|
grantType: GrantType.TOKEN_EXCHANGE,
|
|
2169
2940
|
clientId: clientInfo.clientId,
|
|
2941
|
+
subjectClientId: grantData.clientId,
|
|
2170
2942
|
userId: tokenSummary.userId,
|
|
2171
2943
|
grantId: tokenSummary.grantId,
|
|
2172
2944
|
scope: tokenSummary.grant.scope,
|
|
2173
2945
|
requestedScope: tokenScopes,
|
|
2946
|
+
resource: newAudience,
|
|
2174
2947
|
props: decryptedProps
|
|
2175
2948
|
};
|
|
2176
2949
|
const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
|
|
2950
|
+
if (crossClientExchange && callbackResult?.allowCrossClientExchange !== true) throw crossClientRejection();
|
|
2177
2951
|
if (callbackResult) {
|
|
2178
2952
|
let accessTokenProps = decryptedProps;
|
|
2179
2953
|
if (callbackResult.newProps) {
|
|
@@ -2181,6 +2955,13 @@ var OAuthProviderImpl = class {
|
|
|
2181
2955
|
}
|
|
2182
2956
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
2183
2957
|
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = Math.min(callbackResult.accessTokenTTL, subjectTokenRemainingLifetime);
|
|
2958
|
+
if (callbackResult.refreshTokenIdleTTL !== void 0) throw new OAuthError("invalid_request", {
|
|
2959
|
+
description: "refreshTokenIdleTTL is only honored during refresh token exchange",
|
|
2960
|
+
internal: {
|
|
2961
|
+
category: "token-exchange-grant",
|
|
2962
|
+
reason: "refresh_token_idle_ttl_wrong_grant"
|
|
2963
|
+
}
|
|
2964
|
+
});
|
|
2184
2965
|
if (accessTokenProps !== decryptedProps) {
|
|
2185
2966
|
const tokenResult = await encryptProps(accessTokenProps);
|
|
2186
2967
|
encryptedAccessTokenProps = tokenResult.encryptedData;
|
|
@@ -2189,8 +2970,14 @@ var OAuthProviderImpl = class {
|
|
|
2189
2970
|
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, tokenSummary.scope);
|
|
2190
2971
|
}
|
|
2191
2972
|
}
|
|
2192
|
-
if (accessTokenTTL
|
|
2193
|
-
|
|
2973
|
+
if (!isValidAccessTokenTTL(accessTokenTTL)) throw new OAuthError("invalid_request", {
|
|
2974
|
+
description: "Requested token lifetime must be at least 60 seconds",
|
|
2975
|
+
internal: {
|
|
2976
|
+
category: "token-exchange-grant",
|
|
2977
|
+
reason: "requested_ttl_too_short"
|
|
2978
|
+
}
|
|
2979
|
+
});
|
|
2980
|
+
return {
|
|
2194
2981
|
access_token: await this.createAccessToken({
|
|
2195
2982
|
userId: tokenSummary.userId,
|
|
2196
2983
|
grantId: tokenSummary.grantId,
|
|
@@ -2205,10 +2992,9 @@ var OAuthProviderImpl = class {
|
|
|
2205
2992
|
issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
|
|
2206
2993
|
token_type: "bearer",
|
|
2207
2994
|
expires_in: accessTokenTTL,
|
|
2208
|
-
scope: tokenScopes.join(" ")
|
|
2995
|
+
scope: tokenScopes.join(" "),
|
|
2996
|
+
resource: newAudience
|
|
2209
2997
|
};
|
|
2210
|
-
if (newAudience) tokenResponse.resource = newAudience;
|
|
2211
|
-
return tokenResponse;
|
|
2212
2998
|
}
|
|
2213
2999
|
/**
|
|
2214
3000
|
* Handles OAuth 2.0 token exchange requests (RFC 8693)
|
|
@@ -2224,18 +3010,36 @@ var OAuthProviderImpl = class {
|
|
|
2224
3010
|
const requestedTokenType = body.requested_token_type || "urn:ietf:params:oauth:token-type:access_token";
|
|
2225
3011
|
const requestedScope = body.scope;
|
|
2226
3012
|
const requestedResource = body.resource;
|
|
2227
|
-
if (!subjectToken) return this.createErrorResponse("invalid_request", { description: "subject_token is required" }
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
3013
|
+
if (!subjectToken) return this.createErrorResponse("invalid_request", { description: "subject_token is required" }, {
|
|
3014
|
+
category: "token-exchange-grant",
|
|
3015
|
+
reason: "subject_token_missing"
|
|
3016
|
+
});
|
|
3017
|
+
if (!subjectTokenType) return this.createErrorResponse("invalid_request", { description: "subject_token_type is required" }, {
|
|
3018
|
+
category: "token-exchange-grant",
|
|
3019
|
+
reason: "subject_token_type_missing"
|
|
3020
|
+
});
|
|
3021
|
+
if (subjectTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", { description: "Only access_token subject_token_type is supported" }, {
|
|
3022
|
+
category: "token-exchange-grant",
|
|
3023
|
+
reason: "subject_token_type_unsupported"
|
|
3024
|
+
});
|
|
3025
|
+
if (requestedTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", { description: "Only access_token requested_token_type is supported" }, {
|
|
3026
|
+
category: "token-exchange-grant",
|
|
3027
|
+
reason: "requested_token_type_unsupported"
|
|
3028
|
+
});
|
|
2231
3029
|
let requestedScopes;
|
|
2232
3030
|
if (requestedScope) if (typeof requestedScope === "string") requestedScopes = requestedScope.split(" ").filter(Boolean);
|
|
2233
3031
|
else if (Array.isArray(requestedScope)) requestedScopes = requestedScope;
|
|
2234
|
-
else return this.createErrorResponse("invalid_request", { description: "Invalid scope parameter format" }
|
|
3032
|
+
else return this.createErrorResponse("invalid_request", { description: "Invalid scope parameter format" }, {
|
|
3033
|
+
category: "token-exchange-grant",
|
|
3034
|
+
reason: "scope_malformed"
|
|
3035
|
+
});
|
|
2235
3036
|
let expiresIn;
|
|
2236
3037
|
if (body.expires_in !== void 0) {
|
|
2237
3038
|
const requestedTTL = parseInt(body.expires_in, 10);
|
|
2238
|
-
if (isNaN(requestedTTL) || requestedTTL <= 0) return this.createErrorResponse("invalid_request", { description: "Invalid expires_in parameter" }
|
|
3039
|
+
if (isNaN(requestedTTL) || requestedTTL <= 0) return this.createErrorResponse("invalid_request", { description: "Invalid expires_in parameter" }, {
|
|
3040
|
+
category: "token-exchange-grant",
|
|
3041
|
+
reason: "expires_in_invalid"
|
|
3042
|
+
});
|
|
2239
3043
|
expiresIn = requestedTTL;
|
|
2240
3044
|
}
|
|
2241
3045
|
try {
|
|
@@ -2260,10 +3064,16 @@ var OAuthProviderImpl = class {
|
|
|
2260
3064
|
*/
|
|
2261
3065
|
async handleJwtBearerGrant(body, clientInfo, env, requestUrl, request) {
|
|
2262
3066
|
const enterpriseOptions = this.options.enterpriseManagedAuthorization;
|
|
2263
|
-
if (!enterpriseOptions) return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" }
|
|
3067
|
+
if (!enterpriseOptions) return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" }, {
|
|
3068
|
+
category: "enterprise-managed-authorization",
|
|
3069
|
+
reason: "grant_disabled"
|
|
3070
|
+
});
|
|
2264
3071
|
if (clientInfo.tokenEndpointAuthMethod === "none" && !enterpriseOptions.allowPublicClients) return this.createErrorResponse("invalid_client", {
|
|
2265
3072
|
description: "Enterprise-managed authorization requires client authentication",
|
|
2266
3073
|
statusCode: 401
|
|
3074
|
+
}, {
|
|
3075
|
+
category: "enterprise-managed-authorization",
|
|
3076
|
+
reason: "client_authentication_required"
|
|
2267
3077
|
});
|
|
2268
3078
|
const result = await this.runEmaPipeline({
|
|
2269
3079
|
body,
|
|
@@ -2298,8 +3108,8 @@ var OAuthProviderImpl = class {
|
|
|
2298
3108
|
async runEmaPipeline(args) {
|
|
2299
3109
|
const { body, clientInfo, env, requestUrl, request, enterpriseOptions } = args;
|
|
2300
3110
|
const { jwksProvider, jtiStore } = this;
|
|
2301
|
-
const configuredResource = this.
|
|
2302
|
-
if (!jwksProvider || !jtiStore
|
|
3111
|
+
const configuredResource = this.resolveNewTokenResource(body.resource);
|
|
3112
|
+
if (!jwksProvider || !jtiStore) throw new Error("EMA pipeline invoked without configured adapters");
|
|
2303
3113
|
const now = Math.floor(Date.now() / 1e3);
|
|
2304
3114
|
const parsed = parseIdJag(body.assertion, EMA_MAX_JWT_BYTES);
|
|
2305
3115
|
if (!parsed.ok) return parsed;
|
|
@@ -2329,7 +3139,6 @@ var OAuthProviderImpl = class {
|
|
|
2329
3139
|
expectedAudience: trustedIssuer.value.audience ?? this.getAuthorizationServerIssuer(requestUrl),
|
|
2330
3140
|
clientId: clientInfo.clientId,
|
|
2331
3141
|
configuredResource,
|
|
2332
|
-
matchOriginOnly: !!this.options.resourceMatchOriginOnly,
|
|
2333
3142
|
now,
|
|
2334
3143
|
clockSkewSeconds: enterpriseOptions.clockSkewSeconds ?? EMA_DEFAULT_CLOCK_SKEW_SECONDS,
|
|
2335
3144
|
maxAssertionLifetimeSeconds: enterpriseOptions.maxAssertionLifetimeSeconds ?? EMA_DEFAULT_MAX_ASSERTION_LIFETIME_SECONDS
|
|
@@ -2351,7 +3160,7 @@ var OAuthProviderImpl = class {
|
|
|
2351
3160
|
mapperOutput = await enterpriseOptions.mapClaims({
|
|
2352
3161
|
claims: claims.value.claims,
|
|
2353
3162
|
clientInfo,
|
|
2354
|
-
resource:
|
|
3163
|
+
resource: configuredResource,
|
|
2355
3164
|
requestedScope: requestedScope.value,
|
|
2356
3165
|
request: args.request,
|
|
2357
3166
|
env
|
|
@@ -2377,7 +3186,7 @@ var OAuthProviderImpl = class {
|
|
|
2377
3186
|
mapperProps: mapped.value.props,
|
|
2378
3187
|
mapperMetadata: mapped.value.metadata,
|
|
2379
3188
|
assertionScopes: claims.value.assertionScopes,
|
|
2380
|
-
resource:
|
|
3189
|
+
resource: configuredResource,
|
|
2381
3190
|
accessTokenTTLSeconds: ttl.value,
|
|
2382
3191
|
env,
|
|
2383
3192
|
now: issueNow
|
|
@@ -2476,7 +3285,10 @@ var OAuthProviderImpl = class {
|
|
|
2476
3285
|
async revokeToken(body, clientInfo, env) {
|
|
2477
3286
|
const token = body.token;
|
|
2478
3287
|
const tokenTypeHint = body.token_type_hint;
|
|
2479
|
-
if (!token) return this.createErrorResponse("invalid_request", { description: "Token parameter is required" }
|
|
3288
|
+
if (!token) return this.createErrorResponse("invalid_request", { description: "Token parameter is required" }, {
|
|
3289
|
+
category: "token-revocation",
|
|
3290
|
+
reason: "token_missing"
|
|
3291
|
+
});
|
|
2480
3292
|
const tokenParts = token.split(":");
|
|
2481
3293
|
if (tokenParts.length !== 3) return new Response("", { status: 200 });
|
|
2482
3294
|
const [userId, grantId, _] = tokenParts;
|
|
@@ -2531,14 +3343,24 @@ var OAuthProviderImpl = class {
|
|
|
2531
3343
|
if (!this.options.clientRegistrationEndpoint) return this.createErrorResponse("not_implemented", {
|
|
2532
3344
|
description: "Client registration is not enabled",
|
|
2533
3345
|
statusCode: 501
|
|
3346
|
+
}, {
|
|
3347
|
+
category: "client-registration",
|
|
3348
|
+
reason: "registration_disabled"
|
|
2534
3349
|
});
|
|
2535
3350
|
if (request.method !== "POST") return this.createErrorResponse("invalid_request", {
|
|
2536
3351
|
description: "Method not allowed",
|
|
2537
|
-
statusCode: 405
|
|
3352
|
+
statusCode: 405,
|
|
3353
|
+
headers: { Allow: "POST, OPTIONS" }
|
|
3354
|
+
}, {
|
|
3355
|
+
category: "client-registration",
|
|
3356
|
+
reason: "method_not_allowed"
|
|
2538
3357
|
});
|
|
2539
3358
|
if (parseInt(request.headers.get("Content-Length") || "0", 10) > 1048576) return this.createErrorResponse("invalid_request", {
|
|
2540
3359
|
description: "Request payload too large, must be under 1 MiB",
|
|
2541
3360
|
statusCode: 413
|
|
3361
|
+
}, {
|
|
3362
|
+
category: "client-registration",
|
|
3363
|
+
reason: "declared_payload_too_large"
|
|
2542
3364
|
});
|
|
2543
3365
|
const callbackRequest = request.clone();
|
|
2544
3366
|
let parsedJson;
|
|
@@ -2547,12 +3369,18 @@ var OAuthProviderImpl = class {
|
|
|
2547
3369
|
if (text.length > 1048576) return this.createErrorResponse("invalid_request", {
|
|
2548
3370
|
description: "Request payload too large, must be under 1 MiB",
|
|
2549
3371
|
statusCode: 413
|
|
3372
|
+
}, {
|
|
3373
|
+
category: "client-registration",
|
|
3374
|
+
reason: "payload_too_large"
|
|
2550
3375
|
});
|
|
2551
3376
|
parsedJson = JSON.parse(text);
|
|
2552
3377
|
} catch {
|
|
2553
3378
|
return this.createErrorResponse("invalid_request", {
|
|
2554
3379
|
description: "Invalid JSON payload",
|
|
2555
3380
|
statusCode: 400
|
|
3381
|
+
}, {
|
|
3382
|
+
category: "client-registration",
|
|
3383
|
+
reason: "json_malformed"
|
|
2556
3384
|
});
|
|
2557
3385
|
}
|
|
2558
3386
|
let clientMetadata;
|
|
@@ -2561,11 +3389,18 @@ var OAuthProviderImpl = class {
|
|
|
2561
3389
|
clientMetadata = requireJsonObject(parsedJson);
|
|
2562
3390
|
metadata = resolveDynamicClientRegistrationMetadata(clientMetadata, this.serverCapabilities);
|
|
2563
3391
|
} catch (error) {
|
|
2564
|
-
return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" }
|
|
3392
|
+
return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" }, {
|
|
3393
|
+
category: "client-registration",
|
|
3394
|
+
reason: "metadata_invalid",
|
|
3395
|
+
detail: error
|
|
3396
|
+
});
|
|
2565
3397
|
}
|
|
2566
3398
|
const authMethod = metadata.tokenEndpointAuthMethod;
|
|
2567
3399
|
const isPublicClient = authMethod === "none";
|
|
2568
|
-
if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" }
|
|
3400
|
+
if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" }, {
|
|
3401
|
+
category: "client-registration",
|
|
3402
|
+
reason: "public_client_forbidden"
|
|
3403
|
+
});
|
|
2569
3404
|
const clientId = generateRandomString(16);
|
|
2570
3405
|
let clientSecret;
|
|
2571
3406
|
let hashedSecret;
|
|
@@ -2602,16 +3437,22 @@ var OAuthProviderImpl = class {
|
|
|
2602
3437
|
return this.createErrorResponse("server_error", {
|
|
2603
3438
|
description: error instanceof Error ? error.message : "Client registration callback failed",
|
|
2604
3439
|
statusCode: 500
|
|
3440
|
+
}, {
|
|
3441
|
+
category: "client-registration",
|
|
3442
|
+
reason: "callback_failed",
|
|
3443
|
+
detail: error
|
|
2605
3444
|
});
|
|
2606
3445
|
}
|
|
2607
3446
|
if (callbackResult !== void 0) return this.createErrorResponse(callbackResult.code || "invalid_client_metadata", {
|
|
2608
3447
|
description: callbackResult.description || "Client registration denied",
|
|
2609
3448
|
statusCode: callbackResult.status ?? 400
|
|
3449
|
+
}, {
|
|
3450
|
+
category: "client-registration",
|
|
3451
|
+
reason: "callback_denied",
|
|
3452
|
+
detail: callbackResult
|
|
2610
3453
|
});
|
|
2611
3454
|
}
|
|
2612
|
-
|
|
2613
|
-
if (this.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.options.clientRegistrationTTL;
|
|
2614
|
-
await env.OAUTH_KV.put(`client:${clientInfo.clientId}`, JSON.stringify(clientInfo), clientKvOptions);
|
|
3455
|
+
await this.putRegisteredClient(env, clientInfo, clientInfo.registrationDate);
|
|
2615
3456
|
const response = {
|
|
2616
3457
|
client_id: clientInfo.clientId,
|
|
2617
3458
|
redirect_uris: clientInfo.redirectUris,
|
|
@@ -2650,18 +3491,23 @@ var OAuthProviderImpl = class {
|
|
|
2650
3491
|
* @param ctx - Cloudflare Worker execution context
|
|
2651
3492
|
* @returns Response from the API handler or error
|
|
2652
3493
|
*/
|
|
2653
|
-
async handleApiRequest(request, env, ctx) {
|
|
3494
|
+
async handleApiRequest(request, env, ctx, apiRoute) {
|
|
2654
3495
|
const url = new URL(request.url);
|
|
2655
|
-
const
|
|
3496
|
+
const { resourceServer } = apiRoute;
|
|
3497
|
+
const configuredResource = resourceServer.resourceMetadata.resource;
|
|
3498
|
+
const externalTokenResolver = resourceServer.resolveExternalToken;
|
|
3499
|
+
const resourceMetadataUrl = this.getResourceMetadataUrlForRequest(url, resourceServer);
|
|
3500
|
+
const challenge = (error, description, scopes = []) => this.buildWwwAuthenticateHeader(resourceMetadataUrl, error, description, scopes, resourceServer);
|
|
2656
3501
|
const authHeader = request.headers.get("Authorization");
|
|
2657
|
-
|
|
3502
|
+
const bearerMatch = authHeader ? /^Bearer[\t ]+([^\s,]+)$/i.exec(authHeader) : null;
|
|
3503
|
+
if (!bearerMatch) return new Response(null, {
|
|
2658
3504
|
status: 401,
|
|
2659
3505
|
headers: {
|
|
2660
3506
|
...NO_CACHE_HEADERS,
|
|
2661
|
-
"WWW-Authenticate":
|
|
3507
|
+
"WWW-Authenticate": challenge()
|
|
2662
3508
|
}
|
|
2663
3509
|
});
|
|
2664
|
-
const accessToken =
|
|
3510
|
+
const accessToken = bearerMatch[1];
|
|
2665
3511
|
const parts = accessToken.split(":");
|
|
2666
3512
|
const isPossiblyInternalFormat = parts.length === 3;
|
|
2667
3513
|
let tokenData = null;
|
|
@@ -2672,75 +3518,104 @@ var OAuthProviderImpl = class {
|
|
|
2672
3518
|
const id = await generateTokenId(accessToken);
|
|
2673
3519
|
tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
|
|
2674
3520
|
}
|
|
2675
|
-
if (!tokenData && !
|
|
3521
|
+
if (!tokenData && !externalTokenResolver) return this.createErrorResponse("invalid_token", {
|
|
2676
3522
|
description: "Invalid access token",
|
|
2677
3523
|
statusCode: 401,
|
|
2678
|
-
headers: { "WWW-Authenticate":
|
|
3524
|
+
headers: { "WWW-Authenticate": challenge("invalid_token") }
|
|
3525
|
+
}, {
|
|
3526
|
+
category: "protected-resource",
|
|
3527
|
+
reason: "token_not_found"
|
|
2679
3528
|
});
|
|
2680
3529
|
if (tokenData) {
|
|
2681
|
-
const
|
|
2682
|
-
if (
|
|
3530
|
+
const tokenAudience = this.resolveStoredTokenAudience(tokenData.audience);
|
|
3531
|
+
if (!isExactResource(tokenAudience, configuredResource)) return this.createErrorResponse("invalid_token", {
|
|
2683
3532
|
description: "Access token is not bound to the configured resource",
|
|
2684
3533
|
statusCode: 401,
|
|
2685
|
-
headers: { "WWW-Authenticate":
|
|
3534
|
+
headers: { "WWW-Authenticate": challenge("invalid_token") }
|
|
3535
|
+
}, {
|
|
3536
|
+
category: "protected-resource",
|
|
3537
|
+
reason: "token_audience_unbound"
|
|
2686
3538
|
});
|
|
2687
3539
|
const now = Math.floor(Date.now() / 1e3);
|
|
2688
3540
|
if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", {
|
|
2689
3541
|
description: "Access token expired",
|
|
2690
3542
|
statusCode: 401,
|
|
2691
|
-
headers: { "WWW-Authenticate":
|
|
3543
|
+
headers: { "WWW-Authenticate": challenge("invalid_token") }
|
|
3544
|
+
}, {
|
|
3545
|
+
category: "protected-resource",
|
|
3546
|
+
reason: "token_expired"
|
|
2692
3547
|
});
|
|
2693
|
-
if (
|
|
3548
|
+
if (tokenAudience) {
|
|
2694
3549
|
const requestUrl = new URL(request.url);
|
|
2695
|
-
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
|
|
2696
|
-
if (!(Array.isArray(
|
|
3550
|
+
const resourceServer$1 = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
|
|
3551
|
+
if (!(Array.isArray(tokenAudience) ? tokenAudience : [tokenAudience]).some((aud) => audienceMatches(resourceServer$1, aud))) return this.createErrorResponse("invalid_token", {
|
|
2697
3552
|
description: "Token audience does not match resource server",
|
|
2698
3553
|
statusCode: 401,
|
|
2699
|
-
headers: { "WWW-Authenticate":
|
|
3554
|
+
headers: { "WWW-Authenticate": challenge("invalid_token", "Invalid audience") }
|
|
3555
|
+
}, {
|
|
3556
|
+
category: "protected-resource",
|
|
3557
|
+
reason: "audience_mismatch"
|
|
2700
3558
|
});
|
|
2701
3559
|
}
|
|
2702
3560
|
ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
|
|
2703
|
-
|
|
3561
|
+
ctx.auth = {
|
|
3562
|
+
token: accessToken,
|
|
3563
|
+
audience: configuredResource,
|
|
3564
|
+
expiresAt: tokenData.expiresAt,
|
|
3565
|
+
scope: [...tokenData.scope || tokenData.grant.scope],
|
|
3566
|
+
userId: tokenData.userId,
|
|
3567
|
+
clientId: tokenData.grant.clientId
|
|
3568
|
+
};
|
|
3569
|
+
} else if (externalTokenResolver) {
|
|
2704
3570
|
let ext;
|
|
2705
3571
|
try {
|
|
2706
|
-
ext = await
|
|
3572
|
+
ext = await externalTokenResolver({
|
|
2707
3573
|
token: accessToken,
|
|
2708
3574
|
request,
|
|
2709
3575
|
env
|
|
2710
3576
|
});
|
|
2711
3577
|
} catch (error) {
|
|
2712
|
-
const response = this.createExternalTokenErrorResponse(error, resourceMetadataUrl);
|
|
3578
|
+
const response = this.createExternalTokenErrorResponse(error, resourceMetadataUrl, resourceServer);
|
|
2713
3579
|
if (response) return response;
|
|
2714
3580
|
throw error;
|
|
2715
3581
|
}
|
|
2716
3582
|
if (!ext) return this.createErrorResponse("invalid_token", {
|
|
2717
3583
|
description: "Invalid access token",
|
|
2718
3584
|
statusCode: 401,
|
|
2719
|
-
headers: { "WWW-Authenticate":
|
|
3585
|
+
headers: { "WWW-Authenticate": challenge("invalid_token") }
|
|
3586
|
+
}, {
|
|
3587
|
+
category: "protected-resource",
|
|
3588
|
+
reason: "external_token_rejected"
|
|
2720
3589
|
});
|
|
2721
|
-
|
|
2722
|
-
if (configuredResource && !isExactResource(ext.audience, configuredResource)) return this.createErrorResponse("invalid_token", {
|
|
3590
|
+
if (typeof ext.audience !== "string" || !isExactResource(ext.audience, configuredResource)) return this.createErrorResponse("invalid_token", {
|
|
2723
3591
|
description: "External access token is not bound to the configured resource",
|
|
2724
3592
|
statusCode: 401,
|
|
2725
|
-
headers: { "WWW-Authenticate":
|
|
3593
|
+
headers: { "WWW-Authenticate": challenge("invalid_token") }
|
|
3594
|
+
}, {
|
|
3595
|
+
category: "protected-resource",
|
|
3596
|
+
reason: "external_audience_unbound"
|
|
2726
3597
|
});
|
|
2727
3598
|
if (ext.audience) {
|
|
2728
3599
|
const requestUrl = new URL(request.url);
|
|
2729
|
-
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
|
|
2730
|
-
if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
|
|
3600
|
+
const resourceServer$1 = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
|
|
3601
|
+
if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer$1, aud))) return this.createErrorResponse("invalid_token", {
|
|
2731
3602
|
description: "Token audience does not match resource server",
|
|
2732
3603
|
statusCode: 401,
|
|
2733
|
-
headers: { "WWW-Authenticate":
|
|
3604
|
+
headers: { "WWW-Authenticate": challenge("invalid_token", "Invalid audience") }
|
|
3605
|
+
}, {
|
|
3606
|
+
category: "protected-resource",
|
|
3607
|
+
reason: "external_audience_mismatch"
|
|
2734
3608
|
});
|
|
2735
3609
|
}
|
|
2736
3610
|
ctx.props = ext.props;
|
|
3611
|
+
ctx.auth = {
|
|
3612
|
+
token: accessToken,
|
|
3613
|
+
audience: configuredResource,
|
|
3614
|
+
scope: []
|
|
3615
|
+
};
|
|
2737
3616
|
}
|
|
2738
3617
|
if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
|
|
2739
|
-
const apiHandler =
|
|
2740
|
-
if (!apiHandler) return this.createErrorResponse("invalid_request", {
|
|
2741
|
-
description: "No handler found for API route",
|
|
2742
|
-
statusCode: 404
|
|
2743
|
-
});
|
|
3618
|
+
const apiHandler = apiRoute.handler;
|
|
2744
3619
|
if (apiHandler.type === HandlerType.EXPORTED_HANDLER) return apiHandler.handler.fetch(request, env, ctx);
|
|
2745
3620
|
else return new apiHandler.handler(ctx, env).fetch(request);
|
|
2746
3621
|
}
|
|
@@ -2764,7 +3639,7 @@ var OAuthProviderImpl = class {
|
|
|
2764
3639
|
const minExpiration = now + KV_MIN_EXPIRATION_TTL_SECONDS + KV_EXPIRATION_CLAMP_MARGIN_SECONDS;
|
|
2765
3640
|
const kvOptions = grantData.expiresAt !== void 0 ? { expiration: Math.max(grantData.expiresAt, minExpiration) } : {};
|
|
2766
3641
|
try {
|
|
2767
|
-
await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
|
|
3642
|
+
await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), grantPutOptions(grantData, kvOptions));
|
|
2768
3643
|
} catch (error) {
|
|
2769
3644
|
this.throwRetryableTokenStorageErrorIfKvRateLimited(error);
|
|
2770
3645
|
throw error;
|
|
@@ -2775,7 +3650,12 @@ var OAuthProviderImpl = class {
|
|
|
2775
3650
|
throw new OAuthError("temporarily_unavailable", {
|
|
2776
3651
|
description: "Token issuance is temporarily unavailable; retry shortly",
|
|
2777
3652
|
statusCode: 429,
|
|
2778
|
-
headers: { "Retry-After": "30" }
|
|
3653
|
+
headers: { "Retry-After": "30" },
|
|
3654
|
+
internal: {
|
|
3655
|
+
category: "token-issuance",
|
|
3656
|
+
reason: "kv_rate_limited",
|
|
3657
|
+
detail: error
|
|
3658
|
+
}
|
|
2779
3659
|
});
|
|
2780
3660
|
}
|
|
2781
3661
|
isKvRateLimitError(error) {
|
|
@@ -2798,6 +3678,51 @@ var OAuthProviderImpl = class {
|
|
|
2798
3678
|
* propagate, and a CIMD metadata fetch failure throws `CimdFetchError`), so an
|
|
2799
3679
|
* upstream outage is distinguishable from an unregistered client.
|
|
2800
3680
|
*/
|
|
3681
|
+
/**
|
|
3682
|
+
* Write a client record that is subject to `clientRegistrationTTL`. The record carries
|
|
3683
|
+
* the expiry it was written with, so a later token request can tell a registration the
|
|
3684
|
+
* provider may renew from a permanent one without listing KV. With the TTL unset the
|
|
3685
|
+
* record is written to last, and any stamp from an earlier configuration is dropped.
|
|
3686
|
+
*/
|
|
3687
|
+
async putRegisteredClient(env, client, now) {
|
|
3688
|
+
const ttl = this.options.clientRegistrationTTL;
|
|
3689
|
+
const { registrationExpiresAt: _previous, ...record } = client;
|
|
3690
|
+
if (ttl === void 0) {
|
|
3691
|
+
await env.OAUTH_KV.put(`client:${client.clientId}`, JSON.stringify(record));
|
|
3692
|
+
return;
|
|
3693
|
+
}
|
|
3694
|
+
await env.OAUTH_KV.put(`client:${client.clientId}`, JSON.stringify({
|
|
3695
|
+
...record,
|
|
3696
|
+
registrationExpiresAt: now + ttl
|
|
3697
|
+
}), { expirationTtl: ttl });
|
|
3698
|
+
}
|
|
3699
|
+
/**
|
|
3700
|
+
* Renew a dynamically registered client whose registration has passed half of
|
|
3701
|
+
* `clientRegistrationTTL`, so a client still exchanging tokens does not vanish from
|
|
3702
|
+
* under its live grants. Without this, a grant that outlives its registration fails
|
|
3703
|
+
* every refresh with `invalid_client` although nothing was revoked. Only records
|
|
3704
|
+
* stamped with `registrationExpiresAt` qualify: CIMD clients are never stored,
|
|
3705
|
+
* `createClient()` records never expire, and a record from before the stamp existed
|
|
3706
|
+
* cannot be told from a permanent one, so it keeps its original schedule and
|
|
3707
|
+
* re-registers once. The half-life rule bounds this to one write per client per half
|
|
3708
|
+
* TTL. Best effort: the token response it follows has already been produced.
|
|
3709
|
+
*
|
|
3710
|
+
* KV has no compare-and-set, so a `deleteClient()` that lands between the read below and
|
|
3711
|
+
* the write is undone by it, as it would be by a concurrent `updateClient()`. The window
|
|
3712
|
+
* is one KV round trip, once per client per half TTL; closing it needs the serialised
|
|
3713
|
+
* storage of #237.
|
|
3714
|
+
*/
|
|
3715
|
+
async renewClientRegistrationIfDue(env, client, now) {
|
|
3716
|
+
const ttl = this.options.clientRegistrationTTL;
|
|
3717
|
+
if (ttl === void 0 || client.registrationExpiresAt === void 0) return;
|
|
3718
|
+
if (client.registrationExpiresAt - now > ttl / 2) return;
|
|
3719
|
+
try {
|
|
3720
|
+
const current = await env.OAUTH_KV.get(`client:${client.clientId}`, { type: "json" });
|
|
3721
|
+
if (current) await this.putRegisteredClient(env, current, now);
|
|
3722
|
+
} catch (error) {
|
|
3723
|
+
console.warn(`Failed to renew client registration ${client.clientId}:`, error);
|
|
3724
|
+
}
|
|
3725
|
+
}
|
|
2801
3726
|
async getClient(env, clientId) {
|
|
2802
3727
|
if (this.isClientMetadataUrl(clientId)) {
|
|
2803
3728
|
if (!this.options.clientIdMetadataDocumentEnabled) {
|
|
@@ -2815,33 +3740,140 @@ var OAuthProviderImpl = class {
|
|
|
2815
3740
|
const clientKey = `client:${clientId}`;
|
|
2816
3741
|
return env.OAUTH_KV.get(clientKey, { type: "json" });
|
|
2817
3742
|
}
|
|
3743
|
+
/** Resolve a value to the registry's canonical spelling, requiring one value. */
|
|
3744
|
+
/**
|
|
3745
|
+
* Resolve a stored grant or token resource. A 0.x record may hold an array; it resolves
|
|
3746
|
+
* when exactly one configured resource appears in it, so multi-audience grants for two
|
|
3747
|
+
* resources this server still hosts are ambiguous and fail closed.
|
|
3748
|
+
*/
|
|
3749
|
+
findStoredConfiguredResource(value) {
|
|
3750
|
+
if (!Array.isArray(value)) return this.findConfiguredResource(value);
|
|
3751
|
+
const matches = /* @__PURE__ */ new Set();
|
|
3752
|
+
for (const entry of value) {
|
|
3753
|
+
const configured = this.findConfiguredResource(entry);
|
|
3754
|
+
if (configured) matches.add(configured);
|
|
3755
|
+
}
|
|
3756
|
+
return matches.size === 1 ? [...matches][0] : void 0;
|
|
3757
|
+
}
|
|
3758
|
+
findConfiguredResource(value) {
|
|
3759
|
+
const distinct = Array.isArray(value) ? [...new Set(value)] : [value];
|
|
3760
|
+
const singular = distinct.length === 1 ? distinct[0] : void 0;
|
|
3761
|
+
if (typeof singular !== "string" || !validateResourceUri(singular)) return void 0;
|
|
3762
|
+
return this.resourceServers.map((server) => server.resourceMetadata.resource).find((configured) => isExactResource(singular, configured));
|
|
3763
|
+
}
|
|
3764
|
+
/** Select the audience for a new interactive authorization. */
|
|
3765
|
+
resolveAuthorizationRequestResource(requestedResource) {
|
|
3766
|
+
if (requestedResource === void 0) {
|
|
3767
|
+
const defaultResource = this.getDefaultAuthorizationResource();
|
|
3768
|
+
if (defaultResource) return defaultResource;
|
|
3769
|
+
throw new AuthorizationError("invalid_target", { description: "The resource parameter is required when the authorization server has multiple resources" });
|
|
3770
|
+
}
|
|
3771
|
+
const configured = this.findConfiguredResource(requestedResource);
|
|
3772
|
+
if (!configured) throw new AuthorizationError("invalid_target", { description: "The resource parameter must name exactly one configured protected resource" });
|
|
3773
|
+
return configured;
|
|
3774
|
+
}
|
|
3775
|
+
/** Whether an existing grant belongs to the replacement bucket for a resource. */
|
|
3776
|
+
shouldReplaceGrantForResource(grantResource, resource) {
|
|
3777
|
+
if (grantResource === void 0) return this.getLegacyGrantResource() === resource;
|
|
3778
|
+
return isExactResource(grantResource, resource);
|
|
3779
|
+
}
|
|
3780
|
+
/** Select the audience for a new non-interactive grant such as EMA. */
|
|
3781
|
+
resolveNewTokenResource(requestedResource) {
|
|
3782
|
+
try {
|
|
3783
|
+
return this.resolveAuthorizationRequestResource(requestedResource);
|
|
3784
|
+
} catch (error) {
|
|
3785
|
+
if (error instanceof AuthorizationError) throw new OAuthError("invalid_target", {
|
|
3786
|
+
description: error.description,
|
|
3787
|
+
internal: {
|
|
3788
|
+
category: "resource-indicator",
|
|
3789
|
+
reason: "resource_invalid",
|
|
3790
|
+
detail: error
|
|
3791
|
+
}
|
|
3792
|
+
});
|
|
3793
|
+
throw error;
|
|
3794
|
+
}
|
|
3795
|
+
}
|
|
3796
|
+
/** Validate explicit resource syntax and configured-resource policy. */
|
|
3797
|
+
validateTokenRequestResourceIndicator(requestedResource) {
|
|
3798
|
+
if (requestedResource === void 0) return;
|
|
3799
|
+
if (!this.findConfiguredResource(requestedResource)) throw new OAuthError("invalid_target", {
|
|
3800
|
+
description: "The resource parameter must name exactly one configured protected resource",
|
|
3801
|
+
internal: {
|
|
3802
|
+
category: "resource-indicator",
|
|
3803
|
+
reason: "resource_not_configured"
|
|
3804
|
+
}
|
|
3805
|
+
});
|
|
3806
|
+
}
|
|
3807
|
+
/** Resolve token exchange strictly within the subject token's audience ceiling. */
|
|
3808
|
+
resolveTokenExchangeResource(requestedResource, subjectResource) {
|
|
3809
|
+
const subjectAudience = this.findStoredConfiguredResource(this.resolveStoredTokenAudience(subjectResource));
|
|
3810
|
+
if (!subjectAudience) throw new OAuthError("invalid_target", {
|
|
3811
|
+
description: "Subject token is not bound to a configured resource",
|
|
3812
|
+
internal: {
|
|
3813
|
+
category: "resource-indicator",
|
|
3814
|
+
reason: "subject_token_audience_unbound"
|
|
3815
|
+
}
|
|
3816
|
+
});
|
|
3817
|
+
const resourceWasProvided = requestedResource !== void 0;
|
|
3818
|
+
const requestedAudience = resourceWasProvided ? this.findConfiguredResource(requestedResource) : void 0;
|
|
3819
|
+
if (resourceWasProvided && (!requestedAudience || requestedAudience !== subjectAudience)) throw new OAuthError("invalid_target", {
|
|
3820
|
+
description: "The requested resource must exactly match the subject token audience",
|
|
3821
|
+
internal: {
|
|
3822
|
+
category: "resource-indicator",
|
|
3823
|
+
reason: "resource_subject_token_mismatch"
|
|
3824
|
+
}
|
|
3825
|
+
});
|
|
3826
|
+
return subjectAudience;
|
|
3827
|
+
}
|
|
2818
3828
|
/**
|
|
2819
3829
|
* Resolves an access-token audience from a token request and its authorization grant.
|
|
2820
|
-
*
|
|
2821
|
-
* Without configuration, RFC 8707 downscoping is allowed, omission inherits a
|
|
2822
|
-
* bound grant, and a legacy unbound grant retains the v0.8.2 behavior.
|
|
3830
|
+
* The configured canonical resource is inherited when omitted and cannot be overridden.
|
|
2823
3831
|
*/
|
|
2824
|
-
resolveTokenResource(requestedResource,
|
|
3832
|
+
resolveTokenResource(requestedResource, grant) {
|
|
3833
|
+
const grantedResource = grant.resource;
|
|
2825
3834
|
const resourceWasProvided = requestedResource !== void 0;
|
|
2826
|
-
const requestedAudience = parseResourceParameter(requestedResource);
|
|
2827
|
-
if (resourceWasProvided && !requestedAudience) throw new OAuthError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
2828
3835
|
const grantResourceWasStored = grantedResource !== void 0;
|
|
2829
|
-
const
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
}
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
3836
|
+
const canonicalGrantResource = this.findStoredConfiguredResource(grantedResource);
|
|
3837
|
+
const canonicalRequestedResource = resourceWasProvided ? this.findConfiguredResource(requestedResource) : void 0;
|
|
3838
|
+
if (grantResourceWasStored && !canonicalGrantResource) throw new OAuthError("invalid_grant", {
|
|
3839
|
+
description: "The authorization grant is not bound to a configured resource",
|
|
3840
|
+
internal: {
|
|
3841
|
+
category: "resource-indicator",
|
|
3842
|
+
reason: "grant_audience_unbound"
|
|
3843
|
+
}
|
|
3844
|
+
});
|
|
3845
|
+
if (canonicalGrantResource) {
|
|
3846
|
+
if (resourceWasProvided && canonicalRequestedResource !== canonicalGrantResource) throw new OAuthError("invalid_target", {
|
|
3847
|
+
description: "The requested resource does not match the authorization grant",
|
|
3848
|
+
internal: {
|
|
3849
|
+
category: "resource-indicator",
|
|
3850
|
+
reason: "resource_grant_mismatch"
|
|
3851
|
+
}
|
|
3852
|
+
});
|
|
3853
|
+
return {
|
|
3854
|
+
audience: canonicalGrantResource,
|
|
3855
|
+
...grantedResource === canonicalGrantResource ? {} : { grantResourceBackfill: canonicalGrantResource }
|
|
3856
|
+
};
|
|
2843
3857
|
}
|
|
2844
|
-
|
|
3858
|
+
const legacyGrantResource = this.getLegacyGrantResource();
|
|
3859
|
+
if (!legacyGrantResource) throw new OAuthError("invalid_grant", {
|
|
3860
|
+
description: "This legacy authorization grant has no resource binding and must be reauthorized",
|
|
3861
|
+
internal: {
|
|
3862
|
+
category: "resource-indicator",
|
|
3863
|
+
reason: "legacy_grant_unbound"
|
|
3864
|
+
}
|
|
3865
|
+
});
|
|
3866
|
+
if (resourceWasProvided && canonicalRequestedResource !== legacyGrantResource) throw new OAuthError("invalid_target", {
|
|
3867
|
+
description: "The requested resource does not match the server legacy-grant migration policy",
|
|
3868
|
+
internal: {
|
|
3869
|
+
category: "resource-indicator",
|
|
3870
|
+
reason: "legacy_grant_resource_mismatch"
|
|
3871
|
+
}
|
|
3872
|
+
});
|
|
3873
|
+
return {
|
|
3874
|
+
audience: legacyGrantResource,
|
|
3875
|
+
grantResourceBackfill: legacyGrantResource
|
|
3876
|
+
};
|
|
2845
3877
|
}
|
|
2846
3878
|
/**
|
|
2847
3879
|
* Creates and stores an access token
|
|
@@ -2850,7 +3882,13 @@ var OAuthProviderImpl = class {
|
|
|
2850
3882
|
*/
|
|
2851
3883
|
async createAccessToken(params) {
|
|
2852
3884
|
const { userId, grantId, clientId, scope, encryptedProps, encryptionKey, expiresIn, audience, env } = params;
|
|
2853
|
-
if (expiresIn
|
|
3885
|
+
if (!isValidAccessTokenTTL(expiresIn)) throw new OAuthError("invalid_request", {
|
|
3886
|
+
description: "Requested token lifetime must be at least 60 seconds",
|
|
3887
|
+
internal: {
|
|
3888
|
+
category: "token-issuance",
|
|
3889
|
+
reason: "requested_ttl_too_short"
|
|
3890
|
+
}
|
|
3891
|
+
});
|
|
2854
3892
|
const accessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
2855
3893
|
const now = Math.floor(Date.now() / 1e3);
|
|
2856
3894
|
const accessTokenId = await generateTokenId(accessToken);
|
|
@@ -2906,14 +3944,36 @@ var OAuthProviderImpl = class {
|
|
|
2906
3944
|
/**
|
|
2907
3945
|
* Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
|
|
2908
3946
|
*/
|
|
2909
|
-
buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription, requiredScopes = []) {
|
|
2910
|
-
let header =
|
|
3947
|
+
buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription, requiredScopes = [], resourceServer = this.resourceServers[0]) {
|
|
3948
|
+
let header = "Bearer realm=\"OAuth\"";
|
|
3949
|
+
if (resourceMetadataUrl) header += `, resource_metadata="${resourceMetadataUrl}"`;
|
|
2911
3950
|
if (error) header += `, error="${error}"`;
|
|
2912
|
-
const challengeScopes = requiredScopes.length > 0 ? this.normalizeProtectedResourceScopes(requiredScopes) : this.getProtectedResourceScopes();
|
|
3951
|
+
const challengeScopes = requiredScopes.length > 0 ? this.normalizeProtectedResourceScopes(requiredScopes) : this.getProtectedResourceScopes(resourceServer);
|
|
2913
3952
|
if (challengeScopes.length > 0) header += `, scope="${challengeScopes.join(" ")}"`;
|
|
2914
3953
|
if (errorDescription) header += `, error_description="${errorDescription}"`;
|
|
2915
3954
|
return header;
|
|
2916
3955
|
}
|
|
3956
|
+
/** Build the RFC 9728 well-known URL for the configured canonical resource. */
|
|
3957
|
+
getConfiguredResourceMetadataUrl(resource) {
|
|
3958
|
+
const authorityStart = resource.indexOf("://") + 3;
|
|
3959
|
+
const suffixOffset = resource.slice(authorityStart).search(/[/?]/);
|
|
3960
|
+
const suffixStart = suffixOffset === -1 ? resource.length : authorityStart + suffixOffset;
|
|
3961
|
+
let suffix = resource.slice(suffixStart);
|
|
3962
|
+
if (suffix === "/") suffix = "";
|
|
3963
|
+
if (suffix.startsWith("/?")) suffix = suffix.slice(1);
|
|
3964
|
+
return resource.slice(0, suffixStart) + PROTECTED_RESOURCE_WELL_KNOWN_PREFIX + suffix;
|
|
3965
|
+
}
|
|
3966
|
+
/**
|
|
3967
|
+
* RFC 9728 §3.3 requires metadata fetched from a challenge to identify the
|
|
3968
|
+
* original protected-resource URL. Descendant and alias routes can still use
|
|
3969
|
+
* a base audience, but must not advertise a document for a different URL.
|
|
3970
|
+
*/
|
|
3971
|
+
getResourceMetadataUrlForRequest(requestUrl, resourceServer) {
|
|
3972
|
+
const configuredResource = resourceServer.resourceMetadata.resource;
|
|
3973
|
+
if (isExactResource(requestUrl.href, configuredResource)) return this.getConfiguredResourceMetadataUrl(configuredResource);
|
|
3974
|
+
const resourceUrl = new URL(configuredResource);
|
|
3975
|
+
if (requestUrl.origin === resourceUrl.origin && requestCarriesResourceQuery(requestUrl, resourceUrl) && isPathDescendant(requestUrl.pathname, resourceUrl.pathname)) return this.getConfiguredResourceMetadataUrl(configuredResource);
|
|
3976
|
+
}
|
|
2917
3977
|
/**
|
|
2918
3978
|
* Helper function to create OAuth error responses.
|
|
2919
3979
|
*
|
|
@@ -2934,21 +3994,22 @@ var OAuthProviderImpl = class {
|
|
|
2934
3994
|
description,
|
|
2935
3995
|
status: responseStatus,
|
|
2936
3996
|
headers: responseHeaders,
|
|
2937
|
-
|
|
3997
|
+
internal,
|
|
2938
3998
|
...request ? { request } : {}
|
|
2939
3999
|
});
|
|
2940
|
-
if (customErrorResponse) return customErrorResponse;
|
|
2941
4000
|
const body = JSON.stringify({
|
|
2942
4001
|
error: code,
|
|
2943
4002
|
error_description: description
|
|
2944
4003
|
});
|
|
2945
|
-
|
|
4004
|
+
const response = customErrorResponse ?? new Response(body, {
|
|
2946
4005
|
status: responseStatus,
|
|
2947
4006
|
headers: {
|
|
2948
4007
|
"Content-Type": "application/json",
|
|
2949
4008
|
...responseHeaders
|
|
2950
4009
|
}
|
|
2951
4010
|
});
|
|
4011
|
+
this.errorResponses.add(response);
|
|
4012
|
+
return response;
|
|
2952
4013
|
}
|
|
2953
4014
|
};
|
|
2954
4015
|
/**
|
|
@@ -3079,6 +4140,9 @@ const DEFAULT_CLIENT_REGISTRATION_TTL = 2160 * 60 * 60;
|
|
|
3079
4140
|
* to clamp absolute expirations when writing grants back to KV.
|
|
3080
4141
|
*/
|
|
3081
4142
|
const KV_MIN_EXPIRATION_TTL_SECONDS = 60;
|
|
4143
|
+
function isValidAccessTokenTTL(value) {
|
|
4144
|
+
return Number.isInteger(value) && value >= KV_MIN_EXPIRATION_TTL_SECONDS;
|
|
4145
|
+
}
|
|
3082
4146
|
/**
|
|
3083
4147
|
* Safety margin (seconds) added on top of `KV_MIN_EXPIRATION_TTL_SECONDS` when clamping an
|
|
3084
4148
|
* absolute KV expiration. Absolute expirations are validated against KV's clock at the
|
|
@@ -3097,8 +4161,9 @@ const DEFAULT_PURGE_BATCH_SIZE = 50;
|
|
|
3097
4161
|
*/
|
|
3098
4162
|
const MAX_KV_LIST_LIMIT = 1e3;
|
|
3099
4163
|
/**
|
|
3100
|
-
*
|
|
3101
|
-
*
|
|
4164
|
+
* How many grants written without key metadata `completeAuthorization()` reads at
|
|
4165
|
+
* once while looking for grants to replace. Grants written with key metadata are
|
|
4166
|
+
* matched from `list()` alone and never read.
|
|
3102
4167
|
*/
|
|
3103
4168
|
const DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE = 50;
|
|
3104
4169
|
function getRevokeExistingGrantsBatchSize(batchSize) {
|
|
@@ -3106,27 +4171,38 @@ function getRevokeExistingGrantsBatchSize(batchSize) {
|
|
|
3106
4171
|
if (!Number.isFinite(batchSize) || !Number.isInteger(batchSize) || batchSize < 1) throw new Error("revokeExistingGrantsBatchSize must be a positive integer.");
|
|
3107
4172
|
return Math.min(batchSize, MAX_KV_LIST_LIMIT);
|
|
3108
4173
|
}
|
|
4174
|
+
/** KV serialises key metadata as JSON and rejects a write carrying more than this. */
|
|
4175
|
+
const MAX_KV_METADATA_BYTES = 1024;
|
|
3109
4176
|
/**
|
|
3110
|
-
*
|
|
4177
|
+
* Key metadata for a grant record, or `undefined` when it would not fit. A grant
|
|
4178
|
+
* written without metadata is read individually when scanned, exactly like a grant
|
|
4179
|
+
* written before metadata existed; that only happens when a `client_id` and two URLs
|
|
4180
|
+
* together exceed a kilobyte.
|
|
3111
4181
|
*/
|
|
3112
|
-
|
|
4182
|
+
function grantKeyMetadata(grant) {
|
|
4183
|
+
const metadata = { clientId: grant.clientId };
|
|
4184
|
+
if (grant.resource !== void 0) metadata.resource = grant.resource;
|
|
4185
|
+
if (grant.redirectUri !== void 0) metadata.redirectUri = grant.redirectUri;
|
|
4186
|
+
return new TextEncoder().encode(JSON.stringify(metadata)).byteLength <= MAX_KV_METADATA_BYTES ? metadata : void 0;
|
|
4187
|
+
}
|
|
4188
|
+
/** Every grant write goes through here so the key metadata cannot drift from the record. */
|
|
4189
|
+
function grantPutOptions(grant, expiry = {}) {
|
|
4190
|
+
const metadata = grantKeyMetadata(grant);
|
|
4191
|
+
return metadata ? {
|
|
4192
|
+
...expiry,
|
|
4193
|
+
metadata
|
|
4194
|
+
} : expiry;
|
|
4195
|
+
}
|
|
4196
|
+
/** Whether a listed key carries metadata this version can match a grant on. */
|
|
4197
|
+
function isGrantKeyMetadata(value) {
|
|
4198
|
+
if (typeof value !== "object" || value === null) return false;
|
|
4199
|
+
const { clientId, resource, redirectUri } = value;
|
|
4200
|
+
return typeof clientId === "string" && (resource === void 0 || typeof resource === "string" || Array.isArray(resource)) && (redirectUri === void 0 || typeof redirectUri === "string");
|
|
4201
|
+
}
|
|
3113
4202
|
/**
|
|
3114
|
-
*
|
|
3115
|
-
* @param uri - The URI string to validate
|
|
3116
|
-
* @returns true if valid, false otherwise
|
|
4203
|
+
* Length of generated token strings
|
|
3117
4204
|
*/
|
|
3118
|
-
|
|
3119
|
-
if (!uri || typeof uri !== "string") return false;
|
|
3120
|
-
try {
|
|
3121
|
-
const parsed = new URL(uri);
|
|
3122
|
-
if (!parsed.protocol) return false;
|
|
3123
|
-
if (parsed.hash) return false;
|
|
3124
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
3125
|
-
return true;
|
|
3126
|
-
} catch {
|
|
3127
|
-
return false;
|
|
3128
|
-
}
|
|
3129
|
-
}
|
|
4205
|
+
const TOKEN_LENGTH = 32;
|
|
3130
4206
|
/**
|
|
3131
4207
|
* Checks if a resource server matches an audience claim.
|
|
3132
4208
|
* Uses origin comparison (case-insensitive hostname via URL normalization)
|
|
@@ -3140,42 +4216,37 @@ function audienceMatches(resourceServerUrl, audienceValue) {
|
|
|
3140
4216
|
const resource = new URL(resourceServerUrl);
|
|
3141
4217
|
const audience = new URL(audienceValue);
|
|
3142
4218
|
if (resource.origin !== audience.origin) return false;
|
|
3143
|
-
if (
|
|
4219
|
+
if (!requestCarriesResourceQuery(resource, audience)) return false;
|
|
3144
4220
|
if (audience.pathname === "/" || audience.pathname === "") return true;
|
|
3145
|
-
|
|
4221
|
+
const descendantPrefix = audience.pathname.endsWith("/") ? audience.pathname : audience.pathname + "/";
|
|
4222
|
+
return resource.pathname === audience.pathname || resource.pathname.startsWith(descendantPrefix);
|
|
3146
4223
|
} catch {
|
|
3147
4224
|
return false;
|
|
3148
4225
|
}
|
|
3149
4226
|
}
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
* @returns The validated value as string, string array, or undefined if validation fails
|
|
3155
|
-
*/
|
|
3156
|
-
function parseResourceParameter(value) {
|
|
3157
|
-
if (!value) return;
|
|
3158
|
-
const uris = Array.isArray(value) ? value : [value];
|
|
3159
|
-
if (uris.length === 0) return;
|
|
3160
|
-
for (const uri of uris) if (typeof uri !== "string" || !validateResourceUri(uri)) return;
|
|
3161
|
-
return value;
|
|
4227
|
+
function appendHeaderValue(headers, name, value) {
|
|
4228
|
+
const values = (headers.get(name) ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
4229
|
+
if (!values.some((item) => item.toLowerCase() === value.toLowerCase())) values.push(value);
|
|
4230
|
+
headers.set(name, values.join(", "));
|
|
3162
4231
|
}
|
|
3163
|
-
/** Whether a request or audience names one
|
|
4232
|
+
/** Whether a request or audience names one canonical configured resource. */
|
|
3164
4233
|
function isExactResource(value, configuredResource) {
|
|
3165
|
-
|
|
4234
|
+
if (Array.isArray(value)) return value.some((entry) => typeof entry === "string" && resourceMatches(entry, configuredResource));
|
|
4235
|
+
return typeof value === "string" && resourceMatches(value, configuredResource);
|
|
3166
4236
|
}
|
|
3167
|
-
/**
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
4237
|
+
/** RFC 9110 §9.3.2: a HEAD response carries the GET headers and no body. */
|
|
4238
|
+
function withoutBodyForHead(request, response) {
|
|
4239
|
+
if (request.method !== "HEAD") return response;
|
|
4240
|
+
return new Response(null, {
|
|
4241
|
+
status: response.status,
|
|
4242
|
+
headers: response.headers
|
|
4243
|
+
});
|
|
4244
|
+
}
|
|
4245
|
+
/** Whether `candidate` is `base` or a path-boundary descendant of it (trailing slashes ignored). */
|
|
4246
|
+
function isPathDescendant(candidate, base) {
|
|
4247
|
+
if (base === "" || base === "/") return true;
|
|
4248
|
+
if (base.endsWith("/")) return candidate.startsWith(base);
|
|
4249
|
+
return candidate === base || candidate.startsWith(`${base}/`);
|
|
3179
4250
|
}
|
|
3180
4251
|
/**
|
|
3181
4252
|
* Hashes a secret value using SHA-256
|
|
@@ -3246,11 +4317,7 @@ async function generateTokenId(token) {
|
|
|
3246
4317
|
*/
|
|
3247
4318
|
function isLoopbackUri(uri) {
|
|
3248
4319
|
try {
|
|
3249
|
-
|
|
3250
|
-
if (host.match(/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) return true;
|
|
3251
|
-
if (host === "::1" || host === "[::1]") return true;
|
|
3252
|
-
if (host.toLowerCase() === "localhost") return true;
|
|
3253
|
-
return false;
|
|
4320
|
+
return isLoopbackHostname(new URL(uri).hostname);
|
|
3254
4321
|
} catch {
|
|
3255
4322
|
return false;
|
|
3256
4323
|
}
|
|
@@ -3480,6 +4547,7 @@ var OAuthHelpersImpl = class {
|
|
|
3480
4547
|
*/
|
|
3481
4548
|
async parseAuthRequest(request) {
|
|
3482
4549
|
const url = new URL(request.url);
|
|
4550
|
+
if (this.provider.hasExplicitIssuer && !this.provider.isAuthorizationEndpointRequest(url)) throw new AuthorizationError("invalid_request", { description: "Authorization request was sent to an unconfigured endpoint" });
|
|
3483
4551
|
const responseType = url.searchParams.get("response_type") || "";
|
|
3484
4552
|
const clientId = url.searchParams.get("client_id") || "";
|
|
3485
4553
|
const redirectUri = url.searchParams.get("redirect_uri") || "";
|
|
@@ -3502,13 +4570,12 @@ var OAuthHelpersImpl = class {
|
|
|
3502
4570
|
const withRedirect = (error) => {
|
|
3503
4571
|
throw withAuthorizationRedirect(error, redirectUri, state || void 0, issuer);
|
|
3504
4572
|
};
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
resource = configuredResource;
|
|
4573
|
+
let resource;
|
|
4574
|
+
try {
|
|
4575
|
+
resource = this.provider.resolveAuthorizationRequestResource(resourceParam);
|
|
4576
|
+
} catch (error) {
|
|
4577
|
+
if (error instanceof AuthorizationError) withRedirect(error);
|
|
4578
|
+
throw error;
|
|
3512
4579
|
}
|
|
3513
4580
|
try {
|
|
3514
4581
|
validateAuthorizationResponseType(this.provider.serverCapabilities, responseType, clientInfo.responseTypes);
|
|
@@ -3563,26 +4630,13 @@ var OAuthHelpersImpl = class {
|
|
|
3563
4630
|
const clientInfo = await this.lookupClient(clientId);
|
|
3564
4631
|
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.");
|
|
3565
4632
|
validateAuthorizationResponseType(this.provider.serverCapabilities, options.request.responseType, clientInfo.responseTypes);
|
|
3566
|
-
const
|
|
3567
|
-
const resourceWasProvided = options.request.resource !== void 0;
|
|
3568
|
-
const parsedResource = parseResourceParameter(options.request.resource);
|
|
3569
|
-
if (resourceWasProvided && !parsedResource) throw new AuthorizationError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
3570
|
-
if (configuredResource && resourceWasProvided && !isExactResource(parsedResource, configuredResource)) throw new AuthorizationError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` });
|
|
3571
|
-
const effectiveResource = configuredResource ?? parsedResource;
|
|
4633
|
+
const effectiveResource = this.provider.resolveAuthorizationRequestResource(options.request.resource);
|
|
3572
4634
|
validateAuthorizationPkce(this.provider.serverCapabilities, options.request, clientInfo);
|
|
3573
4635
|
let grantsToRevoke = [];
|
|
3574
4636
|
if (options.revokeExistingGrants !== false) {
|
|
3575
4637
|
const isCimdClient = this.provider.isClientMetadataUrl(clientId);
|
|
3576
|
-
const
|
|
3577
|
-
|
|
3578
|
-
do {
|
|
3579
|
-
const page = await this.listUserGrants(options.userId, {
|
|
3580
|
-
cursor,
|
|
3581
|
-
limit: batchSize
|
|
3582
|
-
});
|
|
3583
|
-
for (const grant of page.items) if (grant.clientId === clientId && (!isCimdClient || grant.redirectUri === options.request.redirectUri)) grantsToRevoke.push(grant.id);
|
|
3584
|
-
cursor = page.cursor;
|
|
3585
|
-
} while (cursor);
|
|
4638
|
+
const readConcurrency = getRevokeExistingGrantsBatchSize(options.revokeExistingGrantsBatchSize);
|
|
4639
|
+
grantsToRevoke = await this.findGrantIds(options.userId, (grant) => grant.clientId === clientId && (!isCimdClient || grant.redirectUri === options.request.redirectUri) && this.provider.shouldReplaceGrantForResource(grant.resource, effectiveResource), readConcurrency);
|
|
3586
4640
|
}
|
|
3587
4641
|
const grantId = generateRandomString(16);
|
|
3588
4642
|
const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
|
|
@@ -3594,8 +4648,7 @@ var OAuthHelpersImpl = class {
|
|
|
3594
4648
|
const accessTokenTTL = this.provider.options.accessTokenTTL || DEFAULT_ACCESS_TOKEN_TTL;
|
|
3595
4649
|
const accessTokenExpiresAt = now + accessTokenTTL;
|
|
3596
4650
|
const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, encryptionKey);
|
|
3597
|
-
const audience =
|
|
3598
|
-
if (effectiveResource && !audience) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
|
|
4651
|
+
const audience = effectiveResource;
|
|
3599
4652
|
const grant = {
|
|
3600
4653
|
id: grantId,
|
|
3601
4654
|
clientId: options.request.clientId,
|
|
@@ -3608,7 +4661,7 @@ var OAuthHelpersImpl = class {
|
|
|
3608
4661
|
redirectUri: options.request.redirectUri
|
|
3609
4662
|
};
|
|
3610
4663
|
const grantKey = `grant:${options.userId}:${grantId}`;
|
|
3611
|
-
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant));
|
|
4664
|
+
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), grantPutOptions(grant));
|
|
3612
4665
|
const accessTokenData = {
|
|
3613
4666
|
id: accessTokenId,
|
|
3614
4667
|
grantId,
|
|
@@ -3631,6 +4684,7 @@ var OAuthHelpersImpl = class {
|
|
|
3631
4684
|
fragment.set("token_type", "bearer");
|
|
3632
4685
|
fragment.set("expires_in", accessTokenTTL.toString());
|
|
3633
4686
|
fragment.set("scope", options.scope.join(" "));
|
|
4687
|
+
fragment.set("resource", effectiveResource);
|
|
3634
4688
|
if (options.request.state) fragment.set("state", options.request.state);
|
|
3635
4689
|
if (options.request.issuer) fragment.set("iss", options.request.issuer);
|
|
3636
4690
|
redirectUrl.hash = fragment.toString();
|
|
@@ -3659,7 +4713,7 @@ var OAuthHelpersImpl = class {
|
|
|
3659
4713
|
redirectUri: options.request.redirectUri
|
|
3660
4714
|
};
|
|
3661
4715
|
const grantKey = `grant:${options.userId}:${grantId}`;
|
|
3662
|
-
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), { expirationTtl: 600 });
|
|
4716
|
+
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), grantPutOptions(grant, { expirationTtl: 600 }));
|
|
3663
4717
|
const redirectUrl = new URL(options.request.redirectUri);
|
|
3664
4718
|
redirectUrl.searchParams.set("code", authCode);
|
|
3665
4719
|
if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
|
|
@@ -3671,6 +4725,39 @@ var OAuthHelpersImpl = class {
|
|
|
3671
4725
|
}
|
|
3672
4726
|
}
|
|
3673
4727
|
/**
|
|
4728
|
+
* IDs of the user's grants that satisfy `matches`, judged from KV key metadata
|
|
4729
|
+
* wherever a grant carries it. Only grants written without metadata, by a version
|
|
4730
|
+
* before 1.0 or because theirs would not fit, are read, `readConcurrency` at a time.
|
|
4731
|
+
*
|
|
4732
|
+
* This runs on the interactive authorization path, where the pre-metadata cost of one
|
|
4733
|
+
* `get()` per grant broke re-authorization for a user with more grants than a Worker
|
|
4734
|
+
* has subrequests. The cost is now one `list()` per thousand grants plus one `get()`
|
|
4735
|
+
* per legacy grant, and every refresh rewrites its grant with metadata, so the legacy
|
|
4736
|
+
* share shrinks on its own.
|
|
4737
|
+
*/
|
|
4738
|
+
async findGrantIds(userId, matches, readConcurrency) {
|
|
4739
|
+
const prefix = `grant:${userId}:`;
|
|
4740
|
+
const found = [];
|
|
4741
|
+
let cursor;
|
|
4742
|
+
do {
|
|
4743
|
+
const page = await this.env.OAUTH_KV.list({
|
|
4744
|
+
prefix,
|
|
4745
|
+
limit: MAX_KV_LIST_LIMIT,
|
|
4746
|
+
cursor
|
|
4747
|
+
});
|
|
4748
|
+
const toRead = [];
|
|
4749
|
+
for (const key of page.keys) if (isGrantKeyMetadata(key.metadata)) {
|
|
4750
|
+
if (matches(key.metadata)) found.push(key.name.slice(prefix.length));
|
|
4751
|
+
} else toRead.push(key.name);
|
|
4752
|
+
for (let start = 0; start < toRead.length; start += readConcurrency) {
|
|
4753
|
+
const grants = await Promise.all(toRead.slice(start, start + readConcurrency).map((name) => this.env.OAUTH_KV.get(name, { type: "json" })));
|
|
4754
|
+
for (const grant of grants) if (grant && matches(grant)) found.push(grant.id);
|
|
4755
|
+
}
|
|
4756
|
+
cursor = page.list_complete ? void 0 : page.cursor;
|
|
4757
|
+
} while (cursor);
|
|
4758
|
+
return found;
|
|
4759
|
+
}
|
|
4760
|
+
/**
|
|
3674
4761
|
* Creates a new OAuth client
|
|
3675
4762
|
* @param clientInfo - Partial client information to create the client with
|
|
3676
4763
|
* @returns A Promise resolving to the created client info
|
|
@@ -3762,9 +4849,7 @@ var OAuthHelpersImpl = class {
|
|
|
3762
4849
|
};
|
|
3763
4850
|
if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
|
|
3764
4851
|
else delete updatedClient.clientSecret;
|
|
3765
|
-
|
|
3766
|
-
if (this.provider.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.provider.options.clientRegistrationTTL;
|
|
3767
|
-
await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(updatedClient), clientKvOptions);
|
|
4852
|
+
await this.provider.putRegisteredClient(this.env, updatedClient, Math.floor(Date.now() / 1e3));
|
|
3768
4853
|
const response = toPublicClientInfo(updatedClient);
|
|
3769
4854
|
if (!isPublicClient && originalSecret) response.clientSecret = originalSecret;
|
|
3770
4855
|
return response;
|
|
@@ -3814,7 +4899,8 @@ var OAuthHelpersImpl = class {
|
|
|
3814
4899
|
metadata: grantData.metadata,
|
|
3815
4900
|
createdAt: grantData.createdAt,
|
|
3816
4901
|
expiresAt: grantData.expiresAt,
|
|
3817
|
-
redirectUri: grantData.redirectUri
|
|
4902
|
+
redirectUri: grantData.redirectUri,
|
|
4903
|
+
resource: grantData.resource
|
|
3818
4904
|
};
|
|
3819
4905
|
grantSummaries.push(summary);
|
|
3820
4906
|
}
|
|
@@ -3964,4 +5050,4 @@ var OAuthHelpersImpl = class {
|
|
|
3964
5050
|
var oauth_provider_default = OAuthProvider;
|
|
3965
5051
|
|
|
3966
5052
|
//#endregion
|
|
3967
|
-
export { AuthorizationError, CimdFetchError, ExternalTokenError, GrantType, OAuthError, OAuthProvider, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
|
|
5053
|
+
export { AuthorizationError, CimdFetchError, ExternalTokenError, GrantType, OAuthAuthorizationServer, OAuthError, OAuthProvider, OAuthResourceServer, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, insufficientScope, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
|