@cloudflare/workers-oauth-provider 0.10.4 → 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 +1393 -309
- 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
|
@@ -911,6 +911,96 @@ async function verifyIdJagSignature(input) {
|
|
|
911
911
|
}
|
|
912
912
|
}
|
|
913
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
|
+
|
|
914
1004
|
//#endregion
|
|
915
1005
|
//#region src/ema/validators.ts
|
|
916
1006
|
/**
|
|
@@ -1036,7 +1126,7 @@ function isWellFormedTrustedIssuer(issuer) {
|
|
|
1036
1126
|
* - `scope` (if present) conforms to RFC 6749 §3.3 grammar
|
|
1037
1127
|
*/
|
|
1038
1128
|
function validateIdJagClaims(input) {
|
|
1039
|
-
const { rawClaims, trustedIssuer, expectedAudience, clientId, configuredResource
|
|
1129
|
+
const { rawClaims, trustedIssuer, expectedAudience, clientId, configuredResource } = input;
|
|
1040
1130
|
const { now, clockSkewSeconds, maxAssertionLifetimeSeconds } = input;
|
|
1041
1131
|
const iss = readRequiredString(rawClaims, "iss");
|
|
1042
1132
|
if (!iss.ok) return iss;
|
|
@@ -1076,7 +1166,7 @@ function validateIdJagClaims(input) {
|
|
|
1076
1166
|
reason: "resource_invalid",
|
|
1077
1167
|
resource: resource.value
|
|
1078
1168
|
});
|
|
1079
|
-
if (!resourceMatches(resource.value, configuredResource
|
|
1169
|
+
if (!resourceMatches(resource.value, configuredResource)) return err({
|
|
1080
1170
|
reason: "resource_mismatch",
|
|
1081
1171
|
expected: configuredResource,
|
|
1082
1172
|
got: resource.value
|
|
@@ -1228,6 +1318,237 @@ function readNumericDateClaim(claims, claimName) {
|
|
|
1228
1318
|
return ok(value);
|
|
1229
1319
|
}
|
|
1230
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
|
+
|
|
1231
1552
|
//#endregion
|
|
1232
1553
|
//#region src/oauth-provider.ts
|
|
1233
1554
|
const PROTECTED_RESOURCE_WELL_KNOWN_PREFIX = "/.well-known/oauth-protected-resource";
|
|
@@ -1256,7 +1577,7 @@ let GrantType = /* @__PURE__ */ function(GrantType$1) {
|
|
|
1256
1577
|
return GrantType$1;
|
|
1257
1578
|
}({});
|
|
1258
1579
|
function toPublicClientInfo(client) {
|
|
1259
|
-
const { authMethodExplicit: _explicit, ...publicClient } = client;
|
|
1580
|
+
const { authMethodExplicit: _explicit, registrationExpiresAt: _expiry, ...publicClient } = client;
|
|
1260
1581
|
return publicClient;
|
|
1261
1582
|
}
|
|
1262
1583
|
function isClientAuthMethodAllowed(client, presentedMethod, isClientMetadataDocument) {
|
|
@@ -1302,6 +1623,66 @@ var OAuthProvider = class {
|
|
|
1302
1623
|
}
|
|
1303
1624
|
};
|
|
1304
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
|
+
/**
|
|
1305
1686
|
* Gets OAuthHelpers for the given environment
|
|
1306
1687
|
* @param options - Configuration options for the OAuth provider
|
|
1307
1688
|
* @param env - Cloudflare Worker environment variables
|
|
@@ -1324,37 +1705,95 @@ var OAuthProviderImpl = class {
|
|
|
1324
1705
|
* @param options - Configuration options for the provider
|
|
1325
1706
|
*/
|
|
1326
1707
|
constructor(options) {
|
|
1708
|
+
this.errorResponses = /* @__PURE__ */ new WeakSet();
|
|
1327
1709
|
this.typedApiHandlers = [];
|
|
1328
|
-
const hasSingleHandlerConfig = !!(options.apiRoute && options.apiHandler);
|
|
1329
|
-
const hasMultiHandlerConfig = !!options.apiHandlers;
|
|
1330
|
-
if (hasSingleHandlerConfig && hasMultiHandlerConfig) throw new TypeError("Cannot use both apiRoute/apiHandler and apiHandlers. Use either apiRoute + apiHandler OR apiHandlers, not both.");
|
|
1331
|
-
if (!hasSingleHandlerConfig && !hasMultiHandlerConfig) throw new TypeError("Must provide either apiRoute + apiHandler OR apiHandlers. No API route configuration provided.");
|
|
1332
1710
|
this.typedDefaultHandler = this.validateHandler(options.defaultHandler, "defaultHandler");
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
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
|
+
}];
|
|
1346
1737
|
}
|
|
1347
|
-
this.validateEndpoint(options.authorizeEndpoint, "authorizeEndpoint");
|
|
1348
|
-
this.validateEndpoint(options.tokenEndpoint, "tokenEndpoint");
|
|
1349
|
-
if (options.clientRegistrationEndpoint) this.validateEndpoint(options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
|
|
1350
1738
|
this.options = {
|
|
1351
1739
|
accessTokenTTL: DEFAULT_ACCESS_TOKEN_TTL,
|
|
1352
1740
|
refreshTokenTTL: DEFAULT_REFRESH_TOKEN_TTL,
|
|
1353
1741
|
clientRegistrationTTL: DEFAULT_CLIENT_REGISTRATION_TTL,
|
|
1354
1742
|
onError: ({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`),
|
|
1355
|
-
...
|
|
1743
|
+
...normalizedOptions
|
|
1356
1744
|
};
|
|
1357
|
-
|
|
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).`);
|
|
1358
1797
|
this.serverCapabilities = buildOAuthServerCapabilities({
|
|
1359
1798
|
allowImplicitFlow: !!this.options.allowImplicitFlow,
|
|
1360
1799
|
allowPlainPKCE: this.options.allowPlainPKCE === true,
|
|
@@ -1362,7 +1801,6 @@ var OAuthProviderImpl = class {
|
|
|
1362
1801
|
enterpriseManagedAuthorization: !!this.options.enterpriseManagedAuthorization
|
|
1363
1802
|
});
|
|
1364
1803
|
validateAuthorizationServerScopes(this.options.scopesSupported);
|
|
1365
|
-
this.validateResourceMetadataOptions(this.options.resourceMetadata);
|
|
1366
1804
|
this.validateEmaOptions(this.options.enterpriseManagedAuthorization);
|
|
1367
1805
|
if (this.options.enterpriseManagedAuthorization) {
|
|
1368
1806
|
this.jwksProvider = createDefaultJwksProvider({ cacheTtlSeconds: this.options.enterpriseManagedAuthorization.jwksCacheTtlSeconds });
|
|
@@ -1378,11 +1816,90 @@ var OAuthProviderImpl = class {
|
|
|
1378
1816
|
validateEndpoint(endpoint, name) {
|
|
1379
1817
|
if (this.isPath(endpoint)) {
|
|
1380
1818
|
if (!endpoint.startsWith("/")) throw new TypeError(`${name} path must be an absolute path starting with /`);
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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");
|
|
1385
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;
|
|
1386
1903
|
}
|
|
1387
1904
|
/**
|
|
1388
1905
|
* Validates that a handler is either an ExportedHandler or a class extending WorkerEntrypoint
|
|
@@ -1402,10 +1919,22 @@ var OAuthProviderImpl = class {
|
|
|
1402
1919
|
};
|
|
1403
1920
|
throw new TypeError(`${name} must be either an ExportedHandler object with a fetch method or a class extending WorkerEntrypoint`);
|
|
1404
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
|
+
}
|
|
1405
1931
|
/** Validate configured RFC 9728 protected resource metadata. */
|
|
1406
1932
|
validateResourceMetadataOptions(options) {
|
|
1407
|
-
if (!options)
|
|
1408
|
-
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`);
|
|
1409
1938
|
if (options.authorization_servers !== void 0) {
|
|
1410
1939
|
if (options.authorization_servers.length === 0) throw new TypeError("resourceMetadata.authorization_servers must contain at least one issuer");
|
|
1411
1940
|
for (const issuer of options.authorization_servers) {
|
|
@@ -1415,7 +1944,7 @@ var OAuthProviderImpl = class {
|
|
|
1415
1944
|
} catch {
|
|
1416
1945
|
throw new TypeError("resourceMetadata.authorization_servers must contain valid HTTPS issuer URLs");
|
|
1417
1946
|
}
|
|
1418
|
-
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)");
|
|
1419
1948
|
}
|
|
1420
1949
|
}
|
|
1421
1950
|
if (options.scopes_supported?.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("resourceMetadata.scopes_supported must contain valid OAuth scope tokens");
|
|
@@ -1433,7 +1962,6 @@ var OAuthProviderImpl = class {
|
|
|
1433
1962
|
if (!options) return;
|
|
1434
1963
|
if (typeof options.trustedIssuers !== "function") throw new TypeError("enterpriseManagedAuthorization.trustedIssuers must be a resolver function: (input) => EmaTrustedIssuer | null");
|
|
1435
1964
|
if (typeof options.mapClaims !== "function") throw new TypeError("enterpriseManagedAuthorization.mapClaims must be a function");
|
|
1436
|
-
if (!this.options.resourceMetadata?.resource) throw new TypeError("enterpriseManagedAuthorization requires resourceMetadata.resource to be configured");
|
|
1437
1965
|
if (options.jwksCacheTtlSeconds !== void 0 && options.jwksCacheTtlSeconds <= 0) throw new TypeError("enterpriseManagedAuthorization.jwksCacheTtlSeconds must be greater than 0");
|
|
1438
1966
|
if (options.clockSkewSeconds !== void 0 && options.clockSkewSeconds < 0) throw new TypeError("enterpriseManagedAuthorization.clockSkewSeconds must be non-negative");
|
|
1439
1967
|
if (options.maxAssertionLifetimeSeconds !== void 0 && options.maxAssertionLifetimeSeconds <= 0) throw new TypeError("enterpriseManagedAuthorization.maxAssertionLifetimeSeconds must be greater than 0");
|
|
@@ -1447,20 +1975,38 @@ var OAuthProviderImpl = class {
|
|
|
1447
1975
|
* @returns A Promise resolving to an HTTP Response
|
|
1448
1976
|
*/
|
|
1449
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) {
|
|
1450
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;
|
|
1451
1988
|
if (request.method === "OPTIONS") {
|
|
1452
|
-
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, {
|
|
1453
1990
|
status: 204,
|
|
1454
1991
|
headers: { "Content-Length": "0" }
|
|
1455
1992
|
}), request);
|
|
1456
1993
|
}
|
|
1457
|
-
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);
|
|
1458
1999
|
const response = await this.handleMetadataDiscovery(url);
|
|
1459
|
-
return this.addCorsHeaders(response, request);
|
|
2000
|
+
return this.addCorsHeaders(withoutBodyForHead(request, response), request);
|
|
1460
2001
|
}
|
|
1461
|
-
if (this.
|
|
1462
|
-
|
|
1463
|
-
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);
|
|
1464
2010
|
}
|
|
1465
2011
|
if (this.isTokenEndpoint(url)) {
|
|
1466
2012
|
const parsed = await this.parseTokenEndpointRequest(request, env);
|
|
@@ -1468,16 +2014,18 @@ var OAuthProviderImpl = class {
|
|
|
1468
2014
|
let response;
|
|
1469
2015
|
if (parsed.isRevocationRequest) response = await this.handleRevocationRequest(parsed.body, parsed.clientInfo, env);
|
|
1470
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));
|
|
1471
2018
|
return this.addCorsHeaders(response, request);
|
|
1472
2019
|
}
|
|
1473
2020
|
if (this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) {
|
|
1474
2021
|
const response = await this.handleClientRegistration(request, env);
|
|
1475
2022
|
return this.addCorsHeaders(response, request);
|
|
1476
2023
|
}
|
|
1477
|
-
if (
|
|
1478
|
-
const response = await this.handleApiRequest(request, env, ctx);
|
|
2024
|
+
if (apiRoute) {
|
|
2025
|
+
const response = await this.handleApiRequest(request, env, ctx, apiRoute);
|
|
1479
2026
|
return this.addCorsHeaders(response, request);
|
|
1480
2027
|
}
|
|
2028
|
+
if (typeof role === "object") return new Response(null, { status: 404 });
|
|
1481
2029
|
if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
|
|
1482
2030
|
if (this.typedDefaultHandler.type === HandlerType.EXPORTED_HANDLER) return this.typedDefaultHandler.handler.fetch(request, env, ctx);
|
|
1483
2031
|
return new this.typedDefaultHandler.handler(ctx, env).fetch(request);
|
|
@@ -1514,6 +2062,20 @@ var OAuthProviderImpl = class {
|
|
|
1514
2062
|
}
|
|
1515
2063
|
};
|
|
1516
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
|
+
}
|
|
1517
2079
|
/**
|
|
1518
2080
|
* Determines if an endpoint configuration is a path or a full URL
|
|
1519
2081
|
* @param endpoint - The endpoint configuration
|
|
@@ -1528,12 +2090,26 @@ var OAuthProviderImpl = class {
|
|
|
1528
2090
|
* @param endpoint - The endpoint pattern (full URL or path)
|
|
1529
2091
|
* @returns True if the URL matches the endpoint pattern
|
|
1530
2092
|
*/
|
|
1531
|
-
matchEndpoint(url, endpoint) {
|
|
1532
|
-
if (this.
|
|
1533
|
-
|
|
1534
|
-
const
|
|
1535
|
-
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);
|
|
1536
2110
|
}
|
|
2111
|
+
if (!allowAdditionalQuery) return unmatchedActualQuery.length === 0;
|
|
2112
|
+
return unmatchedActualQuery.every(([name]) => !configuredQueryNames.has(name));
|
|
1537
2113
|
}
|
|
1538
2114
|
/**
|
|
1539
2115
|
* Checks if a URL matches the configured token endpoint
|
|
@@ -1541,7 +2117,10 @@ var OAuthProviderImpl = class {
|
|
|
1541
2117
|
* @returns True if the URL matches the token endpoint
|
|
1542
2118
|
*/
|
|
1543
2119
|
isTokenEndpoint(url) {
|
|
1544
|
-
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);
|
|
1545
2124
|
}
|
|
1546
2125
|
/**
|
|
1547
2126
|
* Checks if a URL matches the configured client registration endpoint
|
|
@@ -1550,28 +2129,37 @@ var OAuthProviderImpl = class {
|
|
|
1550
2129
|
*/
|
|
1551
2130
|
isClientRegistrationEndpoint(url) {
|
|
1552
2131
|
if (!this.options.clientRegistrationEndpoint) return false;
|
|
1553
|
-
return this.matchEndpoint(url, this.options.clientRegistrationEndpoint);
|
|
2132
|
+
return this.matchEndpoint(url, this.options.clientRegistrationEndpoint, true);
|
|
1554
2133
|
}
|
|
1555
2134
|
/**
|
|
1556
2135
|
* Checks if a URL is a request for OAuth Protected Resource Metadata (RFC 9728).
|
|
1557
|
-
*
|
|
2136
|
+
* Only the well-known URL constructed from the configured canonical resource
|
|
2137
|
+
* may return its document; aliases would violate RFC 9728 §3.3.
|
|
1558
2138
|
*/
|
|
1559
|
-
|
|
1560
|
-
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
|
+
});
|
|
1561
2144
|
}
|
|
1562
|
-
/**
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
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 + "/");
|
|
1575
2163
|
}
|
|
1576
2164
|
createInvalidClientResponse(description, basicAuthenticationAttempted, internal, request) {
|
|
1577
2165
|
return this.createErrorResponse("invalid_client", {
|
|
@@ -1588,13 +2176,20 @@ var OAuthProviderImpl = class {
|
|
|
1588
2176
|
async parseTokenEndpointRequest(request, env) {
|
|
1589
2177
|
if (request.method !== "POST") return this.createErrorResponse("invalid_request", {
|
|
1590
2178
|
description: "Method not allowed",
|
|
1591
|
-
statusCode: 405
|
|
2179
|
+
statusCode: 405,
|
|
2180
|
+
headers: { Allow: "POST, OPTIONS" }
|
|
2181
|
+
}, {
|
|
2182
|
+
category: "token-endpoint-request",
|
|
2183
|
+
reason: "method_not_allowed"
|
|
1592
2184
|
});
|
|
1593
2185
|
const contentType = request.headers.get("Content-Type") || "";
|
|
1594
2186
|
let body = {};
|
|
1595
2187
|
if (contentType.split(";")[0].trim().toLowerCase() !== "application/x-www-form-urlencoded") return this.createErrorResponse("invalid_request", {
|
|
1596
2188
|
description: "Content-Type must be application/x-www-form-urlencoded",
|
|
1597
2189
|
statusCode: 400
|
|
2190
|
+
}, {
|
|
2191
|
+
category: "token-endpoint-request",
|
|
2192
|
+
reason: "unsupported_content_type"
|
|
1598
2193
|
});
|
|
1599
2194
|
let formData;
|
|
1600
2195
|
try {
|
|
@@ -1603,6 +2198,9 @@ var OAuthProviderImpl = class {
|
|
|
1603
2198
|
return this.createErrorResponse("invalid_request", {
|
|
1604
2199
|
description: "Request body must be valid application/x-www-form-urlencoded data",
|
|
1605
2200
|
statusCode: 400
|
|
2201
|
+
}, {
|
|
2202
|
+
category: "token-endpoint-request",
|
|
2203
|
+
reason: "malformed_request_body"
|
|
1606
2204
|
});
|
|
1607
2205
|
}
|
|
1608
2206
|
const processedKeys = /* @__PURE__ */ new Set();
|
|
@@ -1613,6 +2211,10 @@ var OAuthProviderImpl = class {
|
|
|
1613
2211
|
if (key !== "resource" && allValues.length > 1) return this.createErrorResponse("invalid_request", {
|
|
1614
2212
|
description: `Request parameter "${key}" must not be repeated`,
|
|
1615
2213
|
statusCode: 400
|
|
2214
|
+
}, {
|
|
2215
|
+
category: "token-endpoint-request",
|
|
2216
|
+
reason: "repeated_parameter",
|
|
2217
|
+
detail: { parameter: key }
|
|
1616
2218
|
});
|
|
1617
2219
|
body[key] = allValues.length > 1 ? allValues : value;
|
|
1618
2220
|
}
|
|
@@ -1624,15 +2226,24 @@ var OAuthProviderImpl = class {
|
|
|
1624
2226
|
if (formData.has("client_id") || formData.has("client_secret")) return this.createErrorResponse("invalid_request", {
|
|
1625
2227
|
description: "Client must not use multiple authentication methods",
|
|
1626
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"
|
|
1627
2236
|
});
|
|
1628
|
-
if (basicAuthorization.kind === "malformed") return this.createInvalidClientResponse("Client authentication failed: invalid Basic credentials", basicAuthenticationAttempted);
|
|
1629
2237
|
clientId = basicAuthorization.clientId;
|
|
1630
2238
|
clientSecret = basicAuthorization.clientSecret;
|
|
1631
2239
|
} else {
|
|
1632
2240
|
clientId = body.client_id;
|
|
1633
2241
|
clientSecret = body.client_secret || "";
|
|
1634
2242
|
}
|
|
1635
|
-
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
|
+
});
|
|
1636
2247
|
let clientInfo;
|
|
1637
2248
|
try {
|
|
1638
2249
|
clientInfo = await this.getClient(env, clientId);
|
|
@@ -1647,7 +2258,10 @@ var OAuthProviderImpl = class {
|
|
|
1647
2258
|
}, request);
|
|
1648
2259
|
throw error;
|
|
1649
2260
|
}
|
|
1650
|
-
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
|
+
});
|
|
1651
2265
|
const presentedAuthMethod = basicAuthenticationAttempted ? "client_secret_basic" : formData.has("client_secret") ? "client_secret_post" : "none";
|
|
1652
2266
|
const registeredAuthMethod = clientInfo.tokenEndpointAuthMethod;
|
|
1653
2267
|
if (!isClientAuthMethodAllowed(clientInfo, presentedAuthMethod, !!this.options.clientIdMetadataDocumentEnabled && this.isClientMetadataUrl(clientInfo.clientId))) return this.createInvalidClientResponse("Client authentication failed", basicAuthenticationAttempted, {
|
|
@@ -1660,9 +2274,18 @@ var OAuthProviderImpl = class {
|
|
|
1660
2274
|
}
|
|
1661
2275
|
});
|
|
1662
2276
|
if (presentedAuthMethod !== "none") {
|
|
1663
|
-
if (!clientSecret) return this.createInvalidClientResponse("Client authentication failed: missing client_secret", basicAuthenticationAttempted
|
|
1664
|
-
|
|
1665
|
-
|
|
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
|
+
});
|
|
1666
2289
|
}
|
|
1667
2290
|
const isRevocationRequest = !body.grant_type && !!body.token;
|
|
1668
2291
|
return {
|
|
@@ -1678,30 +2301,24 @@ var OAuthProviderImpl = class {
|
|
|
1678
2301
|
* @returns True if the URL matches the API route
|
|
1679
2302
|
*/
|
|
1680
2303
|
matchApiRoute(url, route) {
|
|
1681
|
-
|
|
1682
|
-
if (
|
|
1683
|
-
|
|
1684
|
-
|
|
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 {
|
|
1685
2311
|
const apiUrl = new URL(route);
|
|
1686
|
-
return url.
|
|
2312
|
+
return url.origin === apiUrl.origin && pathMatches(apiUrl.pathname, true) && requestCarriesResourceQuery(url, apiUrl);
|
|
1687
2313
|
}
|
|
1688
2314
|
}
|
|
1689
2315
|
/**
|
|
1690
|
-
* Checks if a URL is an API request based on the configured API route(s)
|
|
1691
|
-
* @param url - The URL to check
|
|
1692
|
-
* @returns True if the URL matches any of the API routes
|
|
1693
|
-
*/
|
|
1694
|
-
isApiRequest(url) {
|
|
1695
|
-
for (const [route, _] of this.typedApiHandlers) if (this.matchApiRoute(url, route)) return true;
|
|
1696
|
-
return false;
|
|
1697
|
-
}
|
|
1698
|
-
/**
|
|
1699
2316
|
* Finds the appropriate API handler for a URL
|
|
1700
2317
|
* @param url - The URL to find a handler for
|
|
1701
2318
|
* @returns The TypedHandler for the URL, or undefined if no handler matches
|
|
1702
2319
|
*/
|
|
1703
|
-
|
|
1704
|
-
|
|
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];
|
|
1705
2322
|
}
|
|
1706
2323
|
/**
|
|
1707
2324
|
* Gets the full URL for an endpoint, using the provided request URL's
|
|
@@ -1711,13 +2328,14 @@ var OAuthProviderImpl = class {
|
|
|
1711
2328
|
* @returns The full URL for the endpoint
|
|
1712
2329
|
*/
|
|
1713
2330
|
getFullEndpointUrl(endpoint, requestUrl) {
|
|
1714
|
-
if (this.isPath(endpoint)) return `${requestUrl.origin}${endpoint}`;
|
|
2331
|
+
if (this.isPath(endpoint)) return `${this.explicitIssuer ? new URL(this.explicitIssuer).origin : requestUrl.origin}${endpoint}`;
|
|
1715
2332
|
else return endpoint;
|
|
1716
2333
|
}
|
|
1717
2334
|
/**
|
|
1718
2335
|
* Gets the authorization server issuer using the same derivation as RFC 8414 metadata.
|
|
1719
2336
|
*/
|
|
1720
2337
|
getAuthorizationServerIssuer(requestUrl) {
|
|
2338
|
+
if (this.explicitIssuer) return this.explicitIssuer;
|
|
1721
2339
|
const tokenEndpoint = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
|
|
1722
2340
|
return new URL(tokenEndpoint).origin;
|
|
1723
2341
|
}
|
|
@@ -1734,6 +2352,7 @@ var OAuthProviderImpl = class {
|
|
|
1734
2352
|
newResponse.headers.set("Access-Control-Allow-Origin", origin);
|
|
1735
2353
|
newResponse.headers.set("Access-Control-Allow-Methods", "*");
|
|
1736
2354
|
newResponse.headers.set("Access-Control-Allow-Headers", "Authorization, *");
|
|
2355
|
+
appendHeaderValue(newResponse.headers, "Vary", "Origin");
|
|
1737
2356
|
const exposedHeaders = (newResponse.headers.get("Access-Control-Expose-Headers") ?? "").split(",").map((name) => name.trim()).filter(Boolean);
|
|
1738
2357
|
for (const requiredHeader of ["WWW-Authenticate", "Retry-After"]) if (!exposedHeaders.some((name) => name.toLowerCase() === requiredHeader.toLowerCase())) exposedHeaders.push(requiredHeader);
|
|
1739
2358
|
newResponse.headers.set("Access-Control-Expose-Headers", exposedHeaders.join(", "));
|
|
@@ -1755,9 +2374,10 @@ var OAuthProviderImpl = class {
|
|
|
1755
2374
|
const grantTypesSupported = this.serverCapabilities.grantTypes;
|
|
1756
2375
|
const authorizationGrantProfilesSupported = this.options.enterpriseManagedAuthorization ? [EMA_ID_JAG_GRANT_PROFILE] : [];
|
|
1757
2376
|
const metadata = {
|
|
1758
|
-
issuer:
|
|
2377
|
+
issuer: this.getAuthorizationServerIssuer(requestUrl),
|
|
1759
2378
|
authorization_endpoint: authorizeEndpoint,
|
|
1760
2379
|
token_endpoint: tokenEndpoint,
|
|
2380
|
+
...this.resourceServers.length > 0 ? { protected_resources: this.resourceServers.map((server) => server.resourceMetadata.resource) } : {},
|
|
1761
2381
|
registration_endpoint: registrationEndpoint,
|
|
1762
2382
|
scopes_supported: this.options.scopesSupported,
|
|
1763
2383
|
response_types_supported: responseTypesSupported,
|
|
@@ -1773,8 +2393,8 @@ var OAuthProviderImpl = class {
|
|
|
1773
2393
|
return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
|
|
1774
2394
|
}
|
|
1775
2395
|
/** Scopes that are baseline requirements of the protected resource itself. */
|
|
1776
|
-
getProtectedResourceScopes() {
|
|
1777
|
-
return this.normalizeProtectedResourceScopes(
|
|
2396
|
+
getProtectedResourceScopes(resourceServer) {
|
|
2397
|
+
return this.normalizeProtectedResourceScopes(resourceServer.resourceMetadata.scopes_supported ?? []);
|
|
1778
2398
|
}
|
|
1779
2399
|
/** Deduplicate resource-facing scopes and remove authorization-server-only capabilities. */
|
|
1780
2400
|
normalizeProtectedResourceScopes(scopes) {
|
|
@@ -1786,18 +2406,17 @@ var OAuthProviderImpl = class {
|
|
|
1786
2406
|
* @param requestUrl - The URL of the incoming request
|
|
1787
2407
|
* @returns Response with protected resource metadata
|
|
1788
2408
|
*/
|
|
1789
|
-
handleProtectedResourceMetadata(requestUrl) {
|
|
1790
|
-
const rm =
|
|
1791
|
-
const
|
|
1792
|
-
const
|
|
1793
|
-
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);
|
|
1794
2413
|
const metadata = {
|
|
1795
|
-
resource: rm
|
|
1796
|
-
authorization_servers: rm
|
|
2414
|
+
resource: rm.resource,
|
|
2415
|
+
authorization_servers: rm.authorization_servers ?? [authorizationServer],
|
|
1797
2416
|
...resourceScopes.length > 0 ? { scopes_supported: resourceScopes } : {},
|
|
1798
|
-
bearer_methods_supported: rm
|
|
2417
|
+
bearer_methods_supported: rm.bearer_methods_supported ?? ["header"]
|
|
1799
2418
|
};
|
|
1800
|
-
if (rm
|
|
2419
|
+
if (rm.resource_name) metadata.resource_name = rm.resource_name;
|
|
1801
2420
|
return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
|
|
1802
2421
|
}
|
|
1803
2422
|
/**
|
|
@@ -1811,11 +2430,23 @@ var OAuthProviderImpl = class {
|
|
|
1811
2430
|
async handleTokenRequest(body, clientInfo, env, requestUrl, request) {
|
|
1812
2431
|
try {
|
|
1813
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);
|
|
1814
2445
|
if (grantType === GrantType.AUTHORIZATION_CODE) return await this.handleAuthorizationCodeGrant(body, clientInfo, env);
|
|
1815
2446
|
else if (grantType === GrantType.REFRESH_TOKEN) return await this.handleRefreshTokenGrant(body, clientInfo, env);
|
|
1816
2447
|
else if (grantType === GrantType.TOKEN_EXCHANGE && this.options.allowTokenExchangeGrant) return await this.handleTokenExchangeGrant(body, clientInfo, env);
|
|
1817
2448
|
else if (grantType === GrantType.JWT_BEARER) return await this.handleJwtBearerGrant(body, clientInfo, env, requestUrl, request);
|
|
1818
|
-
|
|
2449
|
+
throw new Error("Unreachable supported grant type");
|
|
1819
2450
|
} catch (error) {
|
|
1820
2451
|
const response = this.createOAuthErrorResponse(error);
|
|
1821
2452
|
if (response) return response;
|
|
@@ -1831,7 +2462,11 @@ var OAuthProviderImpl = class {
|
|
|
1831
2462
|
*/
|
|
1832
2463
|
createOAuthErrorResponse(error) {
|
|
1833
2464
|
if (!(error instanceof OAuthError)) return void 0;
|
|
1834
|
-
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
|
+
});
|
|
1835
2470
|
}
|
|
1836
2471
|
/**
|
|
1837
2472
|
* Build a structured protected-resource response from an external-token error.
|
|
@@ -1840,7 +2475,7 @@ var OAuthProviderImpl = class {
|
|
|
1840
2475
|
* bearer failures receive an RFC 6750 / RFC 9728 challenge unless the caller
|
|
1841
2476
|
* supplied one. Other errors retain the pre-existing behavior and are re-thrown.
|
|
1842
2477
|
*/
|
|
1843
|
-
createExternalTokenErrorResponse(error, resourceMetadataUrl) {
|
|
2478
|
+
createExternalTokenErrorResponse(error, resourceMetadataUrl, resourceServer) {
|
|
1844
2479
|
if (!(error instanceof ExternalTokenError)) return void 0;
|
|
1845
2480
|
const headers = error.headers ?? {};
|
|
1846
2481
|
const hasChallenge = Object.keys(headers).some((name) => name.toLowerCase() === "www-authenticate");
|
|
@@ -1851,13 +2486,17 @@ var OAuthProviderImpl = class {
|
|
|
1851
2486
|
if (requiredScopes.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("ExternalTokenError requiredScopes must contain valid OAuth scope tokens");
|
|
1852
2487
|
challengeHeaders = {
|
|
1853
2488
|
...headers,
|
|
1854
|
-
"WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, error.code, void 0, requiredScopes)
|
|
2489
|
+
"WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, error.code, void 0, requiredScopes, resourceServer)
|
|
1855
2490
|
};
|
|
1856
2491
|
}
|
|
1857
2492
|
return this.createErrorResponse(error.code, {
|
|
1858
2493
|
description: error.description,
|
|
1859
2494
|
statusCode: error.statusCode,
|
|
1860
2495
|
headers: challengeHeaders ?? headers
|
|
2496
|
+
}, {
|
|
2497
|
+
category: "protected-resource",
|
|
2498
|
+
reason: "resolver_rejected",
|
|
2499
|
+
detail: error
|
|
1861
2500
|
});
|
|
1862
2501
|
}
|
|
1863
2502
|
/**
|
|
@@ -1872,34 +2511,72 @@ var OAuthProviderImpl = class {
|
|
|
1872
2511
|
const code = body.code;
|
|
1873
2512
|
const redirectUri = body.redirect_uri;
|
|
1874
2513
|
const codeVerifier = body.code_verifier;
|
|
1875
|
-
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
|
+
});
|
|
1876
2518
|
const codeParts = code.split(":");
|
|
1877
|
-
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
|
+
});
|
|
1878
2523
|
const [userId, grantId, _] = codeParts;
|
|
1879
2524
|
const grantKey = `grant:${userId}:${grantId}`;
|
|
1880
2525
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
1881
|
-
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
|
+
});
|
|
1882
2530
|
const codeHash = await hashSecret(code);
|
|
1883
|
-
if (!grantData.authCodeId || codeHash !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code" }
|
|
1884
|
-
|
|
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
|
+
});
|
|
1885
2539
|
if (!grantData.authCodeWrappedKey) {
|
|
1886
2540
|
try {
|
|
1887
2541
|
await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
|
|
1888
2542
|
} catch {}
|
|
1889
|
-
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
|
+
});
|
|
1890
2547
|
}
|
|
1891
2548
|
let codeChallengeMethod;
|
|
1892
2549
|
try {
|
|
1893
2550
|
codeChallengeMethod = grantData.codeChallenge ? validatePkceCodeChallengeMethod(this.serverCapabilities, grantData.codeChallengeMethod) : normalizePkceCodeChallengeMethod(grantData.codeChallengeMethod);
|
|
1894
2551
|
} catch (error) {
|
|
1895
|
-
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
|
+
});
|
|
1896
2557
|
}
|
|
1897
2558
|
const isPkceEnabled = !!grantData.codeChallenge;
|
|
1898
|
-
if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" }
|
|
1899
|
-
|
|
1900
|
-
|
|
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
|
+
});
|
|
1901
2575
|
if (isPkceEnabled) {
|
|
1902
|
-
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
|
+
});
|
|
1903
2580
|
let calculatedChallenge;
|
|
1904
2581
|
if (codeChallengeMethod === "S256") {
|
|
1905
2582
|
const data = new TextEncoder().encode(codeVerifier);
|
|
@@ -1907,9 +2584,13 @@ var OAuthProviderImpl = class {
|
|
|
1907
2584
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
1908
2585
|
calculatedChallenge = base64UrlEncode(String.fromCharCode(...hashArray));
|
|
1909
2586
|
} else calculatedChallenge = codeVerifier;
|
|
1910
|
-
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
|
+
});
|
|
1911
2591
|
}
|
|
1912
|
-
const
|
|
2592
|
+
const resourceResolution = this.resolveTokenResource(body.resource, grantData);
|
|
2593
|
+
const audience = resourceResolution.audience;
|
|
1913
2594
|
let accessTokenTTL = this.options.accessTokenTTL;
|
|
1914
2595
|
let refreshTokenTTL = this.options.refreshTokenTTL;
|
|
1915
2596
|
const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
|
|
@@ -1924,10 +2605,12 @@ var OAuthProviderImpl = class {
|
|
|
1924
2605
|
const callbackOptions = {
|
|
1925
2606
|
grantType: GrantType.AUTHORIZATION_CODE,
|
|
1926
2607
|
clientId: clientInfo.clientId,
|
|
2608
|
+
subjectClientId: grantData.clientId,
|
|
1927
2609
|
userId,
|
|
1928
2610
|
grantId,
|
|
1929
2611
|
scope: grantData.scope,
|
|
1930
2612
|
requestedScope: tokenScopes,
|
|
2613
|
+
resource: audience,
|
|
1931
2614
|
props: decryptedProps
|
|
1932
2615
|
};
|
|
1933
2616
|
const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
|
|
@@ -1939,6 +2622,10 @@ var OAuthProviderImpl = class {
|
|
|
1939
2622
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
1940
2623
|
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
|
|
1941
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
|
+
});
|
|
1942
2629
|
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
1943
2630
|
}
|
|
1944
2631
|
const grantResult = await encryptProps(grantProps);
|
|
@@ -1953,6 +2640,10 @@ var OAuthProviderImpl = class {
|
|
|
1953
2640
|
accessTokenEncryptionKey = grantEncryptionKey;
|
|
1954
2641
|
}
|
|
1955
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
|
+
});
|
|
1956
2647
|
const now = Math.floor(Date.now() / 1e3);
|
|
1957
2648
|
const useRefreshToken = refreshTokenTTL !== 0;
|
|
1958
2649
|
delete grantData.codeChallenge;
|
|
@@ -1970,6 +2661,7 @@ var OAuthProviderImpl = class {
|
|
|
1970
2661
|
grantData.previousRefreshTokenWrappedKey = void 0;
|
|
1971
2662
|
grantData.expiresAt = expiresAt;
|
|
1972
2663
|
}
|
|
2664
|
+
if (resourceResolution.grantResourceBackfill) grantData.resource = resourceResolution.grantResourceBackfill;
|
|
1973
2665
|
await this.saveGrantWithTTL(env, grantKey, grantData, now);
|
|
1974
2666
|
const tokenResponse = {
|
|
1975
2667
|
access_token: await this.createAccessToken({
|
|
@@ -1985,10 +2677,10 @@ var OAuthProviderImpl = class {
|
|
|
1985
2677
|
}),
|
|
1986
2678
|
token_type: "bearer",
|
|
1987
2679
|
expires_in: accessTokenTTL,
|
|
1988
|
-
scope: tokenScopes.join(" ")
|
|
2680
|
+
scope: tokenScopes.join(" "),
|
|
2681
|
+
resource: audience
|
|
1989
2682
|
};
|
|
1990
2683
|
if (refreshToken) tokenResponse.refresh_token = refreshToken;
|
|
1991
|
-
if (audience) tokenResponse.resource = audience;
|
|
1992
2684
|
return new Response(JSON.stringify(tokenResponse), { headers: {
|
|
1993
2685
|
"Content-Type": "application/json",
|
|
1994
2686
|
...NO_CACHE_HEADERS
|
|
@@ -2004,26 +2696,46 @@ var OAuthProviderImpl = class {
|
|
|
2004
2696
|
*/
|
|
2005
2697
|
async handleRefreshTokenGrant(body, clientInfo, env) {
|
|
2006
2698
|
const refreshToken = body.refresh_token;
|
|
2007
|
-
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
|
+
});
|
|
2008
2703
|
const tokenParts = refreshToken.split(":");
|
|
2009
|
-
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
|
+
});
|
|
2010
2708
|
const [userId, grantId, _] = tokenParts;
|
|
2011
2709
|
const providedTokenHash = await generateTokenId(refreshToken);
|
|
2012
2710
|
const grantKey = `grant:${userId}:${grantId}`;
|
|
2013
2711
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
2014
|
-
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
|
+
});
|
|
2015
2716
|
const isCurrentToken = grantData.refreshTokenId === providedTokenHash;
|
|
2016
2717
|
const isPreviousToken = grantData.previousRefreshTokenId === providedTokenHash;
|
|
2017
|
-
if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", { description: "Invalid refresh token" }
|
|
2018
|
-
|
|
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
|
+
});
|
|
2019
2726
|
if (grantData.expiresAt !== void 0) {
|
|
2020
2727
|
const now$1 = Math.floor(Date.now() / 1e3);
|
|
2021
|
-
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
|
+
});
|
|
2022
2732
|
}
|
|
2023
|
-
const
|
|
2733
|
+
const resourceResolution = this.resolveTokenResource(body.resource, grantData);
|
|
2734
|
+
const audience = resourceResolution.audience;
|
|
2024
2735
|
const newAccessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
2025
2736
|
const accessTokenId = await generateTokenId(newAccessToken);
|
|
2026
2737
|
let accessTokenTTL = this.options.accessTokenTTL;
|
|
2738
|
+
let refreshTokenIdleTTL = this.options.refreshTokenIdleTTL;
|
|
2027
2739
|
let wrappedKeyToUse;
|
|
2028
2740
|
if (isCurrentToken) wrappedKeyToUse = grantData.refreshTokenWrappedKey;
|
|
2029
2741
|
else wrappedKeyToUse = grantData.previousRefreshTokenWrappedKey;
|
|
@@ -2040,10 +2752,12 @@ var OAuthProviderImpl = class {
|
|
|
2040
2752
|
const callbackOptions = {
|
|
2041
2753
|
grantType: GrantType.REFRESH_TOKEN,
|
|
2042
2754
|
clientId: clientInfo.clientId,
|
|
2755
|
+
subjectClientId: grantData.clientId,
|
|
2043
2756
|
userId,
|
|
2044
2757
|
grantId,
|
|
2045
2758
|
scope: grantData.scope,
|
|
2046
2759
|
requestedScope: tokenScopes,
|
|
2760
|
+
resource: audience,
|
|
2047
2761
|
props: decryptedProps
|
|
2048
2762
|
};
|
|
2049
2763
|
const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
|
|
@@ -2055,7 +2769,17 @@ var OAuthProviderImpl = class {
|
|
|
2055
2769
|
}
|
|
2056
2770
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
2057
2771
|
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
|
|
2058
|
-
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
|
+
}
|
|
2059
2783
|
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
2060
2784
|
}
|
|
2061
2785
|
if (grantPropsChanged) {
|
|
@@ -2076,12 +2800,19 @@ var OAuthProviderImpl = class {
|
|
|
2076
2800
|
}
|
|
2077
2801
|
}
|
|
2078
2802
|
const now = Math.floor(Date.now() / 1e3);
|
|
2079
|
-
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;
|
|
2080
2808
|
if (grantData.expiresAt !== void 0) {
|
|
2081
2809
|
const remainingRefreshTokenLifetime = grantData.expiresAt - now;
|
|
2082
2810
|
if (remainingRefreshTokenLifetime > 0) accessTokenTTL = Math.min(accessTokenTTL, remainingRefreshTokenLifetime);
|
|
2083
2811
|
}
|
|
2084
|
-
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
|
+
});
|
|
2085
2816
|
const accessTokenExpiresAt = now + accessTokenTTL;
|
|
2086
2817
|
const accessTokenWrappedKey = await wrapKeyWithToken(newAccessToken, accessTokenEncryptionKey);
|
|
2087
2818
|
const newRefreshToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
@@ -2091,6 +2822,7 @@ var OAuthProviderImpl = class {
|
|
|
2091
2822
|
grantData.previousRefreshTokenWrappedKey = wrappedKeyToUse;
|
|
2092
2823
|
grantData.refreshTokenId = newRefreshTokenId;
|
|
2093
2824
|
grantData.refreshTokenWrappedKey = newRefreshTokenWrappedKey;
|
|
2825
|
+
if (resourceResolution.grantResourceBackfill) grantData.resource = resourceResolution.grantResourceBackfill;
|
|
2094
2826
|
await this.saveGrantWithTTL(env, grantKey, grantData, now);
|
|
2095
2827
|
const accessTokenData = {
|
|
2096
2828
|
id: accessTokenId,
|
|
@@ -2118,9 +2850,9 @@ var OAuthProviderImpl = class {
|
|
|
2118
2850
|
token_type: "bearer",
|
|
2119
2851
|
expires_in: accessTokenTTL,
|
|
2120
2852
|
refresh_token: newRefreshToken,
|
|
2121
|
-
scope: tokenScopes.join(" ")
|
|
2853
|
+
scope: tokenScopes.join(" "),
|
|
2854
|
+
resource: audience
|
|
2122
2855
|
};
|
|
2123
|
-
if (audience) tokenResponse.resource = audience;
|
|
2124
2856
|
return new Response(JSON.stringify(tokenResponse), { headers: {
|
|
2125
2857
|
"Content-Type": "application/json",
|
|
2126
2858
|
...NO_CACHE_HEADERS
|
|
@@ -2134,7 +2866,7 @@ var OAuthProviderImpl = class {
|
|
|
2134
2866
|
* module-private.
|
|
2135
2867
|
* @param subjectToken - The subject token to exchange
|
|
2136
2868
|
* @param requestedScopes - Optional requested scopes, limited to the subject token's scopes
|
|
2137
|
-
* @param requestedResource - Optional resource/audience
|
|
2869
|
+
* @param requestedResource - Optional resource/audience; when present, it must match the configured canonical resource
|
|
2138
2870
|
* @param expiresIn - Optional TTL override in seconds
|
|
2139
2871
|
* @param clientInfo - The client making the exchange request
|
|
2140
2872
|
* @param env - Cloudflare Worker environment variables
|
|
@@ -2143,24 +2875,61 @@ var OAuthProviderImpl = class {
|
|
|
2143
2875
|
*/
|
|
2144
2876
|
async exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env) {
|
|
2145
2877
|
const tokenSummary = await this.unwrapToken(subjectToken, env);
|
|
2146
|
-
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
|
+
});
|
|
2147
2885
|
const grantKey = `grant:${tokenSummary.userId}:${tokenSummary.grantId}`;
|
|
2148
2886
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
2149
|
-
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();
|
|
2150
2903
|
let tokenScopes = this.downscope(requestedScopes, tokenSummary.scope);
|
|
2151
|
-
const
|
|
2152
|
-
if (configuredResource && !isExactResource(tokenSummary.audience, configuredResource)) throw new OAuthError("invalid_target", { description: "Subject token is not bound to the configured resource" });
|
|
2153
|
-
const newAudience = requestedResource === void 0 ? tokenSummary.audience : this.resolveTokenResource(requestedResource, grantData.resource);
|
|
2904
|
+
const newAudience = this.resolveTokenExchangeResource(requestedResource, tokenSummary.audience);
|
|
2154
2905
|
const now = Math.floor(Date.now() / 1e3);
|
|
2155
2906
|
const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
|
|
2156
|
-
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
|
+
});
|
|
2157
2914
|
let accessTokenTTL = this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL;
|
|
2158
2915
|
if (expiresIn !== void 0) {
|
|
2159
|
-
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
|
+
});
|
|
2160
2923
|
accessTokenTTL = Math.min(expiresIn, subjectTokenRemainingLifetime);
|
|
2161
2924
|
} else accessTokenTTL = Math.min(accessTokenTTL, subjectTokenRemainingLifetime);
|
|
2162
2925
|
const subjectTokenData = await env.OAUTH_KV.get(`token:${tokenSummary.userId}:${tokenSummary.grantId}:${tokenSummary.id}`, { type: "json" });
|
|
2163
|
-
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
|
+
});
|
|
2164
2933
|
const encryptionKey = await unwrapKeyWithToken(subjectToken, subjectTokenData.wrappedEncryptionKey);
|
|
2165
2934
|
let accessTokenEncryptionKey = encryptionKey;
|
|
2166
2935
|
let encryptedAccessTokenProps = subjectTokenData.grant.encryptedProps;
|
|
@@ -2169,13 +2938,16 @@ var OAuthProviderImpl = class {
|
|
|
2169
2938
|
const callbackOptions = {
|
|
2170
2939
|
grantType: GrantType.TOKEN_EXCHANGE,
|
|
2171
2940
|
clientId: clientInfo.clientId,
|
|
2941
|
+
subjectClientId: grantData.clientId,
|
|
2172
2942
|
userId: tokenSummary.userId,
|
|
2173
2943
|
grantId: tokenSummary.grantId,
|
|
2174
2944
|
scope: tokenSummary.grant.scope,
|
|
2175
2945
|
requestedScope: tokenScopes,
|
|
2946
|
+
resource: newAudience,
|
|
2176
2947
|
props: decryptedProps
|
|
2177
2948
|
};
|
|
2178
2949
|
const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
|
|
2950
|
+
if (crossClientExchange && callbackResult?.allowCrossClientExchange !== true) throw crossClientRejection();
|
|
2179
2951
|
if (callbackResult) {
|
|
2180
2952
|
let accessTokenProps = decryptedProps;
|
|
2181
2953
|
if (callbackResult.newProps) {
|
|
@@ -2183,6 +2955,13 @@ var OAuthProviderImpl = class {
|
|
|
2183
2955
|
}
|
|
2184
2956
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
2185
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
|
+
});
|
|
2186
2965
|
if (accessTokenProps !== decryptedProps) {
|
|
2187
2966
|
const tokenResult = await encryptProps(accessTokenProps);
|
|
2188
2967
|
encryptedAccessTokenProps = tokenResult.encryptedData;
|
|
@@ -2191,8 +2970,14 @@ var OAuthProviderImpl = class {
|
|
|
2191
2970
|
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, tokenSummary.scope);
|
|
2192
2971
|
}
|
|
2193
2972
|
}
|
|
2194
|
-
if (accessTokenTTL
|
|
2195
|
-
|
|
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 {
|
|
2196
2981
|
access_token: await this.createAccessToken({
|
|
2197
2982
|
userId: tokenSummary.userId,
|
|
2198
2983
|
grantId: tokenSummary.grantId,
|
|
@@ -2207,10 +2992,9 @@ var OAuthProviderImpl = class {
|
|
|
2207
2992
|
issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
|
|
2208
2993
|
token_type: "bearer",
|
|
2209
2994
|
expires_in: accessTokenTTL,
|
|
2210
|
-
scope: tokenScopes.join(" ")
|
|
2995
|
+
scope: tokenScopes.join(" "),
|
|
2996
|
+
resource: newAudience
|
|
2211
2997
|
};
|
|
2212
|
-
if (newAudience) tokenResponse.resource = newAudience;
|
|
2213
|
-
return tokenResponse;
|
|
2214
2998
|
}
|
|
2215
2999
|
/**
|
|
2216
3000
|
* Handles OAuth 2.0 token exchange requests (RFC 8693)
|
|
@@ -2226,18 +3010,36 @@ var OAuthProviderImpl = class {
|
|
|
2226
3010
|
const requestedTokenType = body.requested_token_type || "urn:ietf:params:oauth:token-type:access_token";
|
|
2227
3011
|
const requestedScope = body.scope;
|
|
2228
3012
|
const requestedResource = body.resource;
|
|
2229
|
-
if (!subjectToken) return this.createErrorResponse("invalid_request", { description: "subject_token is required" }
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
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
|
+
});
|
|
2233
3029
|
let requestedScopes;
|
|
2234
3030
|
if (requestedScope) if (typeof requestedScope === "string") requestedScopes = requestedScope.split(" ").filter(Boolean);
|
|
2235
3031
|
else if (Array.isArray(requestedScope)) requestedScopes = requestedScope;
|
|
2236
|
-
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
|
+
});
|
|
2237
3036
|
let expiresIn;
|
|
2238
3037
|
if (body.expires_in !== void 0) {
|
|
2239
3038
|
const requestedTTL = parseInt(body.expires_in, 10);
|
|
2240
|
-
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
|
+
});
|
|
2241
3043
|
expiresIn = requestedTTL;
|
|
2242
3044
|
}
|
|
2243
3045
|
try {
|
|
@@ -2262,10 +3064,16 @@ var OAuthProviderImpl = class {
|
|
|
2262
3064
|
*/
|
|
2263
3065
|
async handleJwtBearerGrant(body, clientInfo, env, requestUrl, request) {
|
|
2264
3066
|
const enterpriseOptions = this.options.enterpriseManagedAuthorization;
|
|
2265
|
-
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
|
+
});
|
|
2266
3071
|
if (clientInfo.tokenEndpointAuthMethod === "none" && !enterpriseOptions.allowPublicClients) return this.createErrorResponse("invalid_client", {
|
|
2267
3072
|
description: "Enterprise-managed authorization requires client authentication",
|
|
2268
3073
|
statusCode: 401
|
|
3074
|
+
}, {
|
|
3075
|
+
category: "enterprise-managed-authorization",
|
|
3076
|
+
reason: "client_authentication_required"
|
|
2269
3077
|
});
|
|
2270
3078
|
const result = await this.runEmaPipeline({
|
|
2271
3079
|
body,
|
|
@@ -2300,8 +3108,8 @@ var OAuthProviderImpl = class {
|
|
|
2300
3108
|
async runEmaPipeline(args) {
|
|
2301
3109
|
const { body, clientInfo, env, requestUrl, request, enterpriseOptions } = args;
|
|
2302
3110
|
const { jwksProvider, jtiStore } = this;
|
|
2303
|
-
const configuredResource = this.
|
|
2304
|
-
if (!jwksProvider || !jtiStore
|
|
3111
|
+
const configuredResource = this.resolveNewTokenResource(body.resource);
|
|
3112
|
+
if (!jwksProvider || !jtiStore) throw new Error("EMA pipeline invoked without configured adapters");
|
|
2305
3113
|
const now = Math.floor(Date.now() / 1e3);
|
|
2306
3114
|
const parsed = parseIdJag(body.assertion, EMA_MAX_JWT_BYTES);
|
|
2307
3115
|
if (!parsed.ok) return parsed;
|
|
@@ -2331,7 +3139,6 @@ var OAuthProviderImpl = class {
|
|
|
2331
3139
|
expectedAudience: trustedIssuer.value.audience ?? this.getAuthorizationServerIssuer(requestUrl),
|
|
2332
3140
|
clientId: clientInfo.clientId,
|
|
2333
3141
|
configuredResource,
|
|
2334
|
-
matchOriginOnly: !!this.options.resourceMatchOriginOnly,
|
|
2335
3142
|
now,
|
|
2336
3143
|
clockSkewSeconds: enterpriseOptions.clockSkewSeconds ?? EMA_DEFAULT_CLOCK_SKEW_SECONDS,
|
|
2337
3144
|
maxAssertionLifetimeSeconds: enterpriseOptions.maxAssertionLifetimeSeconds ?? EMA_DEFAULT_MAX_ASSERTION_LIFETIME_SECONDS
|
|
@@ -2353,7 +3160,7 @@ var OAuthProviderImpl = class {
|
|
|
2353
3160
|
mapperOutput = await enterpriseOptions.mapClaims({
|
|
2354
3161
|
claims: claims.value.claims,
|
|
2355
3162
|
clientInfo,
|
|
2356
|
-
resource:
|
|
3163
|
+
resource: configuredResource,
|
|
2357
3164
|
requestedScope: requestedScope.value,
|
|
2358
3165
|
request: args.request,
|
|
2359
3166
|
env
|
|
@@ -2379,7 +3186,7 @@ var OAuthProviderImpl = class {
|
|
|
2379
3186
|
mapperProps: mapped.value.props,
|
|
2380
3187
|
mapperMetadata: mapped.value.metadata,
|
|
2381
3188
|
assertionScopes: claims.value.assertionScopes,
|
|
2382
|
-
resource:
|
|
3189
|
+
resource: configuredResource,
|
|
2383
3190
|
accessTokenTTLSeconds: ttl.value,
|
|
2384
3191
|
env,
|
|
2385
3192
|
now: issueNow
|
|
@@ -2478,7 +3285,10 @@ var OAuthProviderImpl = class {
|
|
|
2478
3285
|
async revokeToken(body, clientInfo, env) {
|
|
2479
3286
|
const token = body.token;
|
|
2480
3287
|
const tokenTypeHint = body.token_type_hint;
|
|
2481
|
-
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
|
+
});
|
|
2482
3292
|
const tokenParts = token.split(":");
|
|
2483
3293
|
if (tokenParts.length !== 3) return new Response("", { status: 200 });
|
|
2484
3294
|
const [userId, grantId, _] = tokenParts;
|
|
@@ -2533,14 +3343,24 @@ var OAuthProviderImpl = class {
|
|
|
2533
3343
|
if (!this.options.clientRegistrationEndpoint) return this.createErrorResponse("not_implemented", {
|
|
2534
3344
|
description: "Client registration is not enabled",
|
|
2535
3345
|
statusCode: 501
|
|
3346
|
+
}, {
|
|
3347
|
+
category: "client-registration",
|
|
3348
|
+
reason: "registration_disabled"
|
|
2536
3349
|
});
|
|
2537
3350
|
if (request.method !== "POST") return this.createErrorResponse("invalid_request", {
|
|
2538
3351
|
description: "Method not allowed",
|
|
2539
|
-
statusCode: 405
|
|
3352
|
+
statusCode: 405,
|
|
3353
|
+
headers: { Allow: "POST, OPTIONS" }
|
|
3354
|
+
}, {
|
|
3355
|
+
category: "client-registration",
|
|
3356
|
+
reason: "method_not_allowed"
|
|
2540
3357
|
});
|
|
2541
3358
|
if (parseInt(request.headers.get("Content-Length") || "0", 10) > 1048576) return this.createErrorResponse("invalid_request", {
|
|
2542
3359
|
description: "Request payload too large, must be under 1 MiB",
|
|
2543
3360
|
statusCode: 413
|
|
3361
|
+
}, {
|
|
3362
|
+
category: "client-registration",
|
|
3363
|
+
reason: "declared_payload_too_large"
|
|
2544
3364
|
});
|
|
2545
3365
|
const callbackRequest = request.clone();
|
|
2546
3366
|
let parsedJson;
|
|
@@ -2549,12 +3369,18 @@ var OAuthProviderImpl = class {
|
|
|
2549
3369
|
if (text.length > 1048576) return this.createErrorResponse("invalid_request", {
|
|
2550
3370
|
description: "Request payload too large, must be under 1 MiB",
|
|
2551
3371
|
statusCode: 413
|
|
3372
|
+
}, {
|
|
3373
|
+
category: "client-registration",
|
|
3374
|
+
reason: "payload_too_large"
|
|
2552
3375
|
});
|
|
2553
3376
|
parsedJson = JSON.parse(text);
|
|
2554
3377
|
} catch {
|
|
2555
3378
|
return this.createErrorResponse("invalid_request", {
|
|
2556
3379
|
description: "Invalid JSON payload",
|
|
2557
3380
|
statusCode: 400
|
|
3381
|
+
}, {
|
|
3382
|
+
category: "client-registration",
|
|
3383
|
+
reason: "json_malformed"
|
|
2558
3384
|
});
|
|
2559
3385
|
}
|
|
2560
3386
|
let clientMetadata;
|
|
@@ -2563,11 +3389,18 @@ var OAuthProviderImpl = class {
|
|
|
2563
3389
|
clientMetadata = requireJsonObject(parsedJson);
|
|
2564
3390
|
metadata = resolveDynamicClientRegistrationMetadata(clientMetadata, this.serverCapabilities);
|
|
2565
3391
|
} catch (error) {
|
|
2566
|
-
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
|
+
});
|
|
2567
3397
|
}
|
|
2568
3398
|
const authMethod = metadata.tokenEndpointAuthMethod;
|
|
2569
3399
|
const isPublicClient = authMethod === "none";
|
|
2570
|
-
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
|
+
});
|
|
2571
3404
|
const clientId = generateRandomString(16);
|
|
2572
3405
|
let clientSecret;
|
|
2573
3406
|
let hashedSecret;
|
|
@@ -2604,16 +3437,22 @@ var OAuthProviderImpl = class {
|
|
|
2604
3437
|
return this.createErrorResponse("server_error", {
|
|
2605
3438
|
description: error instanceof Error ? error.message : "Client registration callback failed",
|
|
2606
3439
|
statusCode: 500
|
|
3440
|
+
}, {
|
|
3441
|
+
category: "client-registration",
|
|
3442
|
+
reason: "callback_failed",
|
|
3443
|
+
detail: error
|
|
2607
3444
|
});
|
|
2608
3445
|
}
|
|
2609
3446
|
if (callbackResult !== void 0) return this.createErrorResponse(callbackResult.code || "invalid_client_metadata", {
|
|
2610
3447
|
description: callbackResult.description || "Client registration denied",
|
|
2611
3448
|
statusCode: callbackResult.status ?? 400
|
|
3449
|
+
}, {
|
|
3450
|
+
category: "client-registration",
|
|
3451
|
+
reason: "callback_denied",
|
|
3452
|
+
detail: callbackResult
|
|
2612
3453
|
});
|
|
2613
3454
|
}
|
|
2614
|
-
|
|
2615
|
-
if (this.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.options.clientRegistrationTTL;
|
|
2616
|
-
await env.OAUTH_KV.put(`client:${clientInfo.clientId}`, JSON.stringify(clientInfo), clientKvOptions);
|
|
3455
|
+
await this.putRegisteredClient(env, clientInfo, clientInfo.registrationDate);
|
|
2617
3456
|
const response = {
|
|
2618
3457
|
client_id: clientInfo.clientId,
|
|
2619
3458
|
redirect_uris: clientInfo.redirectUris,
|
|
@@ -2652,18 +3491,23 @@ var OAuthProviderImpl = class {
|
|
|
2652
3491
|
* @param ctx - Cloudflare Worker execution context
|
|
2653
3492
|
* @returns Response from the API handler or error
|
|
2654
3493
|
*/
|
|
2655
|
-
async handleApiRequest(request, env, ctx) {
|
|
3494
|
+
async handleApiRequest(request, env, ctx, apiRoute) {
|
|
2656
3495
|
const url = new URL(request.url);
|
|
2657
|
-
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);
|
|
2658
3501
|
const authHeader = request.headers.get("Authorization");
|
|
2659
|
-
|
|
3502
|
+
const bearerMatch = authHeader ? /^Bearer[\t ]+([^\s,]+)$/i.exec(authHeader) : null;
|
|
3503
|
+
if (!bearerMatch) return new Response(null, {
|
|
2660
3504
|
status: 401,
|
|
2661
3505
|
headers: {
|
|
2662
3506
|
...NO_CACHE_HEADERS,
|
|
2663
|
-
"WWW-Authenticate":
|
|
3507
|
+
"WWW-Authenticate": challenge()
|
|
2664
3508
|
}
|
|
2665
3509
|
});
|
|
2666
|
-
const accessToken =
|
|
3510
|
+
const accessToken = bearerMatch[1];
|
|
2667
3511
|
const parts = accessToken.split(":");
|
|
2668
3512
|
const isPossiblyInternalFormat = parts.length === 3;
|
|
2669
3513
|
let tokenData = null;
|
|
@@ -2674,75 +3518,104 @@ var OAuthProviderImpl = class {
|
|
|
2674
3518
|
const id = await generateTokenId(accessToken);
|
|
2675
3519
|
tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
|
|
2676
3520
|
}
|
|
2677
|
-
if (!tokenData && !
|
|
3521
|
+
if (!tokenData && !externalTokenResolver) return this.createErrorResponse("invalid_token", {
|
|
2678
3522
|
description: "Invalid access token",
|
|
2679
3523
|
statusCode: 401,
|
|
2680
|
-
headers: { "WWW-Authenticate":
|
|
3524
|
+
headers: { "WWW-Authenticate": challenge("invalid_token") }
|
|
3525
|
+
}, {
|
|
3526
|
+
category: "protected-resource",
|
|
3527
|
+
reason: "token_not_found"
|
|
2681
3528
|
});
|
|
2682
3529
|
if (tokenData) {
|
|
2683
|
-
const
|
|
2684
|
-
if (
|
|
3530
|
+
const tokenAudience = this.resolveStoredTokenAudience(tokenData.audience);
|
|
3531
|
+
if (!isExactResource(tokenAudience, configuredResource)) return this.createErrorResponse("invalid_token", {
|
|
2685
3532
|
description: "Access token is not bound to the configured resource",
|
|
2686
3533
|
statusCode: 401,
|
|
2687
|
-
headers: { "WWW-Authenticate":
|
|
3534
|
+
headers: { "WWW-Authenticate": challenge("invalid_token") }
|
|
3535
|
+
}, {
|
|
3536
|
+
category: "protected-resource",
|
|
3537
|
+
reason: "token_audience_unbound"
|
|
2688
3538
|
});
|
|
2689
3539
|
const now = Math.floor(Date.now() / 1e3);
|
|
2690
3540
|
if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", {
|
|
2691
3541
|
description: "Access token expired",
|
|
2692
3542
|
statusCode: 401,
|
|
2693
|
-
headers: { "WWW-Authenticate":
|
|
3543
|
+
headers: { "WWW-Authenticate": challenge("invalid_token") }
|
|
3544
|
+
}, {
|
|
3545
|
+
category: "protected-resource",
|
|
3546
|
+
reason: "token_expired"
|
|
2694
3547
|
});
|
|
2695
|
-
if (
|
|
3548
|
+
if (tokenAudience) {
|
|
2696
3549
|
const requestUrl = new URL(request.url);
|
|
2697
|
-
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
|
|
2698
|
-
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", {
|
|
2699
3552
|
description: "Token audience does not match resource server",
|
|
2700
3553
|
statusCode: 401,
|
|
2701
|
-
headers: { "WWW-Authenticate":
|
|
3554
|
+
headers: { "WWW-Authenticate": challenge("invalid_token", "Invalid audience") }
|
|
3555
|
+
}, {
|
|
3556
|
+
category: "protected-resource",
|
|
3557
|
+
reason: "audience_mismatch"
|
|
2702
3558
|
});
|
|
2703
3559
|
}
|
|
2704
3560
|
ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
|
|
2705
|
-
|
|
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) {
|
|
2706
3570
|
let ext;
|
|
2707
3571
|
try {
|
|
2708
|
-
ext = await
|
|
3572
|
+
ext = await externalTokenResolver({
|
|
2709
3573
|
token: accessToken,
|
|
2710
3574
|
request,
|
|
2711
3575
|
env
|
|
2712
3576
|
});
|
|
2713
3577
|
} catch (error) {
|
|
2714
|
-
const response = this.createExternalTokenErrorResponse(error, resourceMetadataUrl);
|
|
3578
|
+
const response = this.createExternalTokenErrorResponse(error, resourceMetadataUrl, resourceServer);
|
|
2715
3579
|
if (response) return response;
|
|
2716
3580
|
throw error;
|
|
2717
3581
|
}
|
|
2718
3582
|
if (!ext) return this.createErrorResponse("invalid_token", {
|
|
2719
3583
|
description: "Invalid access token",
|
|
2720
3584
|
statusCode: 401,
|
|
2721
|
-
headers: { "WWW-Authenticate":
|
|
3585
|
+
headers: { "WWW-Authenticate": challenge("invalid_token") }
|
|
3586
|
+
}, {
|
|
3587
|
+
category: "protected-resource",
|
|
3588
|
+
reason: "external_token_rejected"
|
|
2722
3589
|
});
|
|
2723
|
-
|
|
2724
|
-
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", {
|
|
2725
3591
|
description: "External access token is not bound to the configured resource",
|
|
2726
3592
|
statusCode: 401,
|
|
2727
|
-
headers: { "WWW-Authenticate":
|
|
3593
|
+
headers: { "WWW-Authenticate": challenge("invalid_token") }
|
|
3594
|
+
}, {
|
|
3595
|
+
category: "protected-resource",
|
|
3596
|
+
reason: "external_audience_unbound"
|
|
2728
3597
|
});
|
|
2729
3598
|
if (ext.audience) {
|
|
2730
3599
|
const requestUrl = new URL(request.url);
|
|
2731
|
-
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
|
|
2732
|
-
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", {
|
|
2733
3602
|
description: "Token audience does not match resource server",
|
|
2734
3603
|
statusCode: 401,
|
|
2735
|
-
headers: { "WWW-Authenticate":
|
|
3604
|
+
headers: { "WWW-Authenticate": challenge("invalid_token", "Invalid audience") }
|
|
3605
|
+
}, {
|
|
3606
|
+
category: "protected-resource",
|
|
3607
|
+
reason: "external_audience_mismatch"
|
|
2736
3608
|
});
|
|
2737
3609
|
}
|
|
2738
3610
|
ctx.props = ext.props;
|
|
3611
|
+
ctx.auth = {
|
|
3612
|
+
token: accessToken,
|
|
3613
|
+
audience: configuredResource,
|
|
3614
|
+
scope: []
|
|
3615
|
+
};
|
|
2739
3616
|
}
|
|
2740
3617
|
if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
|
|
2741
|
-
const apiHandler =
|
|
2742
|
-
if (!apiHandler) return this.createErrorResponse("invalid_request", {
|
|
2743
|
-
description: "No handler found for API route",
|
|
2744
|
-
statusCode: 404
|
|
2745
|
-
});
|
|
3618
|
+
const apiHandler = apiRoute.handler;
|
|
2746
3619
|
if (apiHandler.type === HandlerType.EXPORTED_HANDLER) return apiHandler.handler.fetch(request, env, ctx);
|
|
2747
3620
|
else return new apiHandler.handler(ctx, env).fetch(request);
|
|
2748
3621
|
}
|
|
@@ -2766,7 +3639,7 @@ var OAuthProviderImpl = class {
|
|
|
2766
3639
|
const minExpiration = now + KV_MIN_EXPIRATION_TTL_SECONDS + KV_EXPIRATION_CLAMP_MARGIN_SECONDS;
|
|
2767
3640
|
const kvOptions = grantData.expiresAt !== void 0 ? { expiration: Math.max(grantData.expiresAt, minExpiration) } : {};
|
|
2768
3641
|
try {
|
|
2769
|
-
await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
|
|
3642
|
+
await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), grantPutOptions(grantData, kvOptions));
|
|
2770
3643
|
} catch (error) {
|
|
2771
3644
|
this.throwRetryableTokenStorageErrorIfKvRateLimited(error);
|
|
2772
3645
|
throw error;
|
|
@@ -2777,7 +3650,12 @@ var OAuthProviderImpl = class {
|
|
|
2777
3650
|
throw new OAuthError("temporarily_unavailable", {
|
|
2778
3651
|
description: "Token issuance is temporarily unavailable; retry shortly",
|
|
2779
3652
|
statusCode: 429,
|
|
2780
|
-
headers: { "Retry-After": "30" }
|
|
3653
|
+
headers: { "Retry-After": "30" },
|
|
3654
|
+
internal: {
|
|
3655
|
+
category: "token-issuance",
|
|
3656
|
+
reason: "kv_rate_limited",
|
|
3657
|
+
detail: error
|
|
3658
|
+
}
|
|
2781
3659
|
});
|
|
2782
3660
|
}
|
|
2783
3661
|
isKvRateLimitError(error) {
|
|
@@ -2800,6 +3678,51 @@ var OAuthProviderImpl = class {
|
|
|
2800
3678
|
* propagate, and a CIMD metadata fetch failure throws `CimdFetchError`), so an
|
|
2801
3679
|
* upstream outage is distinguishable from an unregistered client.
|
|
2802
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
|
+
}
|
|
2803
3726
|
async getClient(env, clientId) {
|
|
2804
3727
|
if (this.isClientMetadataUrl(clientId)) {
|
|
2805
3728
|
if (!this.options.clientIdMetadataDocumentEnabled) {
|
|
@@ -2817,33 +3740,140 @@ var OAuthProviderImpl = class {
|
|
|
2817
3740
|
const clientKey = `client:${clientId}`;
|
|
2818
3741
|
return env.OAUTH_KV.get(clientKey, { type: "json" });
|
|
2819
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
|
+
}
|
|
2820
3828
|
/**
|
|
2821
3829
|
* Resolves an access-token audience from a token request and its authorization grant.
|
|
2822
|
-
*
|
|
2823
|
-
* Without configuration, RFC 8707 downscoping is allowed, omission inherits a
|
|
2824
|
-
* 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.
|
|
2825
3831
|
*/
|
|
2826
|
-
resolveTokenResource(requestedResource,
|
|
3832
|
+
resolveTokenResource(requestedResource, grant) {
|
|
3833
|
+
const grantedResource = grant.resource;
|
|
2827
3834
|
const resourceWasProvided = requestedResource !== void 0;
|
|
2828
|
-
const requestedAudience = parseResourceParameter(requestedResource);
|
|
2829
|
-
if (resourceWasProvided && !requestedAudience) throw new OAuthError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
2830
3835
|
const grantResourceWasStored = grantedResource !== void 0;
|
|
2831
|
-
const
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
}
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
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
|
+
};
|
|
2845
3857
|
}
|
|
2846
|
-
|
|
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
|
+
};
|
|
2847
3877
|
}
|
|
2848
3878
|
/**
|
|
2849
3879
|
* Creates and stores an access token
|
|
@@ -2852,7 +3882,13 @@ var OAuthProviderImpl = class {
|
|
|
2852
3882
|
*/
|
|
2853
3883
|
async createAccessToken(params) {
|
|
2854
3884
|
const { userId, grantId, clientId, scope, encryptedProps, encryptionKey, expiresIn, audience, env } = params;
|
|
2855
|
-
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
|
+
});
|
|
2856
3892
|
const accessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
2857
3893
|
const now = Math.floor(Date.now() / 1e3);
|
|
2858
3894
|
const accessTokenId = await generateTokenId(accessToken);
|
|
@@ -2908,14 +3944,36 @@ var OAuthProviderImpl = class {
|
|
|
2908
3944
|
/**
|
|
2909
3945
|
* Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
|
|
2910
3946
|
*/
|
|
2911
|
-
buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription, requiredScopes = []) {
|
|
2912
|
-
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}"`;
|
|
2913
3950
|
if (error) header += `, error="${error}"`;
|
|
2914
|
-
const challengeScopes = requiredScopes.length > 0 ? this.normalizeProtectedResourceScopes(requiredScopes) : this.getProtectedResourceScopes();
|
|
3951
|
+
const challengeScopes = requiredScopes.length > 0 ? this.normalizeProtectedResourceScopes(requiredScopes) : this.getProtectedResourceScopes(resourceServer);
|
|
2915
3952
|
if (challengeScopes.length > 0) header += `, scope="${challengeScopes.join(" ")}"`;
|
|
2916
3953
|
if (errorDescription) header += `, error_description="${errorDescription}"`;
|
|
2917
3954
|
return header;
|
|
2918
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
|
+
}
|
|
2919
3977
|
/**
|
|
2920
3978
|
* Helper function to create OAuth error responses.
|
|
2921
3979
|
*
|
|
@@ -2936,21 +3994,22 @@ var OAuthProviderImpl = class {
|
|
|
2936
3994
|
description,
|
|
2937
3995
|
status: responseStatus,
|
|
2938
3996
|
headers: responseHeaders,
|
|
2939
|
-
|
|
3997
|
+
internal,
|
|
2940
3998
|
...request ? { request } : {}
|
|
2941
3999
|
});
|
|
2942
|
-
if (customErrorResponse) return customErrorResponse;
|
|
2943
4000
|
const body = JSON.stringify({
|
|
2944
4001
|
error: code,
|
|
2945
4002
|
error_description: description
|
|
2946
4003
|
});
|
|
2947
|
-
|
|
4004
|
+
const response = customErrorResponse ?? new Response(body, {
|
|
2948
4005
|
status: responseStatus,
|
|
2949
4006
|
headers: {
|
|
2950
4007
|
"Content-Type": "application/json",
|
|
2951
4008
|
...responseHeaders
|
|
2952
4009
|
}
|
|
2953
4010
|
});
|
|
4011
|
+
this.errorResponses.add(response);
|
|
4012
|
+
return response;
|
|
2954
4013
|
}
|
|
2955
4014
|
};
|
|
2956
4015
|
/**
|
|
@@ -3081,6 +4140,9 @@ const DEFAULT_CLIENT_REGISTRATION_TTL = 2160 * 60 * 60;
|
|
|
3081
4140
|
* to clamp absolute expirations when writing grants back to KV.
|
|
3082
4141
|
*/
|
|
3083
4142
|
const KV_MIN_EXPIRATION_TTL_SECONDS = 60;
|
|
4143
|
+
function isValidAccessTokenTTL(value) {
|
|
4144
|
+
return Number.isInteger(value) && value >= KV_MIN_EXPIRATION_TTL_SECONDS;
|
|
4145
|
+
}
|
|
3084
4146
|
/**
|
|
3085
4147
|
* Safety margin (seconds) added on top of `KV_MIN_EXPIRATION_TTL_SECONDS` when clamping an
|
|
3086
4148
|
* absolute KV expiration. Absolute expirations are validated against KV's clock at the
|
|
@@ -3099,8 +4161,9 @@ const DEFAULT_PURGE_BATCH_SIZE = 50;
|
|
|
3099
4161
|
*/
|
|
3100
4162
|
const MAX_KV_LIST_LIMIT = 1e3;
|
|
3101
4163
|
/**
|
|
3102
|
-
*
|
|
3103
|
-
*
|
|
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.
|
|
3104
4167
|
*/
|
|
3105
4168
|
const DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE = 50;
|
|
3106
4169
|
function getRevokeExistingGrantsBatchSize(batchSize) {
|
|
@@ -3108,27 +4171,38 @@ function getRevokeExistingGrantsBatchSize(batchSize) {
|
|
|
3108
4171
|
if (!Number.isFinite(batchSize) || !Number.isInteger(batchSize) || batchSize < 1) throw new Error("revokeExistingGrantsBatchSize must be a positive integer.");
|
|
3109
4172
|
return Math.min(batchSize, MAX_KV_LIST_LIMIT);
|
|
3110
4173
|
}
|
|
4174
|
+
/** KV serialises key metadata as JSON and rejects a write carrying more than this. */
|
|
4175
|
+
const MAX_KV_METADATA_BYTES = 1024;
|
|
3111
4176
|
/**
|
|
3112
|
-
*
|
|
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.
|
|
3113
4181
|
*/
|
|
3114
|
-
|
|
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
|
+
}
|
|
3115
4202
|
/**
|
|
3116
|
-
*
|
|
3117
|
-
* @param uri - The URI string to validate
|
|
3118
|
-
* @returns true if valid, false otherwise
|
|
4203
|
+
* Length of generated token strings
|
|
3119
4204
|
*/
|
|
3120
|
-
|
|
3121
|
-
if (!uri || typeof uri !== "string") return false;
|
|
3122
|
-
try {
|
|
3123
|
-
const parsed = new URL(uri);
|
|
3124
|
-
if (!parsed.protocol) return false;
|
|
3125
|
-
if (parsed.hash) return false;
|
|
3126
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
3127
|
-
return true;
|
|
3128
|
-
} catch {
|
|
3129
|
-
return false;
|
|
3130
|
-
}
|
|
3131
|
-
}
|
|
4205
|
+
const TOKEN_LENGTH = 32;
|
|
3132
4206
|
/**
|
|
3133
4207
|
* Checks if a resource server matches an audience claim.
|
|
3134
4208
|
* Uses origin comparison (case-insensitive hostname via URL normalization)
|
|
@@ -3142,42 +4216,37 @@ function audienceMatches(resourceServerUrl, audienceValue) {
|
|
|
3142
4216
|
const resource = new URL(resourceServerUrl);
|
|
3143
4217
|
const audience = new URL(audienceValue);
|
|
3144
4218
|
if (resource.origin !== audience.origin) return false;
|
|
3145
|
-
if (
|
|
4219
|
+
if (!requestCarriesResourceQuery(resource, audience)) return false;
|
|
3146
4220
|
if (audience.pathname === "/" || audience.pathname === "") return true;
|
|
3147
|
-
|
|
4221
|
+
const descendantPrefix = audience.pathname.endsWith("/") ? audience.pathname : audience.pathname + "/";
|
|
4222
|
+
return resource.pathname === audience.pathname || resource.pathname.startsWith(descendantPrefix);
|
|
3148
4223
|
} catch {
|
|
3149
4224
|
return false;
|
|
3150
4225
|
}
|
|
3151
4226
|
}
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
* @returns The validated value as string, string array, or undefined if validation fails
|
|
3157
|
-
*/
|
|
3158
|
-
function parseResourceParameter(value) {
|
|
3159
|
-
if (!value) return;
|
|
3160
|
-
const uris = Array.isArray(value) ? value : [value];
|
|
3161
|
-
if (uris.length === 0) return;
|
|
3162
|
-
for (const uri of uris) if (typeof uri !== "string" || !validateResourceUri(uri)) return;
|
|
3163
|
-
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(", "));
|
|
3164
4231
|
}
|
|
3165
|
-
/** Whether a request or audience names one
|
|
4232
|
+
/** Whether a request or audience names one canonical configured resource. */
|
|
3166
4233
|
function isExactResource(value, configuredResource) {
|
|
3167
|
-
|
|
4234
|
+
if (Array.isArray(value)) return value.some((entry) => typeof entry === "string" && resourceMatches(entry, configuredResource));
|
|
4235
|
+
return typeof value === "string" && resourceMatches(value, configuredResource);
|
|
3168
4236
|
}
|
|
3169
|
-
/**
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
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}/`);
|
|
3181
4250
|
}
|
|
3182
4251
|
/**
|
|
3183
4252
|
* Hashes a secret value using SHA-256
|
|
@@ -3248,11 +4317,7 @@ async function generateTokenId(token) {
|
|
|
3248
4317
|
*/
|
|
3249
4318
|
function isLoopbackUri(uri) {
|
|
3250
4319
|
try {
|
|
3251
|
-
|
|
3252
|
-
if (host.match(/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) return true;
|
|
3253
|
-
if (host === "::1" || host === "[::1]") return true;
|
|
3254
|
-
if (host.toLowerCase() === "localhost") return true;
|
|
3255
|
-
return false;
|
|
4320
|
+
return isLoopbackHostname(new URL(uri).hostname);
|
|
3256
4321
|
} catch {
|
|
3257
4322
|
return false;
|
|
3258
4323
|
}
|
|
@@ -3482,6 +4547,7 @@ var OAuthHelpersImpl = class {
|
|
|
3482
4547
|
*/
|
|
3483
4548
|
async parseAuthRequest(request) {
|
|
3484
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" });
|
|
3485
4551
|
const responseType = url.searchParams.get("response_type") || "";
|
|
3486
4552
|
const clientId = url.searchParams.get("client_id") || "";
|
|
3487
4553
|
const redirectUri = url.searchParams.get("redirect_uri") || "";
|
|
@@ -3504,13 +4570,12 @@ var OAuthHelpersImpl = class {
|
|
|
3504
4570
|
const withRedirect = (error) => {
|
|
3505
4571
|
throw withAuthorizationRedirect(error, redirectUri, state || void 0, issuer);
|
|
3506
4572
|
};
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
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;
|
|
3514
4579
|
}
|
|
3515
4580
|
try {
|
|
3516
4581
|
validateAuthorizationResponseType(this.provider.serverCapabilities, responseType, clientInfo.responseTypes);
|
|
@@ -3565,26 +4630,13 @@ var OAuthHelpersImpl = class {
|
|
|
3565
4630
|
const clientInfo = await this.lookupClient(clientId);
|
|
3566
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.");
|
|
3567
4632
|
validateAuthorizationResponseType(this.provider.serverCapabilities, options.request.responseType, clientInfo.responseTypes);
|
|
3568
|
-
const
|
|
3569
|
-
const resourceWasProvided = options.request.resource !== void 0;
|
|
3570
|
-
const parsedResource = parseResourceParameter(options.request.resource);
|
|
3571
|
-
if (resourceWasProvided && !parsedResource) throw new AuthorizationError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
3572
|
-
if (configuredResource && resourceWasProvided && !isExactResource(parsedResource, configuredResource)) throw new AuthorizationError("invalid_target", { description: `The resource parameter must exactly match ${configuredResource}` });
|
|
3573
|
-
const effectiveResource = configuredResource ?? parsedResource;
|
|
4633
|
+
const effectiveResource = this.provider.resolveAuthorizationRequestResource(options.request.resource);
|
|
3574
4634
|
validateAuthorizationPkce(this.provider.serverCapabilities, options.request, clientInfo);
|
|
3575
4635
|
let grantsToRevoke = [];
|
|
3576
4636
|
if (options.revokeExistingGrants !== false) {
|
|
3577
4637
|
const isCimdClient = this.provider.isClientMetadataUrl(clientId);
|
|
3578
|
-
const
|
|
3579
|
-
|
|
3580
|
-
do {
|
|
3581
|
-
const page = await this.listUserGrants(options.userId, {
|
|
3582
|
-
cursor,
|
|
3583
|
-
limit: batchSize
|
|
3584
|
-
});
|
|
3585
|
-
for (const grant of page.items) if (grant.clientId === clientId && (!isCimdClient || grant.redirectUri === options.request.redirectUri)) grantsToRevoke.push(grant.id);
|
|
3586
|
-
cursor = page.cursor;
|
|
3587
|
-
} 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);
|
|
3588
4640
|
}
|
|
3589
4641
|
const grantId = generateRandomString(16);
|
|
3590
4642
|
const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
|
|
@@ -3596,8 +4648,7 @@ var OAuthHelpersImpl = class {
|
|
|
3596
4648
|
const accessTokenTTL = this.provider.options.accessTokenTTL || DEFAULT_ACCESS_TOKEN_TTL;
|
|
3597
4649
|
const accessTokenExpiresAt = now + accessTokenTTL;
|
|
3598
4650
|
const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, encryptionKey);
|
|
3599
|
-
const audience =
|
|
3600
|
-
if (effectiveResource && !audience) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
|
|
4651
|
+
const audience = effectiveResource;
|
|
3601
4652
|
const grant = {
|
|
3602
4653
|
id: grantId,
|
|
3603
4654
|
clientId: options.request.clientId,
|
|
@@ -3610,7 +4661,7 @@ var OAuthHelpersImpl = class {
|
|
|
3610
4661
|
redirectUri: options.request.redirectUri
|
|
3611
4662
|
};
|
|
3612
4663
|
const grantKey = `grant:${options.userId}:${grantId}`;
|
|
3613
|
-
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant));
|
|
4664
|
+
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), grantPutOptions(grant));
|
|
3614
4665
|
const accessTokenData = {
|
|
3615
4666
|
id: accessTokenId,
|
|
3616
4667
|
grantId,
|
|
@@ -3633,6 +4684,7 @@ var OAuthHelpersImpl = class {
|
|
|
3633
4684
|
fragment.set("token_type", "bearer");
|
|
3634
4685
|
fragment.set("expires_in", accessTokenTTL.toString());
|
|
3635
4686
|
fragment.set("scope", options.scope.join(" "));
|
|
4687
|
+
fragment.set("resource", effectiveResource);
|
|
3636
4688
|
if (options.request.state) fragment.set("state", options.request.state);
|
|
3637
4689
|
if (options.request.issuer) fragment.set("iss", options.request.issuer);
|
|
3638
4690
|
redirectUrl.hash = fragment.toString();
|
|
@@ -3661,7 +4713,7 @@ var OAuthHelpersImpl = class {
|
|
|
3661
4713
|
redirectUri: options.request.redirectUri
|
|
3662
4714
|
};
|
|
3663
4715
|
const grantKey = `grant:${options.userId}:${grantId}`;
|
|
3664
|
-
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 }));
|
|
3665
4717
|
const redirectUrl = new URL(options.request.redirectUri);
|
|
3666
4718
|
redirectUrl.searchParams.set("code", authCode);
|
|
3667
4719
|
if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
|
|
@@ -3673,6 +4725,39 @@ var OAuthHelpersImpl = class {
|
|
|
3673
4725
|
}
|
|
3674
4726
|
}
|
|
3675
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
|
+
/**
|
|
3676
4761
|
* Creates a new OAuth client
|
|
3677
4762
|
* @param clientInfo - Partial client information to create the client with
|
|
3678
4763
|
* @returns A Promise resolving to the created client info
|
|
@@ -3764,9 +4849,7 @@ var OAuthHelpersImpl = class {
|
|
|
3764
4849
|
};
|
|
3765
4850
|
if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
|
|
3766
4851
|
else delete updatedClient.clientSecret;
|
|
3767
|
-
|
|
3768
|
-
if (this.provider.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.provider.options.clientRegistrationTTL;
|
|
3769
|
-
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));
|
|
3770
4853
|
const response = toPublicClientInfo(updatedClient);
|
|
3771
4854
|
if (!isPublicClient && originalSecret) response.clientSecret = originalSecret;
|
|
3772
4855
|
return response;
|
|
@@ -3816,7 +4899,8 @@ var OAuthHelpersImpl = class {
|
|
|
3816
4899
|
metadata: grantData.metadata,
|
|
3817
4900
|
createdAt: grantData.createdAt,
|
|
3818
4901
|
expiresAt: grantData.expiresAt,
|
|
3819
|
-
redirectUri: grantData.redirectUri
|
|
4902
|
+
redirectUri: grantData.redirectUri,
|
|
4903
|
+
resource: grantData.resource
|
|
3820
4904
|
};
|
|
3821
4905
|
grantSummaries.push(summary);
|
|
3822
4906
|
}
|
|
@@ -3966,4 +5050,4 @@ var OAuthHelpersImpl = class {
|
|
|
3966
5050
|
var oauth_provider_default = OAuthProvider;
|
|
3967
5051
|
|
|
3968
5052
|
//#endregion
|
|
3969
|
-
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 };
|