@cloudflare/workers-oauth-provider 0.10.4 → 1.1.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 +200 -136
- package/dist/oauth-provider.d.ts +431 -88
- package/dist/oauth-provider.js +1756 -316
- package/docs/advanced-configuration.md +370 -0
- package/docs/consent-page.md +138 -0
- package/docs/migration-1.0.md +97 -0
- package/docs/resource-servers.md +153 -0
- package/docs/upstream-sign-in.md +93 -0
- package/package.json +4 -2
- package/skills/migrate-to-1.0/SKILL.md +35 -0
package/dist/oauth-provider.js
CHANGED
|
@@ -678,7 +678,7 @@ function emaErrorToWire(e) {
|
|
|
678
678
|
* only through the adapters' public interfaces.
|
|
679
679
|
*/
|
|
680
680
|
/** SHA-256 a string and return its hex digest. */
|
|
681
|
-
async function sha256Hex(input) {
|
|
681
|
+
async function sha256Hex$1(input) {
|
|
682
682
|
const data = new TextEncoder().encode(input);
|
|
683
683
|
const buffer = await crypto.subtle.digest("SHA-256", data);
|
|
684
684
|
return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -701,7 +701,7 @@ const EMA_JTI_KV_PREFIX = "enterprise-jti:";
|
|
|
701
701
|
function createKvJtiStore() {
|
|
702
702
|
return { async markUsed({ issuer, jti, exp, now, env }) {
|
|
703
703
|
const ttl = Math.max(EMA_JTI_MIN_TTL_SECONDS, exp - now);
|
|
704
|
-
const key = `${EMA_JTI_KV_PREFIX}${await sha256Hex(`${issuer}\n${jti}`)}`;
|
|
704
|
+
const key = `${EMA_JTI_KV_PREFIX}${await sha256Hex$1(`${issuer}\n${jti}`)}`;
|
|
705
705
|
if (await env.OAUTH_KV.get(key)) return err({
|
|
706
706
|
reason: "replayed",
|
|
707
707
|
jti
|
|
@@ -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,548 @@ 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
|
+
|
|
1552
|
+
//#endregion
|
|
1553
|
+
//#region src/oauth-consent.ts
|
|
1554
|
+
/**
|
|
1555
|
+
* Consent and upstream-state primitives for authorization servers that sign users in through a
|
|
1556
|
+
* third-party OAuth provider (an MCP "proxy" server). They implement the protections the MCP
|
|
1557
|
+
* 2026-07-28 security best practices require under Confused Deputy → Mitigation:
|
|
1558
|
+
*
|
|
1559
|
+
* - consent per client before any redirect to the third party, with CSRF protection and anti-framing
|
|
1560
|
+
* headers on the consent page;
|
|
1561
|
+
* - remembered consent, chosen per call, in a signed `__Host-` cookie bound to the client, its redirect
|
|
1562
|
+
* URI and resource (and the user, when the caller knows them), reused only for a subset of the
|
|
1563
|
+
* approved scopes;
|
|
1564
|
+
* - the approved scopes chosen on the consent page, from any the server supports;
|
|
1565
|
+
* - a random `state` stored server-side only after consent, bound to the browser by a `__Host-` cookie,
|
|
1566
|
+
* single-use and short-lived.
|
|
1567
|
+
*
|
|
1568
|
+
* A transaction handle is 256 random bits. KV stores the record under the handle's SHA-256, encrypted
|
|
1569
|
+
* with a key derived from the handle, so KV alone reveals neither the request nor deployer data such as
|
|
1570
|
+
* a PKCE verifier. Each transaction has its own binding cookie, named after its hash and holding the
|
|
1571
|
+
* full hash, so several authorizations can be in flight in one browser (two tabs) without replacing each
|
|
1572
|
+
* other. Single use is `get` then `delete`, which KV cannot make atomic: two concurrent requests from the
|
|
1573
|
+
* same browser with the same handle could both pass; the cookie binding confines that to the browser
|
|
1574
|
+
* that started the transaction.
|
|
1575
|
+
*/
|
|
1576
|
+
const TRANSACTION_TTL_SECONDS = 600;
|
|
1577
|
+
/** Default for the provider's `cookiePrefix` option. */
|
|
1578
|
+
const DEFAULT_COOKIE_PREFIX = "__Host-oauth-";
|
|
1579
|
+
const DEFAULT_REMEMBER_SECONDS = 720 * 60 * 60;
|
|
1580
|
+
const MIN_SECRET_LENGTH = 32;
|
|
1581
|
+
const MAX_APPROVALS_COOKIE_BYTES = 3800;
|
|
1582
|
+
/** Throws a `TypeError` unless the prefix keeps the `__Host-` guarantees the MCP best practices require. */
|
|
1583
|
+
function consentCookies(prefix = DEFAULT_COOKIE_PREFIX) {
|
|
1584
|
+
if (typeof prefix !== "string" || !prefix.startsWith("__Host-") || !/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(prefix)) throw new TypeError("cookiePrefix must start with \"__Host-\" and contain only cookie-name characters");
|
|
1585
|
+
return {
|
|
1586
|
+
consent: `${prefix}consent`,
|
|
1587
|
+
upstream: `${prefix}upstream`,
|
|
1588
|
+
approvals: `${prefix}approvals`
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
function validateRememberConsentOptions(options) {
|
|
1592
|
+
if (typeof options !== "object" || options === null) throw new TypeError("remember must be an object with a secret");
|
|
1593
|
+
if (typeof options.secret !== "string" || options.secret.length < MIN_SECRET_LENGTH) throw new TypeError(`remember.secret must be a string of at least ${MIN_SECRET_LENGTH} characters`);
|
|
1594
|
+
const maxAge = options.maxAgeSeconds;
|
|
1595
|
+
if (maxAge !== void 0 && (!Number.isInteger(maxAge) || maxAge <= 0)) throw new TypeError("remember.maxAgeSeconds must be a positive integer");
|
|
1596
|
+
if (options.subject !== void 0 && (typeof options.subject !== "string" || options.subject.length === 0)) throw new TypeError("remember.subject must be a non-empty string");
|
|
1597
|
+
}
|
|
1598
|
+
/** Start a consent transaction for an authorization request that must be shown to the user. */
|
|
1599
|
+
async function beginConsent(kv, cookies, request) {
|
|
1600
|
+
const { handle, hash } = await createTransaction(kv, {
|
|
1601
|
+
kind: "consent",
|
|
1602
|
+
request
|
|
1603
|
+
});
|
|
1604
|
+
return {
|
|
1605
|
+
handle,
|
|
1606
|
+
headers: new Headers({
|
|
1607
|
+
"Set-Cookie": bindingCookie(transactionCookieName(cookies.consent, hash), hash, TRANSACTION_TTL_SECONDS),
|
|
1608
|
+
"Cache-Control": "no-store",
|
|
1609
|
+
"Content-Security-Policy": "frame-ancestors 'none'",
|
|
1610
|
+
"X-Frame-Options": "DENY"
|
|
1611
|
+
})
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Consume a consent transaction the user approved. `scope`, when given, replaces the requested
|
|
1616
|
+
* scopes: the page may narrow them or offer more, but each must be one the server supports
|
|
1617
|
+
* (`supportedScopes`, from `scopesSupported`). `remember` stores the approval in a signed cookie.
|
|
1618
|
+
*/
|
|
1619
|
+
async function approveConsent(kv, cookies, supportedScopes, request, handle, options = {}) {
|
|
1620
|
+
if (options.remember !== void 0) validateRememberConsentOptions(options.remember);
|
|
1621
|
+
const transaction = await openTransaction(kv, request, cookies.consent, handle, "consent");
|
|
1622
|
+
let approved = transaction.record.request;
|
|
1623
|
+
if (options.scope !== void 0) {
|
|
1624
|
+
const supported = supportedScopes ? new Set(supportedScopes) : void 0;
|
|
1625
|
+
const valid = (scope) => typeof scope === "string" && isValidOAuthScopeToken(scope) && (!supported || supported.has(scope));
|
|
1626
|
+
if (!Array.isArray(options.scope) || !options.scope.every(valid)) throw new AuthorizationError("invalid_scope", { description: "Approved scopes must be ones this server supports" });
|
|
1627
|
+
approved = {
|
|
1628
|
+
...approved,
|
|
1629
|
+
scope: [...new Set(options.scope)]
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
await transaction.consume();
|
|
1633
|
+
const headers = new Headers({ "Cache-Control": "no-store" });
|
|
1634
|
+
headers.append("Set-Cookie", clearCookie(transaction.cookieName));
|
|
1635
|
+
if (options.remember) headers.append("Set-Cookie", await rememberApproval(cookies, request, approved, options.remember));
|
|
1636
|
+
return {
|
|
1637
|
+
request: approved,
|
|
1638
|
+
headers
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* Consume a consent transaction the user declined, and build the OAuth error redirect back to the
|
|
1643
|
+
* client: `error=access_denied`, the client's `state`, and `iss`. The redirect URI comes from the
|
|
1644
|
+
* stored request, which `parseAuthRequest()` validated, never from the form.
|
|
1645
|
+
*/
|
|
1646
|
+
async function denyConsent(kv, cookies, request, handle, options = {}) {
|
|
1647
|
+
const transaction = await openTransaction(kv, request, cookies.consent, handle, "consent");
|
|
1648
|
+
await transaction.consume();
|
|
1649
|
+
const record = transaction.record;
|
|
1650
|
+
const redirect = new URL(record.request.redirectUri);
|
|
1651
|
+
redirect.searchParams.set("error", "access_denied");
|
|
1652
|
+
if (options.description) redirect.searchParams.set("error_description", options.description);
|
|
1653
|
+
if (record.request.state) redirect.searchParams.set("state", record.request.state);
|
|
1654
|
+
if (record.request.issuer) redirect.searchParams.set("iss", record.request.issuer);
|
|
1655
|
+
const headers = new Headers({
|
|
1656
|
+
"Cache-Control": "no-store",
|
|
1657
|
+
Location: redirect.href
|
|
1658
|
+
});
|
|
1659
|
+
headers.append("Set-Cookie", clearCookie(transaction.cookieName));
|
|
1660
|
+
return {
|
|
1661
|
+
request: record.request,
|
|
1662
|
+
redirectTo: redirect.href,
|
|
1663
|
+
headers
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
/** Whether a remembered approval covers this request: same client, redirect URI and resource, subset of scopes. */
|
|
1667
|
+
async function isConsentRemembered(cookies, request, authRequest, remember) {
|
|
1668
|
+
validateRememberConsentOptions(remember);
|
|
1669
|
+
const approvals = await readApprovals(cookies, request, remember.secret);
|
|
1670
|
+
const key = await approvalKey(authRequest, remember.subject);
|
|
1671
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1672
|
+
const approval = approvals.find((entry) => entry.k === key && entry.e > now);
|
|
1673
|
+
if (!approval) return false;
|
|
1674
|
+
const approvedScopes = new Set(approval.s);
|
|
1675
|
+
return authRequest.scope.every((scope) => approvedScopes.has(scope));
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Save an approved authorization request before redirecting to the third-party provider, and get
|
|
1679
|
+
* the `state` to send it. Call only after consent. Pass `headers` to add the binding cookie to
|
|
1680
|
+
* headers you are already sending, such as `approveConsent()`'s.
|
|
1681
|
+
*/
|
|
1682
|
+
async function beginUpstream(kv, cookies, request, options = {}) {
|
|
1683
|
+
const { handle, hash } = await createTransaction(kv, {
|
|
1684
|
+
kind: "upstream",
|
|
1685
|
+
request,
|
|
1686
|
+
data: options.data
|
|
1687
|
+
});
|
|
1688
|
+
const headers = options.headers ?? new Headers();
|
|
1689
|
+
headers.append("Set-Cookie", bindingCookie(transactionCookieName(cookies.upstream, hash), hash, TRANSACTION_TTL_SECONDS));
|
|
1690
|
+
headers.set("Cache-Control", "no-store");
|
|
1691
|
+
return {
|
|
1692
|
+
state: handle,
|
|
1693
|
+
headers
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
1696
|
+
/** Recover the authorization request at the third-party provider's callback, from its `state` parameter. */
|
|
1697
|
+
async function finishUpstream(kv, cookies, request) {
|
|
1698
|
+
const state = new URL(request.url).searchParams.get("state");
|
|
1699
|
+
if (!state) throw new AuthorizationError("invalid_request", { description: "Missing state parameter" });
|
|
1700
|
+
const transaction = await openTransaction(kv, request, cookies.upstream, state, "upstream");
|
|
1701
|
+
await transaction.consume();
|
|
1702
|
+
const headers = new Headers({ "Cache-Control": "no-store" });
|
|
1703
|
+
headers.append("Set-Cookie", clearCookie(transaction.cookieName));
|
|
1704
|
+
return {
|
|
1705
|
+
request: transaction.record.request,
|
|
1706
|
+
data: transaction.record.data,
|
|
1707
|
+
headers
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
async function createTransaction(kv, record) {
|
|
1711
|
+
const handle = base64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
1712
|
+
const hash = await sha256Hex(handle);
|
|
1713
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
1714
|
+
const sealed = await crypto.subtle.encrypt({
|
|
1715
|
+
name: "AES-GCM",
|
|
1716
|
+
iv
|
|
1717
|
+
}, await transactionKey(handle), new TextEncoder().encode(JSON.stringify(record)));
|
|
1718
|
+
await kv.put(`transaction:${hash}`, `${base64url(iv)}.${base64url(new Uint8Array(sealed))}`, { expirationTtl: TRANSACTION_TTL_SECONDS });
|
|
1719
|
+
return {
|
|
1720
|
+
handle,
|
|
1721
|
+
hash
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
async function openTransaction(kv, request, cookieBase, handle, kind) {
|
|
1725
|
+
if (typeof handle !== "string" || handle.length === 0) throw new AuthorizationError("invalid_request", { description: "Missing transaction handle" });
|
|
1726
|
+
const hash = await sha256Hex(handle);
|
|
1727
|
+
const cookieName = transactionCookieName(cookieBase, hash);
|
|
1728
|
+
const bound = readCookie(request, cookieName);
|
|
1729
|
+
if (!bound) throw new AuthorizationError("invalid_request", { description: "This authorization was not started in this browser; start again" });
|
|
1730
|
+
if (!timingSafeEqual(hash, bound)) throw new AuthorizationError("invalid_request", { description: "This authorization belongs to a different browser session; start again" });
|
|
1731
|
+
const key = `transaction:${hash}`;
|
|
1732
|
+
const expired = new AuthorizationError("invalid_request", { description: "This authorization expired or was already used; start again" });
|
|
1733
|
+
const stored = await kv.get(key);
|
|
1734
|
+
if (!stored) throw expired;
|
|
1735
|
+
let record;
|
|
1736
|
+
try {
|
|
1737
|
+
const [iv, sealed] = stored.split(".");
|
|
1738
|
+
const plain = await crypto.subtle.decrypt({
|
|
1739
|
+
name: "AES-GCM",
|
|
1740
|
+
iv: fromBase64url(iv)
|
|
1741
|
+
}, await transactionKey(handle), fromBase64url(sealed));
|
|
1742
|
+
record = JSON.parse(new TextDecoder().decode(plain));
|
|
1743
|
+
} catch {
|
|
1744
|
+
throw expired;
|
|
1745
|
+
}
|
|
1746
|
+
if (record.kind !== kind) throw expired;
|
|
1747
|
+
return {
|
|
1748
|
+
record,
|
|
1749
|
+
cookieName,
|
|
1750
|
+
consume: () => kv.delete(key)
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
/** Only the holder of the handle can decrypt its record; KV keeps the hash, not the handle. */
|
|
1754
|
+
async function transactionKey(handle) {
|
|
1755
|
+
const material = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`oauth-transaction-key:${handle}`));
|
|
1756
|
+
return crypto.subtle.importKey("raw", material, "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
1757
|
+
}
|
|
1758
|
+
/** One binding cookie per transaction, so concurrent authorizations in one browser don't collide. */
|
|
1759
|
+
function transactionCookieName(base, hash) {
|
|
1760
|
+
return `${base}-${hash.slice(0, 16)}`;
|
|
1761
|
+
}
|
|
1762
|
+
async function rememberApproval(cookies, request, approved, remember) {
|
|
1763
|
+
const maxAge = remember.maxAgeSeconds ?? DEFAULT_REMEMBER_SECONDS;
|
|
1764
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1765
|
+
const key = await approvalKey(approved, remember.subject);
|
|
1766
|
+
const approvals = (await readApprovals(cookies, request, remember.secret)).filter((entry) => entry.e > now && entry.k !== key);
|
|
1767
|
+
approvals.push({
|
|
1768
|
+
k: key,
|
|
1769
|
+
s: approved.scope,
|
|
1770
|
+
e: now + maxAge
|
|
1771
|
+
});
|
|
1772
|
+
let value = await signApprovals(approvals, remember.secret);
|
|
1773
|
+
while (value.length > MAX_APPROVALS_COOKIE_BYTES && approvals.length > 1) {
|
|
1774
|
+
approvals.shift();
|
|
1775
|
+
value = await signApprovals(approvals, remember.secret);
|
|
1776
|
+
}
|
|
1777
|
+
const cookieMaxAge = Math.max(...approvals.map((entry) => entry.e)) - now;
|
|
1778
|
+
return `${cookies.approvals}=${value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${cookieMaxAge}`;
|
|
1779
|
+
}
|
|
1780
|
+
async function readApprovals(cookies, request, secret) {
|
|
1781
|
+
const value = readCookie(request, cookies.approvals);
|
|
1782
|
+
if (!value) return [];
|
|
1783
|
+
const [payload, signature] = value.split(".");
|
|
1784
|
+
if (!payload || !signature) return [];
|
|
1785
|
+
const key = await hmacKey(secret);
|
|
1786
|
+
let valid = false;
|
|
1787
|
+
try {
|
|
1788
|
+
valid = await crypto.subtle.verify("HMAC", key, fromBase64url(signature), new TextEncoder().encode(payload));
|
|
1789
|
+
} catch {
|
|
1790
|
+
return [];
|
|
1791
|
+
}
|
|
1792
|
+
if (!valid) return [];
|
|
1793
|
+
try {
|
|
1794
|
+
const parsed = JSON.parse(new TextDecoder().decode(fromBase64url(payload)));
|
|
1795
|
+
return Array.isArray(parsed) ? parsed.filter(isApproval) : [];
|
|
1796
|
+
} catch {
|
|
1797
|
+
return [];
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
async function signApprovals(approvals, secret) {
|
|
1801
|
+
const payload = base64url(new TextEncoder().encode(JSON.stringify(approvals)));
|
|
1802
|
+
const signature = await crypto.subtle.sign("HMAC", await hmacKey(secret), new TextEncoder().encode(payload));
|
|
1803
|
+
return `${payload}.${base64url(new Uint8Array(signature))}`;
|
|
1804
|
+
}
|
|
1805
|
+
function isApproval(value) {
|
|
1806
|
+
if (!value || typeof value !== "object") return false;
|
|
1807
|
+
const entry = value;
|
|
1808
|
+
return typeof entry.k === "string" && typeof entry.e === "number" && Array.isArray(entry.s) && entry.s.every((scope) => typeof scope === "string");
|
|
1809
|
+
}
|
|
1810
|
+
/** Approval is bound to the client, where its tokens go, which resource they are for, and the user if known. */
|
|
1811
|
+
async function approvalKey(request, subject) {
|
|
1812
|
+
const material = JSON.stringify([
|
|
1813
|
+
request.clientId,
|
|
1814
|
+
request.redirectUri,
|
|
1815
|
+
request.resource ?? null,
|
|
1816
|
+
subject ?? null
|
|
1817
|
+
]);
|
|
1818
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(material));
|
|
1819
|
+
return base64url(new Uint8Array(digest));
|
|
1820
|
+
}
|
|
1821
|
+
function hmacKey(secret) {
|
|
1822
|
+
return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), {
|
|
1823
|
+
name: "HMAC",
|
|
1824
|
+
hash: "SHA-256"
|
|
1825
|
+
}, false, ["sign", "verify"]);
|
|
1826
|
+
}
|
|
1827
|
+
function bindingCookie(name, value, maxAge) {
|
|
1828
|
+
return `${name}=${value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`;
|
|
1829
|
+
}
|
|
1830
|
+
function clearCookie(name) {
|
|
1831
|
+
return `${name}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
1832
|
+
}
|
|
1833
|
+
function readCookie(request, name) {
|
|
1834
|
+
const header = request.headers.get("Cookie");
|
|
1835
|
+
if (!header) return void 0;
|
|
1836
|
+
for (const part of header.split(";")) {
|
|
1837
|
+
const separator = part.indexOf("=");
|
|
1838
|
+
if (separator === -1) continue;
|
|
1839
|
+
if (part.slice(0, separator).trim() === name) return part.slice(separator + 1).trim();
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
async function sha256Hex(value) {
|
|
1843
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
1844
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1845
|
+
}
|
|
1846
|
+
function timingSafeEqual(a, b) {
|
|
1847
|
+
if (a.length !== b.length) return false;
|
|
1848
|
+
let difference = 0;
|
|
1849
|
+
for (let index = 0; index < a.length; index++) difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
1850
|
+
return difference === 0;
|
|
1851
|
+
}
|
|
1852
|
+
function base64url(bytes) {
|
|
1853
|
+
let binary = "";
|
|
1854
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
1855
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1856
|
+
}
|
|
1857
|
+
function fromBase64url(value) {
|
|
1858
|
+
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
1859
|
+
const binary = atob(base64 + "=".repeat((4 - base64.length % 4) % 4));
|
|
1860
|
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1231
1863
|
//#endregion
|
|
1232
1864
|
//#region src/oauth-provider.ts
|
|
1233
1865
|
const PROTECTED_RESOURCE_WELL_KNOWN_PREFIX = "/.well-known/oauth-protected-resource";
|
|
@@ -1256,7 +1888,7 @@ let GrantType = /* @__PURE__ */ function(GrantType$1) {
|
|
|
1256
1888
|
return GrantType$1;
|
|
1257
1889
|
}({});
|
|
1258
1890
|
function toPublicClientInfo(client) {
|
|
1259
|
-
const { authMethodExplicit: _explicit, ...publicClient } = client;
|
|
1891
|
+
const { authMethodExplicit: _explicit, registrationExpiresAt: _expiry, ...publicClient } = client;
|
|
1260
1892
|
return publicClient;
|
|
1261
1893
|
}
|
|
1262
1894
|
function isClientAuthMethodAllowed(client, presentedMethod, isClientMetadataDocument) {
|
|
@@ -1302,6 +1934,66 @@ var OAuthProvider = class {
|
|
|
1302
1934
|
}
|
|
1303
1935
|
};
|
|
1304
1936
|
/**
|
|
1937
|
+
* The authorization-server role on its own. It serves discovery, token, revocation and
|
|
1938
|
+
* registration endpoints from `fetch()`, exposes the interactive authorization flow through
|
|
1939
|
+
* `getOAuthApi()`, and validates its access tokens for any declared resource through
|
|
1940
|
+
* `validateToken()`. Resources are hosted, in this Worker or another, by
|
|
1941
|
+
* `OAuthResourceServer`, whose `validateToken` points back here.
|
|
1942
|
+
*/
|
|
1943
|
+
var OAuthAuthorizationServer = class {
|
|
1944
|
+
#impl;
|
|
1945
|
+
constructor(options) {
|
|
1946
|
+
const { issuer, resources, defaultResource, legacyGrantResource, authorizeEndpoint, tokenEndpoint, clientRegistrationEndpoint, ...commonOptions } = options;
|
|
1947
|
+
this.#impl = new OAuthProviderImpl({
|
|
1948
|
+
...commonOptions,
|
|
1949
|
+
defaultHandler: { fetch: () => new Response(null, { status: 404 }) },
|
|
1950
|
+
authorizationServer: {
|
|
1951
|
+
issuer,
|
|
1952
|
+
resources,
|
|
1953
|
+
authorizeEndpoint,
|
|
1954
|
+
tokenEndpoint,
|
|
1955
|
+
clientRegistrationEndpoint,
|
|
1956
|
+
defaultResource,
|
|
1957
|
+
legacyGrantResource
|
|
1958
|
+
}
|
|
1959
|
+
});
|
|
1960
|
+
}
|
|
1961
|
+
/**
|
|
1962
|
+
* Validate an access token for one declared resource: the token's KV record is loaded, its
|
|
1963
|
+
* audience checked against `resource`, and its props decrypted. Resolves `null` for a token
|
|
1964
|
+
* that is not valid for that resource, and rejects for a resource this server does not
|
|
1965
|
+
* declare.
|
|
1966
|
+
*
|
|
1967
|
+
* This is what a resource server calls, whether it shares this Worker or reaches it over a
|
|
1968
|
+
* Service Binding. For the latter, expose it from a `WorkerEntrypoint`:
|
|
1969
|
+
*
|
|
1970
|
+
* ```ts
|
|
1971
|
+
* export default class AuthServer extends WorkerEntrypoint<Env> {
|
|
1972
|
+
* fetch(request: Request) {
|
|
1973
|
+
* return authorizationServer.fetch(request, this.env, this.ctx);
|
|
1974
|
+
* }
|
|
1975
|
+
* validateToken(resource: string, token: string) {
|
|
1976
|
+
* return authorizationServer.validateToken(resource, token, this.env);
|
|
1977
|
+
* }
|
|
1978
|
+
* }
|
|
1979
|
+
* ```
|
|
1980
|
+
*/
|
|
1981
|
+
validateToken(resource, token, env) {
|
|
1982
|
+
return this.#impl.validateAccessToken(token, resource, env);
|
|
1983
|
+
}
|
|
1984
|
+
/** Serve protocol-owned authorization-server endpoints, excluding the application authorization UI. */
|
|
1985
|
+
fetch(request, env, ctx) {
|
|
1986
|
+
return this.#impl.fetchAuthorizationServer(request, env, ctx);
|
|
1987
|
+
}
|
|
1988
|
+
/** Obtain helpers for scheduled jobs or custom code outside a fetch dispatch. */
|
|
1989
|
+
getOAuthApi(env) {
|
|
1990
|
+
return this.#impl.createOAuthHelpers(env);
|
|
1991
|
+
}
|
|
1992
|
+
purgeExpiredData(env, options) {
|
|
1993
|
+
return this.#impl.createOAuthHelpers(env).purgeExpiredData(options);
|
|
1994
|
+
}
|
|
1995
|
+
};
|
|
1996
|
+
/**
|
|
1305
1997
|
* Gets OAuthHelpers for the given environment
|
|
1306
1998
|
* @param options - Configuration options for the OAuth provider
|
|
1307
1999
|
* @param env - Cloudflare Worker environment variables
|
|
@@ -1324,37 +2016,96 @@ var OAuthProviderImpl = class {
|
|
|
1324
2016
|
* @param options - Configuration options for the provider
|
|
1325
2017
|
*/
|
|
1326
2018
|
constructor(options) {
|
|
2019
|
+
this.errorResponses = /* @__PURE__ */ new WeakSet();
|
|
1327
2020
|
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
2021
|
this.typedDefaultHandler = this.validateHandler(options.defaultHandler, "defaultHandler");
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
2022
|
+
const roleBased = "authorizationServer" in options;
|
|
2023
|
+
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.");
|
|
2024
|
+
let normalizedOptions;
|
|
2025
|
+
let configuredResourceServers;
|
|
2026
|
+
if (roleBased) {
|
|
2027
|
+
const { authorizationServer: authorizationServer$1, ...commonOptions } = options;
|
|
2028
|
+
this.validateAuthorizationServerIssuer(authorizationServer$1.issuer);
|
|
2029
|
+
this.explicitIssuer = authorizationServer$1.issuer;
|
|
2030
|
+
normalizedOptions = {
|
|
2031
|
+
...commonOptions,
|
|
2032
|
+
authorizeEndpoint: authorizationServer$1.authorizeEndpoint,
|
|
2033
|
+
tokenEndpoint: authorizationServer$1.tokenEndpoint,
|
|
2034
|
+
clientRegistrationEndpoint: authorizationServer$1.clientRegistrationEndpoint
|
|
2035
|
+
};
|
|
2036
|
+
const declared = authorizationServer$1.resources;
|
|
2037
|
+
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");
|
|
2038
|
+
const duplicate = declared.find((resource, index) => declared.slice(0, index).some((earlier) => isExactResource(earlier, resource)));
|
|
2039
|
+
if (duplicate !== void 0) throw new TypeError(`resources must be unique; duplicate ${duplicate}`);
|
|
2040
|
+
configuredResourceServers = declared.map((resource) => ({ resourceMetadata: { resource } }));
|
|
2041
|
+
} else {
|
|
2042
|
+
this.explicitIssuer = void 0;
|
|
2043
|
+
normalizedOptions = options;
|
|
2044
|
+
configuredResourceServers = [{
|
|
2045
|
+
resourceMetadata: options.resourceMetadata,
|
|
2046
|
+
resolveExternalToken: options.resolveExternalToken
|
|
2047
|
+
}];
|
|
1346
2048
|
}
|
|
1347
|
-
this.validateEndpoint(options.authorizeEndpoint, "authorizeEndpoint");
|
|
1348
|
-
this.validateEndpoint(options.tokenEndpoint, "tokenEndpoint");
|
|
1349
|
-
if (options.clientRegistrationEndpoint) this.validateEndpoint(options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
|
|
1350
2049
|
this.options = {
|
|
1351
2050
|
accessTokenTTL: DEFAULT_ACCESS_TOKEN_TTL,
|
|
1352
2051
|
refreshTokenTTL: DEFAULT_REFRESH_TOKEN_TTL,
|
|
1353
2052
|
clientRegistrationTTL: DEFAULT_CLIENT_REGISTRATION_TTL,
|
|
1354
2053
|
onError: ({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`),
|
|
1355
|
-
...
|
|
2054
|
+
...normalizedOptions
|
|
1356
2055
|
};
|
|
1357
|
-
|
|
2056
|
+
this.validateEndpoint(this.options.authorizeEndpoint, "authorizeEndpoint");
|
|
2057
|
+
this.validateEndpoint(this.options.tokenEndpoint, "tokenEndpoint");
|
|
2058
|
+
this.consentCookies = consentCookies(this.options.cookiePrefix);
|
|
2059
|
+
if (this.options.clientRegistrationEndpoint) this.validateEndpoint(this.options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
|
|
2060
|
+
if (roleBased) this.validateAuthorizationServerRouteIsolation();
|
|
2061
|
+
this.resourceServers = configuredResourceServers.map(({ resourceMetadata, resolveExternalToken }) => {
|
|
2062
|
+
const metadata = this.snapshotResourceMetadata(resourceMetadata);
|
|
2063
|
+
this.validateResourceMetadataOptions(metadata);
|
|
2064
|
+
return {
|
|
2065
|
+
resourceMetadata: metadata,
|
|
2066
|
+
resolveExternalToken
|
|
2067
|
+
};
|
|
2068
|
+
});
|
|
2069
|
+
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}`);
|
|
2070
|
+
if (!roleBased) {
|
|
2071
|
+
const legacyOptions = options;
|
|
2072
|
+
const hasSingleHandlerConfig = !!(legacyOptions.apiRoute && legacyOptions.apiHandler);
|
|
2073
|
+
const hasMultiHandlerConfig = !!legacyOptions.apiHandlers;
|
|
2074
|
+
if (hasSingleHandlerConfig && hasMultiHandlerConfig) throw new TypeError("Cannot use both apiRoute/apiHandler and apiHandlers. Use either apiRoute + apiHandler OR apiHandlers, not both.");
|
|
2075
|
+
if (!hasSingleHandlerConfig && !hasMultiHandlerConfig) throw new TypeError("Must provide either apiRoute + apiHandler OR apiHandlers. No API route configuration provided.");
|
|
2076
|
+
const resourceServer = this.resourceServers[0];
|
|
2077
|
+
if (hasSingleHandlerConfig) {
|
|
2078
|
+
const handler = this.validateHandler(legacyOptions.apiHandler, "apiHandler");
|
|
2079
|
+
(Array.isArray(legacyOptions.apiRoute) ? legacyOptions.apiRoute : [legacyOptions.apiRoute]).forEach((route, index) => {
|
|
2080
|
+
this.validateEndpoint(route, Array.isArray(legacyOptions.apiRoute) ? `apiRoute[${index}]` : "apiRoute");
|
|
2081
|
+
this.typedApiHandlers.push({
|
|
2082
|
+
route,
|
|
2083
|
+
handler,
|
|
2084
|
+
resourceServer
|
|
2085
|
+
});
|
|
2086
|
+
});
|
|
2087
|
+
} else for (const [route, rawHandler] of Object.entries(legacyOptions.apiHandlers)) {
|
|
2088
|
+
this.validateEndpoint(route, `apiHandlers key: ${route}`);
|
|
2089
|
+
const handler = this.validateHandler(rawHandler, `apiHandlers[${route}]`);
|
|
2090
|
+
this.typedApiHandlers.push({
|
|
2091
|
+
route,
|
|
2092
|
+
handler,
|
|
2093
|
+
resourceServer
|
|
2094
|
+
});
|
|
2095
|
+
}
|
|
2096
|
+
for (const { route } of this.typedApiHandlers) this.assertRouteCoveredByResource(route, resourceServer.resourceMetadata.resource);
|
|
2097
|
+
}
|
|
2098
|
+
const resources = this.resourceServers.map((server) => server.resourceMetadata.resource);
|
|
2099
|
+
const soleResource = resources.length === 1 ? resources[0] : void 0;
|
|
2100
|
+
const authorizationServer = roleBased ? options.authorizationServer : void 0;
|
|
2101
|
+
this.configuredDefaultAuthorizationResource = authorizationServer?.defaultResource ?? soleResource;
|
|
2102
|
+
this.configuredLegacyGrantResource = authorizationServer?.legacyGrantResource ?? soleResource;
|
|
2103
|
+
if (roleBased) {
|
|
2104
|
+
this.getDefaultAuthorizationResource();
|
|
2105
|
+
this.getLegacyGrantResource();
|
|
2106
|
+
}
|
|
2107
|
+
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).`);
|
|
2108
|
+
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
2109
|
this.serverCapabilities = buildOAuthServerCapabilities({
|
|
1359
2110
|
allowImplicitFlow: !!this.options.allowImplicitFlow,
|
|
1360
2111
|
allowPlainPKCE: this.options.allowPlainPKCE === true,
|
|
@@ -1362,7 +2113,6 @@ var OAuthProviderImpl = class {
|
|
|
1362
2113
|
enterpriseManagedAuthorization: !!this.options.enterpriseManagedAuthorization
|
|
1363
2114
|
});
|
|
1364
2115
|
validateAuthorizationServerScopes(this.options.scopesSupported);
|
|
1365
|
-
this.validateResourceMetadataOptions(this.options.resourceMetadata);
|
|
1366
2116
|
this.validateEmaOptions(this.options.enterpriseManagedAuthorization);
|
|
1367
2117
|
if (this.options.enterpriseManagedAuthorization) {
|
|
1368
2118
|
this.jwksProvider = createDefaultJwksProvider({ cacheTtlSeconds: this.options.enterpriseManagedAuthorization.jwksCacheTtlSeconds });
|
|
@@ -1378,12 +2128,91 @@ var OAuthProviderImpl = class {
|
|
|
1378
2128
|
validateEndpoint(endpoint, name) {
|
|
1379
2129
|
if (this.isPath(endpoint)) {
|
|
1380
2130
|
if (!endpoint.startsWith("/")) throw new TypeError(`${name} path must be an absolute path starting with /`);
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
2131
|
+
if (this.explicitIssuer && new URL(endpoint, new URL(this.explicitIssuer).origin).hash) throw new TypeError(`${name} must not contain a fragment`);
|
|
2132
|
+
} else {
|
|
2133
|
+
let parsed;
|
|
2134
|
+
try {
|
|
2135
|
+
parsed = new URL(endpoint);
|
|
2136
|
+
} catch (e) {
|
|
2137
|
+
throw new TypeError(`${name} must be either an absolute path starting with / or a valid URL`);
|
|
2138
|
+
}
|
|
2139
|
+
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)`);
|
|
1385
2140
|
}
|
|
1386
2141
|
}
|
|
2142
|
+
/** Validate the explicit issuer used to host-gate the role-based AS. */
|
|
2143
|
+
validateAuthorizationServerIssuer(issuer) {
|
|
2144
|
+
let parsed;
|
|
2145
|
+
try {
|
|
2146
|
+
parsed = new URL(issuer);
|
|
2147
|
+
} catch {
|
|
2148
|
+
throw new TypeError("authorizationServer.issuer must be a canonical absolute HTTPS URL");
|
|
2149
|
+
}
|
|
2150
|
+
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)");
|
|
2151
|
+
}
|
|
2152
|
+
/** Reject exact collisions between protocol endpoints owned by the AS fetch surface. */
|
|
2153
|
+
validateAuthorizationServerRouteIsolation() {
|
|
2154
|
+
const issuerUrl = new URL(this.explicitIssuer);
|
|
2155
|
+
const discoveryEndpoint = this.getAuthorizationServerMetadataUrl(issuerUrl);
|
|
2156
|
+
const tokenUrl = new URL(this.getFullEndpointUrl(this.options.tokenEndpoint, issuerUrl));
|
|
2157
|
+
if (this.matchEndpoint(tokenUrl, discoveryEndpoint)) throw new TypeError("tokenEndpoint must not collide with the authorization server metadata endpoint");
|
|
2158
|
+
if (!this.options.clientRegistrationEndpoint) return;
|
|
2159
|
+
const registrationUrl = new URL(this.getFullEndpointUrl(this.options.clientRegistrationEndpoint, issuerUrl));
|
|
2160
|
+
if (this.matchEndpoint(registrationUrl, this.options.tokenEndpoint)) throw new TypeError("clientRegistrationEndpoint must not collide with tokenEndpoint");
|
|
2161
|
+
if (this.matchEndpoint(registrationUrl, discoveryEndpoint)) throw new TypeError("clientRegistrationEndpoint must not collide with the authorization server metadata endpoint");
|
|
2162
|
+
}
|
|
2163
|
+
/**
|
|
2164
|
+
* A protected route must be the canonical resource path or a path-boundary descendant of
|
|
2165
|
+
* it. Anything else can never validate a token, because every token is bound to the
|
|
2166
|
+
* canonical resource, so it would be a permanently 401 zone.
|
|
2167
|
+
*/
|
|
2168
|
+
assertRouteCoveredByResource(route, resource) {
|
|
2169
|
+
const resourceUrl = new URL(resource);
|
|
2170
|
+
let routePath;
|
|
2171
|
+
if (this.isPath(route)) routePath = route.split("?")[0];
|
|
2172
|
+
else {
|
|
2173
|
+
const routeUrl = new URL(route);
|
|
2174
|
+
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.`);
|
|
2175
|
+
for (const [name, value] of resourceUrl.searchParams) {
|
|
2176
|
+
const routeValues = routeUrl.searchParams.getAll(name);
|
|
2177
|
+
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.`);
|
|
2178
|
+
}
|
|
2179
|
+
routePath = routeUrl.pathname;
|
|
2180
|
+
}
|
|
2181
|
+
if (isPathDescendant(routePath, resourceUrl.pathname)) return;
|
|
2182
|
+
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.`);
|
|
2183
|
+
}
|
|
2184
|
+
/** Resolve a configured policy value to the registry's canonical spelling. */
|
|
2185
|
+
resolveConfiguredResourceDefault(resource, name) {
|
|
2186
|
+
if (resource === void 0) return void 0;
|
|
2187
|
+
const configured = this.findConfiguredResource(resource);
|
|
2188
|
+
if (!configured) throw new TypeError(`${name} must name one of the configured protected resources`);
|
|
2189
|
+
return configured;
|
|
2190
|
+
}
|
|
2191
|
+
getDefaultAuthorizationResource() {
|
|
2192
|
+
const sole = this.resourceServers.length === 1 ? this.resourceServers[0].resourceMetadata.resource : void 0;
|
|
2193
|
+
return this.resolveConfiguredResourceDefault(this.configuredDefaultAuthorizationResource ?? sole, "defaultResource");
|
|
2194
|
+
}
|
|
2195
|
+
getLegacyGrantResource() {
|
|
2196
|
+
const sole = this.resourceServers.length === 1 ? this.resourceServers[0].resourceMetadata.resource : void 0;
|
|
2197
|
+
return this.resolveConfiguredResourceDefault(this.configuredLegacyGrantResource ?? sole, "legacyGrantResource");
|
|
2198
|
+
}
|
|
2199
|
+
/**
|
|
2200
|
+
* The audience a stored access token is treated as bound to.
|
|
2201
|
+
*
|
|
2202
|
+
* A token issued before resource binding has no stored audience. It keeps
|
|
2203
|
+
* working at the server-selected migration resource (the sole resource, or
|
|
2204
|
+
* `legacyGrantResource` in a multi-resource deployment) until it expires;
|
|
2205
|
+
* refresh binds its grant and returns a bound replacement. The client never
|
|
2206
|
+
* chooses this destination. Without a migration resource the token is
|
|
2207
|
+
* treated as unbound and rejected.
|
|
2208
|
+
*
|
|
2209
|
+
* The destination is deployment policy, not an issuance-time claim: changing
|
|
2210
|
+
* `legacyGrantResource` re-targets every surviving unbound token and grant.
|
|
2211
|
+
* Keep it fixed for the length of the migration window.
|
|
2212
|
+
*/
|
|
2213
|
+
resolveStoredTokenAudience(audience) {
|
|
2214
|
+
return audience === void 0 ? this.getLegacyGrantResource() : audience;
|
|
2215
|
+
}
|
|
1387
2216
|
/**
|
|
1388
2217
|
* Validates that a handler is either an ExportedHandler or a class extending WorkerEntrypoint
|
|
1389
2218
|
* @param handler - The handler to validate
|
|
@@ -1402,10 +2231,22 @@ var OAuthProviderImpl = class {
|
|
|
1402
2231
|
};
|
|
1403
2232
|
throw new TypeError(`${name} must be either an ExportedHandler object with a fetch method or a class extending WorkerEntrypoint`);
|
|
1404
2233
|
}
|
|
2234
|
+
/** Snapshot caller-owned metadata so later mutation cannot change routing or policy. */
|
|
2235
|
+
snapshotResourceMetadata(metadata) {
|
|
2236
|
+
return {
|
|
2237
|
+
...metadata,
|
|
2238
|
+
...metadata?.authorization_servers ? { authorization_servers: [...metadata.authorization_servers] } : {},
|
|
2239
|
+
...metadata?.scopes_supported ? { scopes_supported: [...metadata.scopes_supported] } : {},
|
|
2240
|
+
...metadata?.bearer_methods_supported ? { bearer_methods_supported: [...metadata.bearer_methods_supported] } : {}
|
|
2241
|
+
};
|
|
2242
|
+
}
|
|
1405
2243
|
/** Validate configured RFC 9728 protected resource metadata. */
|
|
1406
2244
|
validateResourceMetadataOptions(options) {
|
|
1407
|
-
if (!options)
|
|
1408
|
-
if (options.resource !==
|
|
2245
|
+
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)");
|
|
2246
|
+
if (foldResourceSchemeAndHost(options.resource) !== options.resource) throw new TypeError("resourceMetadata.resource must use a lowercase scheme and lowercase host");
|
|
2247
|
+
const parsedResource = new URL(options.resource);
|
|
2248
|
+
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");
|
|
2249
|
+
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
2250
|
if (options.authorization_servers !== void 0) {
|
|
1410
2251
|
if (options.authorization_servers.length === 0) throw new TypeError("resourceMetadata.authorization_servers must contain at least one issuer");
|
|
1411
2252
|
for (const issuer of options.authorization_servers) {
|
|
@@ -1415,7 +2256,7 @@ var OAuthProviderImpl = class {
|
|
|
1415
2256
|
} catch {
|
|
1416
2257
|
throw new TypeError("resourceMetadata.authorization_servers must contain valid HTTPS issuer URLs");
|
|
1417
2258
|
}
|
|
1418
|
-
if (parsed.
|
|
2259
|
+
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
2260
|
}
|
|
1420
2261
|
}
|
|
1421
2262
|
if (options.scopes_supported?.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("resourceMetadata.scopes_supported must contain valid OAuth scope tokens");
|
|
@@ -1433,7 +2274,6 @@ var OAuthProviderImpl = class {
|
|
|
1433
2274
|
if (!options) return;
|
|
1434
2275
|
if (typeof options.trustedIssuers !== "function") throw new TypeError("enterpriseManagedAuthorization.trustedIssuers must be a resolver function: (input) => EmaTrustedIssuer | null");
|
|
1435
2276
|
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
2277
|
if (options.jwksCacheTtlSeconds !== void 0 && options.jwksCacheTtlSeconds <= 0) throw new TypeError("enterpriseManagedAuthorization.jwksCacheTtlSeconds must be greater than 0");
|
|
1438
2278
|
if (options.clockSkewSeconds !== void 0 && options.clockSkewSeconds < 0) throw new TypeError("enterpriseManagedAuthorization.clockSkewSeconds must be non-negative");
|
|
1439
2279
|
if (options.maxAssertionLifetimeSeconds !== void 0 && options.maxAssertionLifetimeSeconds <= 0) throw new TypeError("enterpriseManagedAuthorization.maxAssertionLifetimeSeconds must be greater than 0");
|
|
@@ -1447,20 +2287,38 @@ var OAuthProviderImpl = class {
|
|
|
1447
2287
|
* @returns A Promise resolving to an HTTP Response
|
|
1448
2288
|
*/
|
|
1449
2289
|
async fetch(request, env, ctx) {
|
|
2290
|
+
return this.fetchForRoles(request, env, ctx, "combined");
|
|
2291
|
+
}
|
|
2292
|
+
async fetchAuthorizationServer(request, env, ctx) {
|
|
2293
|
+
return this.fetchForRoles(request, env, ctx, "authorization-server");
|
|
2294
|
+
}
|
|
2295
|
+
async fetchForRoles(request, env, ctx, role) {
|
|
1450
2296
|
const url = new URL(request.url);
|
|
2297
|
+
const servesProtectedResources = role === "combined";
|
|
2298
|
+
const metadataResourceServer = servesProtectedResources ? this.findResourceServerForMetadataUrl(url) : void 0;
|
|
2299
|
+
const apiRoute = servesProtectedResources ? this.findApiRouteForUrl(url) : void 0;
|
|
1451
2300
|
if (request.method === "OPTIONS") {
|
|
1452
|
-
if (this.
|
|
2301
|
+
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
2302
|
status: 204,
|
|
1454
2303
|
headers: { "Content-Length": "0" }
|
|
1455
2304
|
}), request);
|
|
1456
2305
|
}
|
|
1457
|
-
if (url
|
|
2306
|
+
if (this.isAuthorizationServerMetadataRequest(url)) {
|
|
2307
|
+
if (request.method !== "GET" && request.method !== "HEAD") return this.addCorsHeaders(new Response(null, {
|
|
2308
|
+
status: 405,
|
|
2309
|
+
headers: { Allow: "GET, HEAD, OPTIONS" }
|
|
2310
|
+
}), request);
|
|
1458
2311
|
const response = await this.handleMetadataDiscovery(url);
|
|
1459
|
-
return this.addCorsHeaders(response, request);
|
|
2312
|
+
return this.addCorsHeaders(withoutBodyForHead(request, response), request);
|
|
1460
2313
|
}
|
|
1461
|
-
if (this.
|
|
1462
|
-
|
|
1463
|
-
return this.addCorsHeaders(
|
|
2314
|
+
if (servesProtectedResources && this.isProtectedResourceMetadataPath(url)) {
|
|
2315
|
+
if (!metadataResourceServer) return this.addCorsHeaders(new Response(null, { status: 404 }), request);
|
|
2316
|
+
if (request.method !== "GET" && request.method !== "HEAD") return this.addCorsHeaders(new Response(null, {
|
|
2317
|
+
status: 405,
|
|
2318
|
+
headers: { Allow: "GET, HEAD, OPTIONS" }
|
|
2319
|
+
}), request);
|
|
2320
|
+
const response = this.handleProtectedResourceMetadata(url, metadataResourceServer);
|
|
2321
|
+
return this.addCorsHeaders(withoutBodyForHead(request, response), request);
|
|
1464
2322
|
}
|
|
1465
2323
|
if (this.isTokenEndpoint(url)) {
|
|
1466
2324
|
const parsed = await this.parseTokenEndpointRequest(request, env);
|
|
@@ -1468,16 +2326,18 @@ var OAuthProviderImpl = class {
|
|
|
1468
2326
|
let response;
|
|
1469
2327
|
if (parsed.isRevocationRequest) response = await this.handleRevocationRequest(parsed.body, parsed.clientInfo, env);
|
|
1470
2328
|
else response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env, url, request);
|
|
2329
|
+
if (response.ok && !this.errorResponses.has(response)) await this.renewClientRegistrationIfDue(env, parsed.clientInfo, Math.floor(Date.now() / 1e3));
|
|
1471
2330
|
return this.addCorsHeaders(response, request);
|
|
1472
2331
|
}
|
|
1473
2332
|
if (this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) {
|
|
1474
2333
|
const response = await this.handleClientRegistration(request, env);
|
|
1475
2334
|
return this.addCorsHeaders(response, request);
|
|
1476
2335
|
}
|
|
1477
|
-
if (
|
|
1478
|
-
const response = await this.handleApiRequest(request, env, ctx);
|
|
2336
|
+
if (apiRoute) {
|
|
2337
|
+
const response = await this.handleApiRequest(request, env, ctx, apiRoute);
|
|
1479
2338
|
return this.addCorsHeaders(response, request);
|
|
1480
2339
|
}
|
|
2340
|
+
if (typeof role === "object") return new Response(null, { status: 404 });
|
|
1481
2341
|
if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
|
|
1482
2342
|
if (this.typedDefaultHandler.type === HandlerType.EXPORTED_HANDLER) return this.typedDefaultHandler.handler.fetch(request, env, ctx);
|
|
1483
2343
|
return new this.typedDefaultHandler.handler(ctx, env).fetch(request);
|
|
@@ -1514,6 +2374,20 @@ var OAuthProviderImpl = class {
|
|
|
1514
2374
|
}
|
|
1515
2375
|
};
|
|
1516
2376
|
}
|
|
2377
|
+
async validateAccessToken(token, resource, env) {
|
|
2378
|
+
const configuredResource = this.findConfiguredResource(resource);
|
|
2379
|
+
if (!configuredResource) throw new TypeError("resource must name one registered protected resource");
|
|
2380
|
+
const summary = await this.unwrapToken(token, env);
|
|
2381
|
+
if (!summary || !isExactResource(this.resolveStoredTokenAudience(summary.audience), configuredResource)) return null;
|
|
2382
|
+
return {
|
|
2383
|
+
props: summary.grant.props,
|
|
2384
|
+
audience: configuredResource,
|
|
2385
|
+
expiresAt: summary.expiresAt,
|
|
2386
|
+
scope: summary.scope,
|
|
2387
|
+
userId: summary.userId,
|
|
2388
|
+
clientId: summary.grant.clientId
|
|
2389
|
+
};
|
|
2390
|
+
}
|
|
1517
2391
|
/**
|
|
1518
2392
|
* Determines if an endpoint configuration is a path or a full URL
|
|
1519
2393
|
* @param endpoint - The endpoint configuration
|
|
@@ -1528,12 +2402,26 @@ var OAuthProviderImpl = class {
|
|
|
1528
2402
|
* @param endpoint - The endpoint pattern (full URL or path)
|
|
1529
2403
|
* @returns True if the URL matches the endpoint pattern
|
|
1530
2404
|
*/
|
|
1531
|
-
matchEndpoint(url, endpoint) {
|
|
1532
|
-
if (this.
|
|
1533
|
-
|
|
1534
|
-
const
|
|
1535
|
-
return url.hostname ===
|
|
2405
|
+
matchEndpoint(url, endpoint, allowAdditionalQuery = false) {
|
|
2406
|
+
if (!this.explicitIssuer) {
|
|
2407
|
+
if (this.isPath(endpoint)) return url.pathname === endpoint;
|
|
2408
|
+
const legacyEndpoint = new URL(endpoint);
|
|
2409
|
+
return url.hostname === legacyEndpoint.hostname && url.pathname === legacyEndpoint.pathname;
|
|
1536
2410
|
}
|
|
2411
|
+
const endpointUrl = new URL(this.getFullEndpointUrl(endpoint, url));
|
|
2412
|
+
if (url.origin !== endpointUrl.origin || url.pathname !== endpointUrl.pathname) return false;
|
|
2413
|
+
const unmatchedActualQuery = [...url.searchParams.entries()];
|
|
2414
|
+
const configuredQueryNames = /* @__PURE__ */ new Set();
|
|
2415
|
+
for (const [name, value] of endpointUrl.searchParams) {
|
|
2416
|
+
configuredQueryNames.add(name);
|
|
2417
|
+
const match = unmatchedActualQuery.findIndex(([actualName, actualValue]) => {
|
|
2418
|
+
return actualName === name && actualValue === value;
|
|
2419
|
+
});
|
|
2420
|
+
if (match === -1) return false;
|
|
2421
|
+
unmatchedActualQuery.splice(match, 1);
|
|
2422
|
+
}
|
|
2423
|
+
if (!allowAdditionalQuery) return unmatchedActualQuery.length === 0;
|
|
2424
|
+
return unmatchedActualQuery.every(([name]) => !configuredQueryNames.has(name));
|
|
1537
2425
|
}
|
|
1538
2426
|
/**
|
|
1539
2427
|
* Checks if a URL matches the configured token endpoint
|
|
@@ -1541,7 +2429,10 @@ var OAuthProviderImpl = class {
|
|
|
1541
2429
|
* @returns True if the URL matches the token endpoint
|
|
1542
2430
|
*/
|
|
1543
2431
|
isTokenEndpoint(url) {
|
|
1544
|
-
return this.matchEndpoint(url, this.options.tokenEndpoint);
|
|
2432
|
+
return this.matchEndpoint(url, this.options.tokenEndpoint, true);
|
|
2433
|
+
}
|
|
2434
|
+
isAuthorizationEndpointRequest(url) {
|
|
2435
|
+
return this.matchEndpoint(url, this.options.authorizeEndpoint, true);
|
|
1545
2436
|
}
|
|
1546
2437
|
/**
|
|
1547
2438
|
* Checks if a URL matches the configured client registration endpoint
|
|
@@ -1550,28 +2441,37 @@ var OAuthProviderImpl = class {
|
|
|
1550
2441
|
*/
|
|
1551
2442
|
isClientRegistrationEndpoint(url) {
|
|
1552
2443
|
if (!this.options.clientRegistrationEndpoint) return false;
|
|
1553
|
-
return this.matchEndpoint(url, this.options.clientRegistrationEndpoint);
|
|
2444
|
+
return this.matchEndpoint(url, this.options.clientRegistrationEndpoint, true);
|
|
1554
2445
|
}
|
|
1555
2446
|
/**
|
|
1556
2447
|
* Checks if a URL is a request for OAuth Protected Resource Metadata (RFC 9728).
|
|
1557
|
-
*
|
|
2448
|
+
* Only the well-known URL constructed from the configured canonical resource
|
|
2449
|
+
* may return its document; aliases would violate RFC 9728 §3.3.
|
|
1558
2450
|
*/
|
|
1559
|
-
|
|
1560
|
-
return
|
|
2451
|
+
findResourceServerForMetadataUrl(url) {
|
|
2452
|
+
return this.resourceServers.find((server) => {
|
|
2453
|
+
const expected = new URL(this.getConfiguredResourceMetadataUrl(server.resourceMetadata.resource));
|
|
2454
|
+
return url.origin === expected.origin && url.pathname === expected.pathname && requestCarriesResourceQuery(url, expected);
|
|
2455
|
+
});
|
|
1561
2456
|
}
|
|
1562
|
-
/**
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
2457
|
+
/** Exact RFC 8414 discovery location for the configured issuer. */
|
|
2458
|
+
getAuthorizationServerMetadataUrl(requestUrl) {
|
|
2459
|
+
if (!this.explicitIssuer) return `${requestUrl.origin}/.well-known/oauth-authorization-server`;
|
|
2460
|
+
const issuer = new URL(this.explicitIssuer);
|
|
2461
|
+
const issuerPath = issuer.pathname === "/" ? "" : issuer.pathname.replace(/\/+$/, "");
|
|
2462
|
+
return `${issuer.origin}/.well-known/oauth-authorization-server${issuerPath}`;
|
|
2463
|
+
}
|
|
2464
|
+
isAuthorizationServerMetadataRequest(url) {
|
|
2465
|
+
const expected = new URL(this.getAuthorizationServerMetadataUrl(url));
|
|
2466
|
+
return url.origin === expected.origin && url.pathname === expected.pathname;
|
|
2467
|
+
}
|
|
2468
|
+
/** Whether this instance is the role-based authorization server with a fixed issuer. */
|
|
2469
|
+
get hasExplicitIssuer() {
|
|
2470
|
+
return this.explicitIssuer !== void 0;
|
|
2471
|
+
}
|
|
2472
|
+
/** Whether a URL is in the RFC 9728 protected-resource metadata namespace. */
|
|
2473
|
+
isProtectedResourceMetadataPath(url) {
|
|
2474
|
+
return url.pathname === PROTECTED_RESOURCE_WELL_KNOWN_PREFIX || url.pathname.startsWith(PROTECTED_RESOURCE_WELL_KNOWN_PREFIX + "/");
|
|
1575
2475
|
}
|
|
1576
2476
|
createInvalidClientResponse(description, basicAuthenticationAttempted, internal, request) {
|
|
1577
2477
|
return this.createErrorResponse("invalid_client", {
|
|
@@ -1588,13 +2488,20 @@ var OAuthProviderImpl = class {
|
|
|
1588
2488
|
async parseTokenEndpointRequest(request, env) {
|
|
1589
2489
|
if (request.method !== "POST") return this.createErrorResponse("invalid_request", {
|
|
1590
2490
|
description: "Method not allowed",
|
|
1591
|
-
statusCode: 405
|
|
2491
|
+
statusCode: 405,
|
|
2492
|
+
headers: { Allow: "POST, OPTIONS" }
|
|
2493
|
+
}, {
|
|
2494
|
+
category: "token-endpoint-request",
|
|
2495
|
+
reason: "method_not_allowed"
|
|
1592
2496
|
});
|
|
1593
2497
|
const contentType = request.headers.get("Content-Type") || "";
|
|
1594
2498
|
let body = {};
|
|
1595
2499
|
if (contentType.split(";")[0].trim().toLowerCase() !== "application/x-www-form-urlencoded") return this.createErrorResponse("invalid_request", {
|
|
1596
2500
|
description: "Content-Type must be application/x-www-form-urlencoded",
|
|
1597
2501
|
statusCode: 400
|
|
2502
|
+
}, {
|
|
2503
|
+
category: "token-endpoint-request",
|
|
2504
|
+
reason: "unsupported_content_type"
|
|
1598
2505
|
});
|
|
1599
2506
|
let formData;
|
|
1600
2507
|
try {
|
|
@@ -1603,6 +2510,9 @@ var OAuthProviderImpl = class {
|
|
|
1603
2510
|
return this.createErrorResponse("invalid_request", {
|
|
1604
2511
|
description: "Request body must be valid application/x-www-form-urlencoded data",
|
|
1605
2512
|
statusCode: 400
|
|
2513
|
+
}, {
|
|
2514
|
+
category: "token-endpoint-request",
|
|
2515
|
+
reason: "malformed_request_body"
|
|
1606
2516
|
});
|
|
1607
2517
|
}
|
|
1608
2518
|
const processedKeys = /* @__PURE__ */ new Set();
|
|
@@ -1613,6 +2523,10 @@ var OAuthProviderImpl = class {
|
|
|
1613
2523
|
if (key !== "resource" && allValues.length > 1) return this.createErrorResponse("invalid_request", {
|
|
1614
2524
|
description: `Request parameter "${key}" must not be repeated`,
|
|
1615
2525
|
statusCode: 400
|
|
2526
|
+
}, {
|
|
2527
|
+
category: "token-endpoint-request",
|
|
2528
|
+
reason: "repeated_parameter",
|
|
2529
|
+
detail: { parameter: key }
|
|
1616
2530
|
});
|
|
1617
2531
|
body[key] = allValues.length > 1 ? allValues : value;
|
|
1618
2532
|
}
|
|
@@ -1624,15 +2538,24 @@ var OAuthProviderImpl = class {
|
|
|
1624
2538
|
if (formData.has("client_id") || formData.has("client_secret")) return this.createErrorResponse("invalid_request", {
|
|
1625
2539
|
description: "Client must not use multiple authentication methods",
|
|
1626
2540
|
statusCode: 400
|
|
2541
|
+
}, {
|
|
2542
|
+
category: "client-authentication",
|
|
2543
|
+
reason: "multiple_authentication_methods"
|
|
2544
|
+
});
|
|
2545
|
+
if (basicAuthorization.kind === "malformed") return this.createInvalidClientResponse("Client authentication failed: invalid Basic credentials", basicAuthenticationAttempted, {
|
|
2546
|
+
category: "client-authentication",
|
|
2547
|
+
reason: "malformed_basic_credentials"
|
|
1627
2548
|
});
|
|
1628
|
-
if (basicAuthorization.kind === "malformed") return this.createInvalidClientResponse("Client authentication failed: invalid Basic credentials", basicAuthenticationAttempted);
|
|
1629
2549
|
clientId = basicAuthorization.clientId;
|
|
1630
2550
|
clientSecret = basicAuthorization.clientSecret;
|
|
1631
2551
|
} else {
|
|
1632
2552
|
clientId = body.client_id;
|
|
1633
2553
|
clientSecret = body.client_secret || "";
|
|
1634
2554
|
}
|
|
1635
|
-
if (!clientId) return this.createInvalidClientResponse("Client ID is required", basicAuthenticationAttempted
|
|
2555
|
+
if (!clientId) return this.createInvalidClientResponse("Client ID is required", basicAuthenticationAttempted, {
|
|
2556
|
+
category: "client-authentication",
|
|
2557
|
+
reason: "client_id_missing"
|
|
2558
|
+
});
|
|
1636
2559
|
let clientInfo;
|
|
1637
2560
|
try {
|
|
1638
2561
|
clientInfo = await this.getClient(env, clientId);
|
|
@@ -1647,7 +2570,10 @@ var OAuthProviderImpl = class {
|
|
|
1647
2570
|
}, request);
|
|
1648
2571
|
throw error;
|
|
1649
2572
|
}
|
|
1650
|
-
if (!clientInfo) return this.createInvalidClientResponse("Client not found", basicAuthenticationAttempted
|
|
2573
|
+
if (!clientInfo) return this.createInvalidClientResponse("Client not found", basicAuthenticationAttempted, {
|
|
2574
|
+
category: "client-authentication",
|
|
2575
|
+
reason: "client_not_found"
|
|
2576
|
+
});
|
|
1651
2577
|
const presentedAuthMethod = basicAuthenticationAttempted ? "client_secret_basic" : formData.has("client_secret") ? "client_secret_post" : "none";
|
|
1652
2578
|
const registeredAuthMethod = clientInfo.tokenEndpointAuthMethod;
|
|
1653
2579
|
if (!isClientAuthMethodAllowed(clientInfo, presentedAuthMethod, !!this.options.clientIdMetadataDocumentEnabled && this.isClientMetadataUrl(clientInfo.clientId))) return this.createInvalidClientResponse("Client authentication failed", basicAuthenticationAttempted, {
|
|
@@ -1660,9 +2586,18 @@ var OAuthProviderImpl = class {
|
|
|
1660
2586
|
}
|
|
1661
2587
|
});
|
|
1662
2588
|
if (presentedAuthMethod !== "none") {
|
|
1663
|
-
if (!clientSecret) return this.createInvalidClientResponse("Client authentication failed: missing client_secret", basicAuthenticationAttempted
|
|
1664
|
-
|
|
1665
|
-
|
|
2589
|
+
if (!clientSecret) return this.createInvalidClientResponse("Client authentication failed: missing client_secret", basicAuthenticationAttempted, {
|
|
2590
|
+
category: "client-authentication",
|
|
2591
|
+
reason: "client_secret_missing"
|
|
2592
|
+
});
|
|
2593
|
+
if (!clientInfo.clientSecret) return this.createInvalidClientResponse("Client authentication failed: client has no registered secret", basicAuthenticationAttempted, {
|
|
2594
|
+
category: "client-authentication",
|
|
2595
|
+
reason: "client_secret_not_registered"
|
|
2596
|
+
});
|
|
2597
|
+
if (await hashSecret(clientSecret) !== clientInfo.clientSecret) return this.createInvalidClientResponse("Client authentication failed: invalid client_secret", basicAuthenticationAttempted, {
|
|
2598
|
+
category: "client-authentication",
|
|
2599
|
+
reason: "client_secret_mismatch"
|
|
2600
|
+
});
|
|
1666
2601
|
}
|
|
1667
2602
|
const isRevocationRequest = !body.grant_type && !!body.token;
|
|
1668
2603
|
return {
|
|
@@ -1678,30 +2613,24 @@ var OAuthProviderImpl = class {
|
|
|
1678
2613
|
* @returns True if the URL matches the API route
|
|
1679
2614
|
*/
|
|
1680
2615
|
matchApiRoute(url, route) {
|
|
1681
|
-
|
|
1682
|
-
if (
|
|
1683
|
-
|
|
1684
|
-
|
|
2616
|
+
const pathMatches = (configuredPath, rootMatchesDescendants) => {
|
|
2617
|
+
if (configuredPath === "/") return rootMatchesDescendants || url.pathname === "/";
|
|
2618
|
+
const normalized = configuredPath.endsWith("/") ? configuredPath.slice(0, -1) : configuredPath;
|
|
2619
|
+
return url.pathname === normalized || url.pathname.startsWith(normalized + "/");
|
|
2620
|
+
};
|
|
2621
|
+
if (this.isPath(route)) return pathMatches(route, false);
|
|
2622
|
+
else {
|
|
1685
2623
|
const apiUrl = new URL(route);
|
|
1686
|
-
return url.
|
|
2624
|
+
return url.origin === apiUrl.origin && pathMatches(apiUrl.pathname, true) && requestCarriesResourceQuery(url, apiUrl);
|
|
1687
2625
|
}
|
|
1688
2626
|
}
|
|
1689
2627
|
/**
|
|
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
2628
|
* Finds the appropriate API handler for a URL
|
|
1700
2629
|
* @param url - The URL to find a handler for
|
|
1701
2630
|
* @returns The TypedHandler for the URL, or undefined if no handler matches
|
|
1702
2631
|
*/
|
|
1703
|
-
|
|
1704
|
-
|
|
2632
|
+
findApiRouteForUrl(url) {
|
|
2633
|
+
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
2634
|
}
|
|
1706
2635
|
/**
|
|
1707
2636
|
* Gets the full URL for an endpoint, using the provided request URL's
|
|
@@ -1711,13 +2640,14 @@ var OAuthProviderImpl = class {
|
|
|
1711
2640
|
* @returns The full URL for the endpoint
|
|
1712
2641
|
*/
|
|
1713
2642
|
getFullEndpointUrl(endpoint, requestUrl) {
|
|
1714
|
-
if (this.isPath(endpoint)) return `${requestUrl.origin}${endpoint}`;
|
|
2643
|
+
if (this.isPath(endpoint)) return `${this.explicitIssuer ? new URL(this.explicitIssuer).origin : requestUrl.origin}${endpoint}`;
|
|
1715
2644
|
else return endpoint;
|
|
1716
2645
|
}
|
|
1717
2646
|
/**
|
|
1718
2647
|
* Gets the authorization server issuer using the same derivation as RFC 8414 metadata.
|
|
1719
2648
|
*/
|
|
1720
2649
|
getAuthorizationServerIssuer(requestUrl) {
|
|
2650
|
+
if (this.explicitIssuer) return this.explicitIssuer;
|
|
1721
2651
|
const tokenEndpoint = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
|
|
1722
2652
|
return new URL(tokenEndpoint).origin;
|
|
1723
2653
|
}
|
|
@@ -1734,6 +2664,7 @@ var OAuthProviderImpl = class {
|
|
|
1734
2664
|
newResponse.headers.set("Access-Control-Allow-Origin", origin);
|
|
1735
2665
|
newResponse.headers.set("Access-Control-Allow-Methods", "*");
|
|
1736
2666
|
newResponse.headers.set("Access-Control-Allow-Headers", "Authorization, *");
|
|
2667
|
+
appendHeaderValue(newResponse.headers, "Vary", "Origin");
|
|
1737
2668
|
const exposedHeaders = (newResponse.headers.get("Access-Control-Expose-Headers") ?? "").split(",").map((name) => name.trim()).filter(Boolean);
|
|
1738
2669
|
for (const requiredHeader of ["WWW-Authenticate", "Retry-After"]) if (!exposedHeaders.some((name) => name.toLowerCase() === requiredHeader.toLowerCase())) exposedHeaders.push(requiredHeader);
|
|
1739
2670
|
newResponse.headers.set("Access-Control-Expose-Headers", exposedHeaders.join(", "));
|
|
@@ -1755,9 +2686,10 @@ var OAuthProviderImpl = class {
|
|
|
1755
2686
|
const grantTypesSupported = this.serverCapabilities.grantTypes;
|
|
1756
2687
|
const authorizationGrantProfilesSupported = this.options.enterpriseManagedAuthorization ? [EMA_ID_JAG_GRANT_PROFILE] : [];
|
|
1757
2688
|
const metadata = {
|
|
1758
|
-
issuer:
|
|
2689
|
+
issuer: this.getAuthorizationServerIssuer(requestUrl),
|
|
1759
2690
|
authorization_endpoint: authorizeEndpoint,
|
|
1760
2691
|
token_endpoint: tokenEndpoint,
|
|
2692
|
+
...this.resourceServers.length > 0 ? { protected_resources: this.resourceServers.map((server) => server.resourceMetadata.resource) } : {},
|
|
1761
2693
|
registration_endpoint: registrationEndpoint,
|
|
1762
2694
|
scopes_supported: this.options.scopesSupported,
|
|
1763
2695
|
response_types_supported: responseTypesSupported,
|
|
@@ -1773,8 +2705,8 @@ var OAuthProviderImpl = class {
|
|
|
1773
2705
|
return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
|
|
1774
2706
|
}
|
|
1775
2707
|
/** Scopes that are baseline requirements of the protected resource itself. */
|
|
1776
|
-
getProtectedResourceScopes() {
|
|
1777
|
-
return this.normalizeProtectedResourceScopes(
|
|
2708
|
+
getProtectedResourceScopes(resourceServer) {
|
|
2709
|
+
return this.normalizeProtectedResourceScopes(resourceServer.resourceMetadata.scopes_supported ?? []);
|
|
1778
2710
|
}
|
|
1779
2711
|
/** Deduplicate resource-facing scopes and remove authorization-server-only capabilities. */
|
|
1780
2712
|
normalizeProtectedResourceScopes(scopes) {
|
|
@@ -1786,18 +2718,17 @@ var OAuthProviderImpl = class {
|
|
|
1786
2718
|
* @param requestUrl - The URL of the incoming request
|
|
1787
2719
|
* @returns Response with protected resource metadata
|
|
1788
2720
|
*/
|
|
1789
|
-
handleProtectedResourceMetadata(requestUrl) {
|
|
1790
|
-
const rm =
|
|
1791
|
-
const
|
|
1792
|
-
const
|
|
1793
|
-
const resourceScopes = this.getProtectedResourceScopes();
|
|
2721
|
+
handleProtectedResourceMetadata(requestUrl, resourceServer) {
|
|
2722
|
+
const rm = resourceServer.resourceMetadata;
|
|
2723
|
+
const authorizationServer = this.getAuthorizationServerIssuer(requestUrl);
|
|
2724
|
+
const resourceScopes = this.getProtectedResourceScopes(resourceServer);
|
|
1794
2725
|
const metadata = {
|
|
1795
|
-
resource: rm
|
|
1796
|
-
authorization_servers: rm
|
|
2726
|
+
resource: rm.resource,
|
|
2727
|
+
authorization_servers: rm.authorization_servers ?? [authorizationServer],
|
|
1797
2728
|
...resourceScopes.length > 0 ? { scopes_supported: resourceScopes } : {},
|
|
1798
|
-
bearer_methods_supported: rm
|
|
2729
|
+
bearer_methods_supported: rm.bearer_methods_supported ?? ["header"]
|
|
1799
2730
|
};
|
|
1800
|
-
if (rm
|
|
2731
|
+
if (rm.resource_name) metadata.resource_name = rm.resource_name;
|
|
1801
2732
|
return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
|
|
1802
2733
|
}
|
|
1803
2734
|
/**
|
|
@@ -1811,11 +2742,23 @@ var OAuthProviderImpl = class {
|
|
|
1811
2742
|
async handleTokenRequest(body, clientInfo, env, requestUrl, request) {
|
|
1812
2743
|
try {
|
|
1813
2744
|
const grantType = body.grant_type;
|
|
2745
|
+
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" }, {
|
|
2746
|
+
category: "token-endpoint-request",
|
|
2747
|
+
reason: "grant_type_not_supported"
|
|
2748
|
+
});
|
|
2749
|
+
if (grantType !== GrantType.JWT_BEARER && Array.isArray(clientInfo.grantTypes)) {
|
|
2750
|
+
const registered = clientInfo.grantTypes;
|
|
2751
|
+
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" }, {
|
|
2752
|
+
category: "token-endpoint-request",
|
|
2753
|
+
reason: "grant_type_not_registered"
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2756
|
+
this.validateTokenRequestResourceIndicator(body.resource);
|
|
1814
2757
|
if (grantType === GrantType.AUTHORIZATION_CODE) return await this.handleAuthorizationCodeGrant(body, clientInfo, env);
|
|
1815
2758
|
else if (grantType === GrantType.REFRESH_TOKEN) return await this.handleRefreshTokenGrant(body, clientInfo, env);
|
|
1816
2759
|
else if (grantType === GrantType.TOKEN_EXCHANGE && this.options.allowTokenExchangeGrant) return await this.handleTokenExchangeGrant(body, clientInfo, env);
|
|
1817
2760
|
else if (grantType === GrantType.JWT_BEARER) return await this.handleJwtBearerGrant(body, clientInfo, env, requestUrl, request);
|
|
1818
|
-
|
|
2761
|
+
throw new Error("Unreachable supported grant type");
|
|
1819
2762
|
} catch (error) {
|
|
1820
2763
|
const response = this.createOAuthErrorResponse(error);
|
|
1821
2764
|
if (response) return response;
|
|
@@ -1829,9 +2772,31 @@ var OAuthProviderImpl = class {
|
|
|
1829
2772
|
* token issuance or `tokenExchangeCallback`. Anything else is re-thrown so
|
|
1830
2773
|
* unexpected failures still surface as 500s.
|
|
1831
2774
|
*/
|
|
2775
|
+
/**
|
|
2776
|
+
* Run `tokenExchangeCallback`. An `invalid_grant` it throws means the grant can never work again
|
|
2777
|
+
* (RFC 6749 §5.2: invalid, expired, or revoked; transient failures are `temporarily_unavailable`),
|
|
2778
|
+
* so the grant it ran for is revoked, with its tokens, before the error answers. A failed
|
|
2779
|
+
* revocation is logged and the callback's error still answers the request.
|
|
2780
|
+
*/
|
|
2781
|
+
async callTokenExchangeCallback(options, env) {
|
|
2782
|
+
try {
|
|
2783
|
+
return await Promise.resolve(this.options.tokenExchangeCallback(options));
|
|
2784
|
+
} catch (error) {
|
|
2785
|
+
if (error instanceof OAuthError && error.code === "invalid_grant") try {
|
|
2786
|
+
await this.createOAuthHelpers(env).revokeGrant(options.grantId, options.userId);
|
|
2787
|
+
} catch (revokeError) {
|
|
2788
|
+
console.warn(`Failed to revoke grant ${options.grantId} after tokenExchangeCallback answered invalid_grant:`, revokeError);
|
|
2789
|
+
}
|
|
2790
|
+
throw error;
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
1832
2793
|
createOAuthErrorResponse(error) {
|
|
1833
2794
|
if (!(error instanceof OAuthError)) return void 0;
|
|
1834
|
-
return this.createErrorResponse(error.code, error.options
|
|
2795
|
+
return this.createErrorResponse(error.code, error.options, error.options.internal ?? {
|
|
2796
|
+
category: "token-exchange-callback",
|
|
2797
|
+
reason: "callback_error",
|
|
2798
|
+
detail: error
|
|
2799
|
+
});
|
|
1835
2800
|
}
|
|
1836
2801
|
/**
|
|
1837
2802
|
* Build a structured protected-resource response from an external-token error.
|
|
@@ -1840,7 +2805,7 @@ var OAuthProviderImpl = class {
|
|
|
1840
2805
|
* bearer failures receive an RFC 6750 / RFC 9728 challenge unless the caller
|
|
1841
2806
|
* supplied one. Other errors retain the pre-existing behavior and are re-thrown.
|
|
1842
2807
|
*/
|
|
1843
|
-
createExternalTokenErrorResponse(error, resourceMetadataUrl) {
|
|
2808
|
+
createExternalTokenErrorResponse(error, resourceMetadataUrl, resourceServer) {
|
|
1844
2809
|
if (!(error instanceof ExternalTokenError)) return void 0;
|
|
1845
2810
|
const headers = error.headers ?? {};
|
|
1846
2811
|
const hasChallenge = Object.keys(headers).some((name) => name.toLowerCase() === "www-authenticate");
|
|
@@ -1851,13 +2816,17 @@ var OAuthProviderImpl = class {
|
|
|
1851
2816
|
if (requiredScopes.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("ExternalTokenError requiredScopes must contain valid OAuth scope tokens");
|
|
1852
2817
|
challengeHeaders = {
|
|
1853
2818
|
...headers,
|
|
1854
|
-
"WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, error.code, void 0, requiredScopes)
|
|
2819
|
+
"WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, error.code, void 0, requiredScopes, resourceServer)
|
|
1855
2820
|
};
|
|
1856
2821
|
}
|
|
1857
2822
|
return this.createErrorResponse(error.code, {
|
|
1858
2823
|
description: error.description,
|
|
1859
2824
|
statusCode: error.statusCode,
|
|
1860
2825
|
headers: challengeHeaders ?? headers
|
|
2826
|
+
}, {
|
|
2827
|
+
category: "protected-resource",
|
|
2828
|
+
reason: "resolver_rejected",
|
|
2829
|
+
detail: error
|
|
1861
2830
|
});
|
|
1862
2831
|
}
|
|
1863
2832
|
/**
|
|
@@ -1872,34 +2841,72 @@ var OAuthProviderImpl = class {
|
|
|
1872
2841
|
const code = body.code;
|
|
1873
2842
|
const redirectUri = body.redirect_uri;
|
|
1874
2843
|
const codeVerifier = body.code_verifier;
|
|
1875
|
-
if (!code) return this.createErrorResponse("invalid_request", { description: "Authorization code is required" }
|
|
2844
|
+
if (!code) return this.createErrorResponse("invalid_request", { description: "Authorization code is required" }, {
|
|
2845
|
+
category: "authorization-code-grant",
|
|
2846
|
+
reason: "code_missing"
|
|
2847
|
+
});
|
|
1876
2848
|
const codeParts = code.split(":");
|
|
1877
|
-
if (codeParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code format" }
|
|
2849
|
+
if (codeParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code format" }, {
|
|
2850
|
+
category: "authorization-code-grant",
|
|
2851
|
+
reason: "code_malformed"
|
|
2852
|
+
});
|
|
1878
2853
|
const [userId, grantId, _] = codeParts;
|
|
1879
2854
|
const grantKey = `grant:${userId}:${grantId}`;
|
|
1880
2855
|
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" }
|
|
2856
|
+
if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found or authorization code expired" }, {
|
|
2857
|
+
category: "authorization-code-grant",
|
|
2858
|
+
reason: "grant_not_found"
|
|
2859
|
+
});
|
|
1882
2860
|
const codeHash = await hashSecret(code);
|
|
1883
|
-
if (!grantData.authCodeId || codeHash !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code" }
|
|
1884
|
-
|
|
2861
|
+
if (!grantData.authCodeId || codeHash !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code" }, {
|
|
2862
|
+
category: "authorization-code-grant",
|
|
2863
|
+
reason: "code_mismatch"
|
|
2864
|
+
});
|
|
2865
|
+
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" }, {
|
|
2866
|
+
category: "authorization-code-grant",
|
|
2867
|
+
reason: "client_mismatch"
|
|
2868
|
+
});
|
|
1885
2869
|
if (!grantData.authCodeWrappedKey) {
|
|
1886
2870
|
try {
|
|
1887
2871
|
await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
|
|
1888
2872
|
} catch {}
|
|
1889
|
-
return this.createErrorResponse("invalid_grant", { description: "Authorization code already used" }
|
|
2873
|
+
return this.createErrorResponse("invalid_grant", { description: "Authorization code already used" }, {
|
|
2874
|
+
category: "authorization-code-grant",
|
|
2875
|
+
reason: "code_replayed"
|
|
2876
|
+
});
|
|
1890
2877
|
}
|
|
1891
2878
|
let codeChallengeMethod;
|
|
1892
2879
|
try {
|
|
1893
2880
|
codeChallengeMethod = grantData.codeChallenge ? validatePkceCodeChallengeMethod(this.serverCapabilities, grantData.codeChallengeMethod) : normalizePkceCodeChallengeMethod(grantData.codeChallengeMethod);
|
|
1894
2881
|
} catch (error) {
|
|
1895
|
-
return this.createErrorResponse("invalid_grant", { description: error instanceof Error ? error.message : "Invalid PKCE code_challenge_method" }
|
|
2882
|
+
return this.createErrorResponse("invalid_grant", { description: error instanceof Error ? error.message : "Invalid PKCE code_challenge_method" }, {
|
|
2883
|
+
category: "authorization-code-grant",
|
|
2884
|
+
reason: "pkce_method_invalid",
|
|
2885
|
+
detail: error
|
|
2886
|
+
});
|
|
1896
2887
|
}
|
|
1897
2888
|
const isPkceEnabled = !!grantData.codeChallenge;
|
|
1898
|
-
if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" }
|
|
1899
|
-
|
|
1900
|
-
|
|
2889
|
+
if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" }, {
|
|
2890
|
+
category: "authorization-code-grant",
|
|
2891
|
+
reason: "redirect_uri_missing"
|
|
2892
|
+
});
|
|
2893
|
+
if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", { description: "Invalid redirect URI" }, {
|
|
2894
|
+
category: "authorization-code-grant",
|
|
2895
|
+
reason: "redirect_uri_invalid"
|
|
2896
|
+
});
|
|
2897
|
+
if (redirectUri && grantData.redirectUri !== void 0 && redirectUri !== grantData.redirectUri) return this.createErrorResponse("invalid_grant", { description: "redirect_uri does not match the authorization request" }, {
|
|
2898
|
+
category: "authorization-code-grant",
|
|
2899
|
+
reason: "redirect_uri_mismatch"
|
|
2900
|
+
});
|
|
2901
|
+
if (!isPkceEnabled && codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier provided for a flow that did not use PKCE" }, {
|
|
2902
|
+
category: "authorization-code-grant",
|
|
2903
|
+
reason: "unexpected_code_verifier"
|
|
2904
|
+
});
|
|
1901
2905
|
if (isPkceEnabled) {
|
|
1902
|
-
if (!codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier is required for PKCE" }
|
|
2906
|
+
if (!codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier is required for PKCE" }, {
|
|
2907
|
+
category: "authorization-code-grant",
|
|
2908
|
+
reason: "code_verifier_missing"
|
|
2909
|
+
});
|
|
1903
2910
|
let calculatedChallenge;
|
|
1904
2911
|
if (codeChallengeMethod === "S256") {
|
|
1905
2912
|
const data = new TextEncoder().encode(codeVerifier);
|
|
@@ -1907,9 +2914,13 @@ var OAuthProviderImpl = class {
|
|
|
1907
2914
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
1908
2915
|
calculatedChallenge = base64UrlEncode(String.fromCharCode(...hashArray));
|
|
1909
2916
|
} else calculatedChallenge = codeVerifier;
|
|
1910
|
-
if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" }
|
|
2917
|
+
if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" }, {
|
|
2918
|
+
category: "authorization-code-grant",
|
|
2919
|
+
reason: "code_verifier_mismatch"
|
|
2920
|
+
});
|
|
1911
2921
|
}
|
|
1912
|
-
const
|
|
2922
|
+
const resourceResolution = this.resolveTokenResource(body.resource, grantData);
|
|
2923
|
+
const audience = resourceResolution.audience;
|
|
1913
2924
|
let accessTokenTTL = this.options.accessTokenTTL;
|
|
1914
2925
|
let refreshTokenTTL = this.options.refreshTokenTTL;
|
|
1915
2926
|
const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
|
|
@@ -1924,13 +2935,15 @@ var OAuthProviderImpl = class {
|
|
|
1924
2935
|
const callbackOptions = {
|
|
1925
2936
|
grantType: GrantType.AUTHORIZATION_CODE,
|
|
1926
2937
|
clientId: clientInfo.clientId,
|
|
2938
|
+
subjectClientId: grantData.clientId,
|
|
1927
2939
|
userId,
|
|
1928
2940
|
grantId,
|
|
1929
2941
|
scope: grantData.scope,
|
|
1930
2942
|
requestedScope: tokenScopes,
|
|
2943
|
+
resource: audience,
|
|
1931
2944
|
props: decryptedProps
|
|
1932
2945
|
};
|
|
1933
|
-
const callbackResult = await
|
|
2946
|
+
const callbackResult = await this.callTokenExchangeCallback(callbackOptions, env);
|
|
1934
2947
|
if (callbackResult) {
|
|
1935
2948
|
if (callbackResult.newProps) {
|
|
1936
2949
|
grantProps = callbackResult.newProps;
|
|
@@ -1939,6 +2952,10 @@ var OAuthProviderImpl = class {
|
|
|
1939
2952
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
1940
2953
|
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
|
|
1941
2954
|
if ("refreshTokenTTL" in callbackResult) refreshTokenTTL = callbackResult.refreshTokenTTL;
|
|
2955
|
+
if (callbackResult.refreshTokenIdleTTL !== void 0) return this.createErrorResponse("invalid_request", { description: "refreshTokenIdleTTL is only honored during refresh token exchange" }, {
|
|
2956
|
+
category: "authorization-code-grant",
|
|
2957
|
+
reason: "refresh_token_idle_ttl_wrong_grant"
|
|
2958
|
+
});
|
|
1942
2959
|
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
1943
2960
|
}
|
|
1944
2961
|
const grantResult = await encryptProps(grantProps);
|
|
@@ -1953,6 +2970,10 @@ var OAuthProviderImpl = class {
|
|
|
1953
2970
|
accessTokenEncryptionKey = grantEncryptionKey;
|
|
1954
2971
|
}
|
|
1955
2972
|
}
|
|
2973
|
+
if (!isValidAccessTokenTTL(accessTokenTTL)) return this.createErrorResponse("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" }, {
|
|
2974
|
+
category: "authorization-code-grant",
|
|
2975
|
+
reason: "requested_ttl_too_short"
|
|
2976
|
+
});
|
|
1956
2977
|
const now = Math.floor(Date.now() / 1e3);
|
|
1957
2978
|
const useRefreshToken = refreshTokenTTL !== 0;
|
|
1958
2979
|
delete grantData.codeChallenge;
|
|
@@ -1970,6 +2991,7 @@ var OAuthProviderImpl = class {
|
|
|
1970
2991
|
grantData.previousRefreshTokenWrappedKey = void 0;
|
|
1971
2992
|
grantData.expiresAt = expiresAt;
|
|
1972
2993
|
}
|
|
2994
|
+
if (resourceResolution.grantResourceBackfill) grantData.resource = resourceResolution.grantResourceBackfill;
|
|
1973
2995
|
await this.saveGrantWithTTL(env, grantKey, grantData, now);
|
|
1974
2996
|
const tokenResponse = {
|
|
1975
2997
|
access_token: await this.createAccessToken({
|
|
@@ -1985,10 +3007,10 @@ var OAuthProviderImpl = class {
|
|
|
1985
3007
|
}),
|
|
1986
3008
|
token_type: "bearer",
|
|
1987
3009
|
expires_in: accessTokenTTL,
|
|
1988
|
-
scope: tokenScopes.join(" ")
|
|
3010
|
+
scope: tokenScopes.join(" "),
|
|
3011
|
+
resource: audience
|
|
1989
3012
|
};
|
|
1990
3013
|
if (refreshToken) tokenResponse.refresh_token = refreshToken;
|
|
1991
|
-
if (audience) tokenResponse.resource = audience;
|
|
1992
3014
|
return new Response(JSON.stringify(tokenResponse), { headers: {
|
|
1993
3015
|
"Content-Type": "application/json",
|
|
1994
3016
|
...NO_CACHE_HEADERS
|
|
@@ -2004,26 +3026,46 @@ var OAuthProviderImpl = class {
|
|
|
2004
3026
|
*/
|
|
2005
3027
|
async handleRefreshTokenGrant(body, clientInfo, env) {
|
|
2006
3028
|
const refreshToken = body.refresh_token;
|
|
2007
|
-
if (!refreshToken) return this.createErrorResponse("invalid_request", { description: "Refresh token is required" }
|
|
3029
|
+
if (!refreshToken) return this.createErrorResponse("invalid_request", { description: "Refresh token is required" }, {
|
|
3030
|
+
category: "refresh-token-grant",
|
|
3031
|
+
reason: "refresh_token_missing"
|
|
3032
|
+
});
|
|
2008
3033
|
const tokenParts = refreshToken.split(":");
|
|
2009
|
-
if (tokenParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid token format" }
|
|
3034
|
+
if (tokenParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid token format" }, {
|
|
3035
|
+
category: "refresh-token-grant",
|
|
3036
|
+
reason: "refresh_token_malformed"
|
|
3037
|
+
});
|
|
2010
3038
|
const [userId, grantId, _] = tokenParts;
|
|
2011
3039
|
const providedTokenHash = await generateTokenId(refreshToken);
|
|
2012
3040
|
const grantKey = `grant:${userId}:${grantId}`;
|
|
2013
3041
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
2014
|
-
if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found" }
|
|
3042
|
+
if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found" }, {
|
|
3043
|
+
category: "refresh-token-grant",
|
|
3044
|
+
reason: "grant_not_found"
|
|
3045
|
+
});
|
|
2015
3046
|
const isCurrentToken = grantData.refreshTokenId === providedTokenHash;
|
|
2016
3047
|
const isPreviousToken = grantData.previousRefreshTokenId === providedTokenHash;
|
|
2017
|
-
if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", { description: "Invalid refresh token" }
|
|
2018
|
-
|
|
3048
|
+
if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", { description: "Invalid refresh token" }, {
|
|
3049
|
+
category: "refresh-token-grant",
|
|
3050
|
+
reason: "refresh_token_mismatch"
|
|
3051
|
+
});
|
|
3052
|
+
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" }, {
|
|
3053
|
+
category: "refresh-token-grant",
|
|
3054
|
+
reason: "client_mismatch"
|
|
3055
|
+
});
|
|
2019
3056
|
if (grantData.expiresAt !== void 0) {
|
|
2020
3057
|
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" }
|
|
3058
|
+
if (grantData.expiresAt - now$1 < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" }, {
|
|
3059
|
+
category: "refresh-token-grant",
|
|
3060
|
+
reason: "refresh_token_expired"
|
|
3061
|
+
});
|
|
2022
3062
|
}
|
|
2023
|
-
const
|
|
3063
|
+
const resourceResolution = this.resolveTokenResource(body.resource, grantData);
|
|
3064
|
+
const audience = resourceResolution.audience;
|
|
2024
3065
|
const newAccessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
2025
3066
|
const accessTokenId = await generateTokenId(newAccessToken);
|
|
2026
3067
|
let accessTokenTTL = this.options.accessTokenTTL;
|
|
3068
|
+
let refreshTokenIdleTTL = this.options.refreshTokenIdleTTL;
|
|
2027
3069
|
let wrappedKeyToUse;
|
|
2028
3070
|
if (isCurrentToken) wrappedKeyToUse = grantData.refreshTokenWrappedKey;
|
|
2029
3071
|
else wrappedKeyToUse = grantData.previousRefreshTokenWrappedKey;
|
|
@@ -2040,13 +3082,15 @@ var OAuthProviderImpl = class {
|
|
|
2040
3082
|
const callbackOptions = {
|
|
2041
3083
|
grantType: GrantType.REFRESH_TOKEN,
|
|
2042
3084
|
clientId: clientInfo.clientId,
|
|
3085
|
+
subjectClientId: grantData.clientId,
|
|
2043
3086
|
userId,
|
|
2044
3087
|
grantId,
|
|
2045
3088
|
scope: grantData.scope,
|
|
2046
3089
|
requestedScope: tokenScopes,
|
|
3090
|
+
resource: audience,
|
|
2047
3091
|
props: decryptedProps
|
|
2048
3092
|
};
|
|
2049
|
-
const callbackResult = await
|
|
3093
|
+
const callbackResult = await this.callTokenExchangeCallback(callbackOptions, env);
|
|
2050
3094
|
if (callbackResult) {
|
|
2051
3095
|
if (callbackResult.newProps) {
|
|
2052
3096
|
grantProps = callbackResult.newProps;
|
|
@@ -2055,7 +3099,17 @@ var OAuthProviderImpl = class {
|
|
|
2055
3099
|
}
|
|
2056
3100
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
2057
3101
|
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" }
|
|
3102
|
+
if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", { description: "refreshTokenTTL cannot be changed during refresh token exchange" }, {
|
|
3103
|
+
category: "refresh-token-grant",
|
|
3104
|
+
reason: "refresh_token_ttl_immutable"
|
|
3105
|
+
});
|
|
3106
|
+
if (callbackResult.refreshTokenIdleTTL !== void 0) {
|
|
3107
|
+
if (!isValidAccessTokenTTL(callbackResult.refreshTokenIdleTTL)) return this.createErrorResponse("invalid_request", { description: `refreshTokenIdleTTL must be an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds` }, {
|
|
3108
|
+
category: "refresh-token-grant",
|
|
3109
|
+
reason: "refresh_token_idle_ttl_invalid"
|
|
3110
|
+
});
|
|
3111
|
+
refreshTokenIdleTTL = callbackResult.refreshTokenIdleTTL;
|
|
3112
|
+
}
|
|
2059
3113
|
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
2060
3114
|
}
|
|
2061
3115
|
if (grantPropsChanged) {
|
|
@@ -2076,12 +3130,19 @@ var OAuthProviderImpl = class {
|
|
|
2076
3130
|
}
|
|
2077
3131
|
}
|
|
2078
3132
|
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" }
|
|
3133
|
+
if (grantData.expiresAt !== void 0 && grantData.expiresAt - now < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" }, {
|
|
3134
|
+
category: "refresh-token-grant",
|
|
3135
|
+
reason: "refresh_token_expired"
|
|
3136
|
+
});
|
|
3137
|
+
if (refreshTokenIdleTTL !== void 0) grantData.expiresAt = now + refreshTokenIdleTTL;
|
|
2080
3138
|
if (grantData.expiresAt !== void 0) {
|
|
2081
3139
|
const remainingRefreshTokenLifetime = grantData.expiresAt - now;
|
|
2082
3140
|
if (remainingRefreshTokenLifetime > 0) accessTokenTTL = Math.min(accessTokenTTL, remainingRefreshTokenLifetime);
|
|
2083
3141
|
}
|
|
2084
|
-
if (accessTokenTTL
|
|
3142
|
+
if (!isValidAccessTokenTTL(accessTokenTTL)) return this.createErrorResponse("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" }, {
|
|
3143
|
+
category: "refresh-token-grant",
|
|
3144
|
+
reason: "requested_ttl_too_short"
|
|
3145
|
+
});
|
|
2085
3146
|
const accessTokenExpiresAt = now + accessTokenTTL;
|
|
2086
3147
|
const accessTokenWrappedKey = await wrapKeyWithToken(newAccessToken, accessTokenEncryptionKey);
|
|
2087
3148
|
const newRefreshToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
@@ -2091,6 +3152,7 @@ var OAuthProviderImpl = class {
|
|
|
2091
3152
|
grantData.previousRefreshTokenWrappedKey = wrappedKeyToUse;
|
|
2092
3153
|
grantData.refreshTokenId = newRefreshTokenId;
|
|
2093
3154
|
grantData.refreshTokenWrappedKey = newRefreshTokenWrappedKey;
|
|
3155
|
+
if (resourceResolution.grantResourceBackfill) grantData.resource = resourceResolution.grantResourceBackfill;
|
|
2094
3156
|
await this.saveGrantWithTTL(env, grantKey, grantData, now);
|
|
2095
3157
|
const accessTokenData = {
|
|
2096
3158
|
id: accessTokenId,
|
|
@@ -2118,9 +3180,9 @@ var OAuthProviderImpl = class {
|
|
|
2118
3180
|
token_type: "bearer",
|
|
2119
3181
|
expires_in: accessTokenTTL,
|
|
2120
3182
|
refresh_token: newRefreshToken,
|
|
2121
|
-
scope: tokenScopes.join(" ")
|
|
3183
|
+
scope: tokenScopes.join(" "),
|
|
3184
|
+
resource: audience
|
|
2122
3185
|
};
|
|
2123
|
-
if (audience) tokenResponse.resource = audience;
|
|
2124
3186
|
return new Response(JSON.stringify(tokenResponse), { headers: {
|
|
2125
3187
|
"Content-Type": "application/json",
|
|
2126
3188
|
...NO_CACHE_HEADERS
|
|
@@ -2134,7 +3196,7 @@ var OAuthProviderImpl = class {
|
|
|
2134
3196
|
* module-private.
|
|
2135
3197
|
* @param subjectToken - The subject token to exchange
|
|
2136
3198
|
* @param requestedScopes - Optional requested scopes, limited to the subject token's scopes
|
|
2137
|
-
* @param requestedResource - Optional resource/audience
|
|
3199
|
+
* @param requestedResource - Optional resource/audience; when present, it must match the configured canonical resource
|
|
2138
3200
|
* @param expiresIn - Optional TTL override in seconds
|
|
2139
3201
|
* @param clientInfo - The client making the exchange request
|
|
2140
3202
|
* @param env - Cloudflare Worker environment variables
|
|
@@ -2143,24 +3205,61 @@ var OAuthProviderImpl = class {
|
|
|
2143
3205
|
*/
|
|
2144
3206
|
async exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env) {
|
|
2145
3207
|
const tokenSummary = await this.unwrapToken(subjectToken, env);
|
|
2146
|
-
if (!tokenSummary) throw new OAuthError("
|
|
3208
|
+
if (!tokenSummary) throw new OAuthError("invalid_request", {
|
|
3209
|
+
description: "Invalid or expired subject token",
|
|
3210
|
+
internal: {
|
|
3211
|
+
category: "token-exchange-grant",
|
|
3212
|
+
reason: "subject_token_invalid"
|
|
3213
|
+
}
|
|
3214
|
+
});
|
|
2147
3215
|
const grantKey = `grant:${tokenSummary.userId}:${tokenSummary.grantId}`;
|
|
2148
3216
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
2149
|
-
if (!grantData) throw new OAuthError("
|
|
3217
|
+
if (!grantData) throw new OAuthError("invalid_request", {
|
|
3218
|
+
description: "Grant not found",
|
|
3219
|
+
internal: {
|
|
3220
|
+
category: "token-exchange-grant",
|
|
3221
|
+
reason: "grant_not_found"
|
|
3222
|
+
}
|
|
3223
|
+
});
|
|
3224
|
+
const crossClientExchange = grantData.clientId !== clientInfo.clientId;
|
|
3225
|
+
const crossClientRejection = () => new OAuthError("invalid_request", {
|
|
3226
|
+
description: "The subject token was issued to a different client",
|
|
3227
|
+
internal: {
|
|
3228
|
+
category: "token-exchange-grant",
|
|
3229
|
+
reason: "cross_client_subject_token"
|
|
3230
|
+
}
|
|
3231
|
+
});
|
|
3232
|
+
if (crossClientExchange && !this.options.tokenExchangeCallback) throw crossClientRejection();
|
|
2150
3233
|
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);
|
|
3234
|
+
const newAudience = this.resolveTokenExchangeResource(requestedResource, tokenSummary.audience);
|
|
2154
3235
|
const now = Math.floor(Date.now() / 1e3);
|
|
2155
3236
|
const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
|
|
2156
|
-
if (subjectTokenRemainingLifetime < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("
|
|
3237
|
+
if (subjectTokenRemainingLifetime < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_request", {
|
|
3238
|
+
description: "Subject token is too close to expiry to exchange",
|
|
3239
|
+
internal: {
|
|
3240
|
+
category: "token-exchange-grant",
|
|
3241
|
+
reason: "subject_token_near_expiry"
|
|
3242
|
+
}
|
|
3243
|
+
});
|
|
2157
3244
|
let accessTokenTTL = this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL;
|
|
2158
3245
|
if (expiresIn !== void 0) {
|
|
2159
|
-
if (expiresIn <= 0) throw new OAuthError("invalid_request", {
|
|
3246
|
+
if (expiresIn <= 0) throw new OAuthError("invalid_request", {
|
|
3247
|
+
description: "Invalid expires_in parameter",
|
|
3248
|
+
internal: {
|
|
3249
|
+
category: "token-exchange-grant",
|
|
3250
|
+
reason: "expires_in_invalid"
|
|
3251
|
+
}
|
|
3252
|
+
});
|
|
2160
3253
|
accessTokenTTL = Math.min(expiresIn, subjectTokenRemainingLifetime);
|
|
2161
3254
|
} else accessTokenTTL = Math.min(accessTokenTTL, subjectTokenRemainingLifetime);
|
|
2162
3255
|
const subjectTokenData = await env.OAUTH_KV.get(`token:${tokenSummary.userId}:${tokenSummary.grantId}:${tokenSummary.id}`, { type: "json" });
|
|
2163
|
-
if (!subjectTokenData) throw new OAuthError("
|
|
3256
|
+
if (!subjectTokenData) throw new OAuthError("invalid_request", {
|
|
3257
|
+
description: "Subject token data not found",
|
|
3258
|
+
internal: {
|
|
3259
|
+
category: "token-exchange-grant",
|
|
3260
|
+
reason: "subject_token_data_missing"
|
|
3261
|
+
}
|
|
3262
|
+
});
|
|
2164
3263
|
const encryptionKey = await unwrapKeyWithToken(subjectToken, subjectTokenData.wrappedEncryptionKey);
|
|
2165
3264
|
let accessTokenEncryptionKey = encryptionKey;
|
|
2166
3265
|
let encryptedAccessTokenProps = subjectTokenData.grant.encryptedProps;
|
|
@@ -2169,13 +3268,16 @@ var OAuthProviderImpl = class {
|
|
|
2169
3268
|
const callbackOptions = {
|
|
2170
3269
|
grantType: GrantType.TOKEN_EXCHANGE,
|
|
2171
3270
|
clientId: clientInfo.clientId,
|
|
3271
|
+
subjectClientId: grantData.clientId,
|
|
2172
3272
|
userId: tokenSummary.userId,
|
|
2173
3273
|
grantId: tokenSummary.grantId,
|
|
2174
3274
|
scope: tokenSummary.grant.scope,
|
|
2175
3275
|
requestedScope: tokenScopes,
|
|
3276
|
+
resource: newAudience,
|
|
2176
3277
|
props: decryptedProps
|
|
2177
3278
|
};
|
|
2178
|
-
const callbackResult = await
|
|
3279
|
+
const callbackResult = await this.callTokenExchangeCallback(callbackOptions, env);
|
|
3280
|
+
if (crossClientExchange && callbackResult?.allowCrossClientExchange !== true) throw crossClientRejection();
|
|
2179
3281
|
if (callbackResult) {
|
|
2180
3282
|
let accessTokenProps = decryptedProps;
|
|
2181
3283
|
if (callbackResult.newProps) {
|
|
@@ -2183,6 +3285,13 @@ var OAuthProviderImpl = class {
|
|
|
2183
3285
|
}
|
|
2184
3286
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
2185
3287
|
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = Math.min(callbackResult.accessTokenTTL, subjectTokenRemainingLifetime);
|
|
3288
|
+
if (callbackResult.refreshTokenIdleTTL !== void 0) throw new OAuthError("invalid_request", {
|
|
3289
|
+
description: "refreshTokenIdleTTL is only honored during refresh token exchange",
|
|
3290
|
+
internal: {
|
|
3291
|
+
category: "token-exchange-grant",
|
|
3292
|
+
reason: "refresh_token_idle_ttl_wrong_grant"
|
|
3293
|
+
}
|
|
3294
|
+
});
|
|
2186
3295
|
if (accessTokenProps !== decryptedProps) {
|
|
2187
3296
|
const tokenResult = await encryptProps(accessTokenProps);
|
|
2188
3297
|
encryptedAccessTokenProps = tokenResult.encryptedData;
|
|
@@ -2191,8 +3300,14 @@ var OAuthProviderImpl = class {
|
|
|
2191
3300
|
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, tokenSummary.scope);
|
|
2192
3301
|
}
|
|
2193
3302
|
}
|
|
2194
|
-
if (accessTokenTTL
|
|
2195
|
-
|
|
3303
|
+
if (!isValidAccessTokenTTL(accessTokenTTL)) throw new OAuthError("invalid_request", {
|
|
3304
|
+
description: "Requested token lifetime must be at least 60 seconds",
|
|
3305
|
+
internal: {
|
|
3306
|
+
category: "token-exchange-grant",
|
|
3307
|
+
reason: "requested_ttl_too_short"
|
|
3308
|
+
}
|
|
3309
|
+
});
|
|
3310
|
+
return {
|
|
2196
3311
|
access_token: await this.createAccessToken({
|
|
2197
3312
|
userId: tokenSummary.userId,
|
|
2198
3313
|
grantId: tokenSummary.grantId,
|
|
@@ -2207,10 +3322,9 @@ var OAuthProviderImpl = class {
|
|
|
2207
3322
|
issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
|
|
2208
3323
|
token_type: "bearer",
|
|
2209
3324
|
expires_in: accessTokenTTL,
|
|
2210
|
-
scope: tokenScopes.join(" ")
|
|
3325
|
+
scope: tokenScopes.join(" "),
|
|
3326
|
+
resource: newAudience
|
|
2211
3327
|
};
|
|
2212
|
-
if (newAudience) tokenResponse.resource = newAudience;
|
|
2213
|
-
return tokenResponse;
|
|
2214
3328
|
}
|
|
2215
3329
|
/**
|
|
2216
3330
|
* Handles OAuth 2.0 token exchange requests (RFC 8693)
|
|
@@ -2226,18 +3340,36 @@ var OAuthProviderImpl = class {
|
|
|
2226
3340
|
const requestedTokenType = body.requested_token_type || "urn:ietf:params:oauth:token-type:access_token";
|
|
2227
3341
|
const requestedScope = body.scope;
|
|
2228
3342
|
const requestedResource = body.resource;
|
|
2229
|
-
if (!subjectToken) return this.createErrorResponse("invalid_request", { description: "subject_token is required" }
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
3343
|
+
if (!subjectToken) return this.createErrorResponse("invalid_request", { description: "subject_token is required" }, {
|
|
3344
|
+
category: "token-exchange-grant",
|
|
3345
|
+
reason: "subject_token_missing"
|
|
3346
|
+
});
|
|
3347
|
+
if (!subjectTokenType) return this.createErrorResponse("invalid_request", { description: "subject_token_type is required" }, {
|
|
3348
|
+
category: "token-exchange-grant",
|
|
3349
|
+
reason: "subject_token_type_missing"
|
|
3350
|
+
});
|
|
3351
|
+
if (subjectTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", { description: "Only access_token subject_token_type is supported" }, {
|
|
3352
|
+
category: "token-exchange-grant",
|
|
3353
|
+
reason: "subject_token_type_unsupported"
|
|
3354
|
+
});
|
|
3355
|
+
if (requestedTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", { description: "Only access_token requested_token_type is supported" }, {
|
|
3356
|
+
category: "token-exchange-grant",
|
|
3357
|
+
reason: "requested_token_type_unsupported"
|
|
3358
|
+
});
|
|
2233
3359
|
let requestedScopes;
|
|
2234
3360
|
if (requestedScope) if (typeof requestedScope === "string") requestedScopes = requestedScope.split(" ").filter(Boolean);
|
|
2235
3361
|
else if (Array.isArray(requestedScope)) requestedScopes = requestedScope;
|
|
2236
|
-
else return this.createErrorResponse("invalid_request", { description: "Invalid scope parameter format" }
|
|
3362
|
+
else return this.createErrorResponse("invalid_request", { description: "Invalid scope parameter format" }, {
|
|
3363
|
+
category: "token-exchange-grant",
|
|
3364
|
+
reason: "scope_malformed"
|
|
3365
|
+
});
|
|
2237
3366
|
let expiresIn;
|
|
2238
3367
|
if (body.expires_in !== void 0) {
|
|
2239
3368
|
const requestedTTL = parseInt(body.expires_in, 10);
|
|
2240
|
-
if (isNaN(requestedTTL) || requestedTTL <= 0) return this.createErrorResponse("invalid_request", { description: "Invalid expires_in parameter" }
|
|
3369
|
+
if (isNaN(requestedTTL) || requestedTTL <= 0) return this.createErrorResponse("invalid_request", { description: "Invalid expires_in parameter" }, {
|
|
3370
|
+
category: "token-exchange-grant",
|
|
3371
|
+
reason: "expires_in_invalid"
|
|
3372
|
+
});
|
|
2241
3373
|
expiresIn = requestedTTL;
|
|
2242
3374
|
}
|
|
2243
3375
|
try {
|
|
@@ -2262,10 +3394,16 @@ var OAuthProviderImpl = class {
|
|
|
2262
3394
|
*/
|
|
2263
3395
|
async handleJwtBearerGrant(body, clientInfo, env, requestUrl, request) {
|
|
2264
3396
|
const enterpriseOptions = this.options.enterpriseManagedAuthorization;
|
|
2265
|
-
if (!enterpriseOptions) return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" }
|
|
3397
|
+
if (!enterpriseOptions) return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" }, {
|
|
3398
|
+
category: "enterprise-managed-authorization",
|
|
3399
|
+
reason: "grant_disabled"
|
|
3400
|
+
});
|
|
2266
3401
|
if (clientInfo.tokenEndpointAuthMethod === "none" && !enterpriseOptions.allowPublicClients) return this.createErrorResponse("invalid_client", {
|
|
2267
3402
|
description: "Enterprise-managed authorization requires client authentication",
|
|
2268
3403
|
statusCode: 401
|
|
3404
|
+
}, {
|
|
3405
|
+
category: "enterprise-managed-authorization",
|
|
3406
|
+
reason: "client_authentication_required"
|
|
2269
3407
|
});
|
|
2270
3408
|
const result = await this.runEmaPipeline({
|
|
2271
3409
|
body,
|
|
@@ -2300,8 +3438,8 @@ var OAuthProviderImpl = class {
|
|
|
2300
3438
|
async runEmaPipeline(args) {
|
|
2301
3439
|
const { body, clientInfo, env, requestUrl, request, enterpriseOptions } = args;
|
|
2302
3440
|
const { jwksProvider, jtiStore } = this;
|
|
2303
|
-
const configuredResource = this.
|
|
2304
|
-
if (!jwksProvider || !jtiStore
|
|
3441
|
+
const configuredResource = this.resolveNewTokenResource(body.resource);
|
|
3442
|
+
if (!jwksProvider || !jtiStore) throw new Error("EMA pipeline invoked without configured adapters");
|
|
2305
3443
|
const now = Math.floor(Date.now() / 1e3);
|
|
2306
3444
|
const parsed = parseIdJag(body.assertion, EMA_MAX_JWT_BYTES);
|
|
2307
3445
|
if (!parsed.ok) return parsed;
|
|
@@ -2331,7 +3469,6 @@ var OAuthProviderImpl = class {
|
|
|
2331
3469
|
expectedAudience: trustedIssuer.value.audience ?? this.getAuthorizationServerIssuer(requestUrl),
|
|
2332
3470
|
clientId: clientInfo.clientId,
|
|
2333
3471
|
configuredResource,
|
|
2334
|
-
matchOriginOnly: !!this.options.resourceMatchOriginOnly,
|
|
2335
3472
|
now,
|
|
2336
3473
|
clockSkewSeconds: enterpriseOptions.clockSkewSeconds ?? EMA_DEFAULT_CLOCK_SKEW_SECONDS,
|
|
2337
3474
|
maxAssertionLifetimeSeconds: enterpriseOptions.maxAssertionLifetimeSeconds ?? EMA_DEFAULT_MAX_ASSERTION_LIFETIME_SECONDS
|
|
@@ -2353,7 +3490,7 @@ var OAuthProviderImpl = class {
|
|
|
2353
3490
|
mapperOutput = await enterpriseOptions.mapClaims({
|
|
2354
3491
|
claims: claims.value.claims,
|
|
2355
3492
|
clientInfo,
|
|
2356
|
-
resource:
|
|
3493
|
+
resource: configuredResource,
|
|
2357
3494
|
requestedScope: requestedScope.value,
|
|
2358
3495
|
request: args.request,
|
|
2359
3496
|
env
|
|
@@ -2379,7 +3516,7 @@ var OAuthProviderImpl = class {
|
|
|
2379
3516
|
mapperProps: mapped.value.props,
|
|
2380
3517
|
mapperMetadata: mapped.value.metadata,
|
|
2381
3518
|
assertionScopes: claims.value.assertionScopes,
|
|
2382
|
-
resource:
|
|
3519
|
+
resource: configuredResource,
|
|
2383
3520
|
accessTokenTTLSeconds: ttl.value,
|
|
2384
3521
|
env,
|
|
2385
3522
|
now: issueNow
|
|
@@ -2478,7 +3615,10 @@ var OAuthProviderImpl = class {
|
|
|
2478
3615
|
async revokeToken(body, clientInfo, env) {
|
|
2479
3616
|
const token = body.token;
|
|
2480
3617
|
const tokenTypeHint = body.token_type_hint;
|
|
2481
|
-
if (!token) return this.createErrorResponse("invalid_request", { description: "Token parameter is required" }
|
|
3618
|
+
if (!token) return this.createErrorResponse("invalid_request", { description: "Token parameter is required" }, {
|
|
3619
|
+
category: "token-revocation",
|
|
3620
|
+
reason: "token_missing"
|
|
3621
|
+
});
|
|
2482
3622
|
const tokenParts = token.split(":");
|
|
2483
3623
|
if (tokenParts.length !== 3) return new Response("", { status: 200 });
|
|
2484
3624
|
const [userId, grantId, _] = tokenParts;
|
|
@@ -2533,14 +3673,24 @@ var OAuthProviderImpl = class {
|
|
|
2533
3673
|
if (!this.options.clientRegistrationEndpoint) return this.createErrorResponse("not_implemented", {
|
|
2534
3674
|
description: "Client registration is not enabled",
|
|
2535
3675
|
statusCode: 501
|
|
3676
|
+
}, {
|
|
3677
|
+
category: "client-registration",
|
|
3678
|
+
reason: "registration_disabled"
|
|
2536
3679
|
});
|
|
2537
3680
|
if (request.method !== "POST") return this.createErrorResponse("invalid_request", {
|
|
2538
3681
|
description: "Method not allowed",
|
|
2539
|
-
statusCode: 405
|
|
3682
|
+
statusCode: 405,
|
|
3683
|
+
headers: { Allow: "POST, OPTIONS" }
|
|
3684
|
+
}, {
|
|
3685
|
+
category: "client-registration",
|
|
3686
|
+
reason: "method_not_allowed"
|
|
2540
3687
|
});
|
|
2541
3688
|
if (parseInt(request.headers.get("Content-Length") || "0", 10) > 1048576) return this.createErrorResponse("invalid_request", {
|
|
2542
3689
|
description: "Request payload too large, must be under 1 MiB",
|
|
2543
3690
|
statusCode: 413
|
|
3691
|
+
}, {
|
|
3692
|
+
category: "client-registration",
|
|
3693
|
+
reason: "declared_payload_too_large"
|
|
2544
3694
|
});
|
|
2545
3695
|
const callbackRequest = request.clone();
|
|
2546
3696
|
let parsedJson;
|
|
@@ -2549,12 +3699,18 @@ var OAuthProviderImpl = class {
|
|
|
2549
3699
|
if (text.length > 1048576) return this.createErrorResponse("invalid_request", {
|
|
2550
3700
|
description: "Request payload too large, must be under 1 MiB",
|
|
2551
3701
|
statusCode: 413
|
|
3702
|
+
}, {
|
|
3703
|
+
category: "client-registration",
|
|
3704
|
+
reason: "payload_too_large"
|
|
2552
3705
|
});
|
|
2553
3706
|
parsedJson = JSON.parse(text);
|
|
2554
3707
|
} catch {
|
|
2555
3708
|
return this.createErrorResponse("invalid_request", {
|
|
2556
3709
|
description: "Invalid JSON payload",
|
|
2557
3710
|
statusCode: 400
|
|
3711
|
+
}, {
|
|
3712
|
+
category: "client-registration",
|
|
3713
|
+
reason: "json_malformed"
|
|
2558
3714
|
});
|
|
2559
3715
|
}
|
|
2560
3716
|
let clientMetadata;
|
|
@@ -2563,11 +3719,18 @@ var OAuthProviderImpl = class {
|
|
|
2563
3719
|
clientMetadata = requireJsonObject(parsedJson);
|
|
2564
3720
|
metadata = resolveDynamicClientRegistrationMetadata(clientMetadata, this.serverCapabilities);
|
|
2565
3721
|
} catch (error) {
|
|
2566
|
-
return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" }
|
|
3722
|
+
return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" }, {
|
|
3723
|
+
category: "client-registration",
|
|
3724
|
+
reason: "metadata_invalid",
|
|
3725
|
+
detail: error
|
|
3726
|
+
});
|
|
2567
3727
|
}
|
|
2568
3728
|
const authMethod = metadata.tokenEndpointAuthMethod;
|
|
2569
3729
|
const isPublicClient = authMethod === "none";
|
|
2570
|
-
if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" }
|
|
3730
|
+
if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" }, {
|
|
3731
|
+
category: "client-registration",
|
|
3732
|
+
reason: "public_client_forbidden"
|
|
3733
|
+
});
|
|
2571
3734
|
const clientId = generateRandomString(16);
|
|
2572
3735
|
let clientSecret;
|
|
2573
3736
|
let hashedSecret;
|
|
@@ -2604,16 +3767,22 @@ var OAuthProviderImpl = class {
|
|
|
2604
3767
|
return this.createErrorResponse("server_error", {
|
|
2605
3768
|
description: error instanceof Error ? error.message : "Client registration callback failed",
|
|
2606
3769
|
statusCode: 500
|
|
3770
|
+
}, {
|
|
3771
|
+
category: "client-registration",
|
|
3772
|
+
reason: "callback_failed",
|
|
3773
|
+
detail: error
|
|
2607
3774
|
});
|
|
2608
3775
|
}
|
|
2609
3776
|
if (callbackResult !== void 0) return this.createErrorResponse(callbackResult.code || "invalid_client_metadata", {
|
|
2610
3777
|
description: callbackResult.description || "Client registration denied",
|
|
2611
3778
|
statusCode: callbackResult.status ?? 400
|
|
3779
|
+
}, {
|
|
3780
|
+
category: "client-registration",
|
|
3781
|
+
reason: "callback_denied",
|
|
3782
|
+
detail: callbackResult
|
|
2612
3783
|
});
|
|
2613
3784
|
}
|
|
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);
|
|
3785
|
+
await this.putRegisteredClient(env, clientInfo, clientInfo.registrationDate);
|
|
2617
3786
|
const response = {
|
|
2618
3787
|
client_id: clientInfo.clientId,
|
|
2619
3788
|
redirect_uris: clientInfo.redirectUris,
|
|
@@ -2652,18 +3821,23 @@ var OAuthProviderImpl = class {
|
|
|
2652
3821
|
* @param ctx - Cloudflare Worker execution context
|
|
2653
3822
|
* @returns Response from the API handler or error
|
|
2654
3823
|
*/
|
|
2655
|
-
async handleApiRequest(request, env, ctx) {
|
|
3824
|
+
async handleApiRequest(request, env, ctx, apiRoute) {
|
|
2656
3825
|
const url = new URL(request.url);
|
|
2657
|
-
const
|
|
3826
|
+
const { resourceServer } = apiRoute;
|
|
3827
|
+
const configuredResource = resourceServer.resourceMetadata.resource;
|
|
3828
|
+
const externalTokenResolver = resourceServer.resolveExternalToken;
|
|
3829
|
+
const resourceMetadataUrl = this.getResourceMetadataUrlForRequest(url, resourceServer);
|
|
3830
|
+
const challenge = (error, description, scopes = []) => this.buildWwwAuthenticateHeader(resourceMetadataUrl, error, description, scopes, resourceServer);
|
|
2658
3831
|
const authHeader = request.headers.get("Authorization");
|
|
2659
|
-
|
|
3832
|
+
const bearerMatch = authHeader ? /^Bearer[\t ]+([^\s,]+)$/i.exec(authHeader) : null;
|
|
3833
|
+
if (!bearerMatch) return new Response(null, {
|
|
2660
3834
|
status: 401,
|
|
2661
3835
|
headers: {
|
|
2662
3836
|
...NO_CACHE_HEADERS,
|
|
2663
|
-
"WWW-Authenticate":
|
|
3837
|
+
"WWW-Authenticate": challenge()
|
|
2664
3838
|
}
|
|
2665
3839
|
});
|
|
2666
|
-
const accessToken =
|
|
3840
|
+
const accessToken = bearerMatch[1];
|
|
2667
3841
|
const parts = accessToken.split(":");
|
|
2668
3842
|
const isPossiblyInternalFormat = parts.length === 3;
|
|
2669
3843
|
let tokenData = null;
|
|
@@ -2674,75 +3848,104 @@ var OAuthProviderImpl = class {
|
|
|
2674
3848
|
const id = await generateTokenId(accessToken);
|
|
2675
3849
|
tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
|
|
2676
3850
|
}
|
|
2677
|
-
if (!tokenData && !
|
|
3851
|
+
if (!tokenData && !externalTokenResolver) return this.createErrorResponse("invalid_token", {
|
|
2678
3852
|
description: "Invalid access token",
|
|
2679
3853
|
statusCode: 401,
|
|
2680
|
-
headers: { "WWW-Authenticate":
|
|
3854
|
+
headers: { "WWW-Authenticate": challenge("invalid_token") }
|
|
3855
|
+
}, {
|
|
3856
|
+
category: "protected-resource",
|
|
3857
|
+
reason: "token_not_found"
|
|
2681
3858
|
});
|
|
2682
3859
|
if (tokenData) {
|
|
2683
|
-
const
|
|
2684
|
-
if (
|
|
3860
|
+
const tokenAudience = this.resolveStoredTokenAudience(tokenData.audience);
|
|
3861
|
+
if (!isExactResource(tokenAudience, configuredResource)) return this.createErrorResponse("invalid_token", {
|
|
2685
3862
|
description: "Access token is not bound to the configured resource",
|
|
2686
3863
|
statusCode: 401,
|
|
2687
|
-
headers: { "WWW-Authenticate":
|
|
3864
|
+
headers: { "WWW-Authenticate": challenge("invalid_token") }
|
|
3865
|
+
}, {
|
|
3866
|
+
category: "protected-resource",
|
|
3867
|
+
reason: "token_audience_unbound"
|
|
2688
3868
|
});
|
|
2689
3869
|
const now = Math.floor(Date.now() / 1e3);
|
|
2690
3870
|
if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", {
|
|
2691
3871
|
description: "Access token expired",
|
|
2692
3872
|
statusCode: 401,
|
|
2693
|
-
headers: { "WWW-Authenticate":
|
|
3873
|
+
headers: { "WWW-Authenticate": challenge("invalid_token") }
|
|
3874
|
+
}, {
|
|
3875
|
+
category: "protected-resource",
|
|
3876
|
+
reason: "token_expired"
|
|
2694
3877
|
});
|
|
2695
|
-
if (
|
|
3878
|
+
if (tokenAudience) {
|
|
2696
3879
|
const requestUrl = new URL(request.url);
|
|
2697
|
-
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
|
|
2698
|
-
if (!(Array.isArray(
|
|
3880
|
+
const resourceServer$1 = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
|
|
3881
|
+
if (!(Array.isArray(tokenAudience) ? tokenAudience : [tokenAudience]).some((aud) => audienceMatches(resourceServer$1, aud))) return this.createErrorResponse("invalid_token", {
|
|
2699
3882
|
description: "Token audience does not match resource server",
|
|
2700
3883
|
statusCode: 401,
|
|
2701
|
-
headers: { "WWW-Authenticate":
|
|
3884
|
+
headers: { "WWW-Authenticate": challenge("invalid_token", "Invalid audience") }
|
|
3885
|
+
}, {
|
|
3886
|
+
category: "protected-resource",
|
|
3887
|
+
reason: "audience_mismatch"
|
|
2702
3888
|
});
|
|
2703
3889
|
}
|
|
2704
3890
|
ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
|
|
2705
|
-
|
|
3891
|
+
ctx.auth = {
|
|
3892
|
+
token: accessToken,
|
|
3893
|
+
audience: configuredResource,
|
|
3894
|
+
expiresAt: tokenData.expiresAt,
|
|
3895
|
+
scope: [...tokenData.scope || tokenData.grant.scope],
|
|
3896
|
+
userId: tokenData.userId,
|
|
3897
|
+
clientId: tokenData.grant.clientId
|
|
3898
|
+
};
|
|
3899
|
+
} else if (externalTokenResolver) {
|
|
2706
3900
|
let ext;
|
|
2707
3901
|
try {
|
|
2708
|
-
ext = await
|
|
3902
|
+
ext = await externalTokenResolver({
|
|
2709
3903
|
token: accessToken,
|
|
2710
3904
|
request,
|
|
2711
3905
|
env
|
|
2712
3906
|
});
|
|
2713
3907
|
} catch (error) {
|
|
2714
|
-
const response = this.createExternalTokenErrorResponse(error, resourceMetadataUrl);
|
|
3908
|
+
const response = this.createExternalTokenErrorResponse(error, resourceMetadataUrl, resourceServer);
|
|
2715
3909
|
if (response) return response;
|
|
2716
3910
|
throw error;
|
|
2717
3911
|
}
|
|
2718
3912
|
if (!ext) return this.createErrorResponse("invalid_token", {
|
|
2719
3913
|
description: "Invalid access token",
|
|
2720
3914
|
statusCode: 401,
|
|
2721
|
-
headers: { "WWW-Authenticate":
|
|
3915
|
+
headers: { "WWW-Authenticate": challenge("invalid_token") }
|
|
3916
|
+
}, {
|
|
3917
|
+
category: "protected-resource",
|
|
3918
|
+
reason: "external_token_rejected"
|
|
2722
3919
|
});
|
|
2723
|
-
|
|
2724
|
-
if (configuredResource && !isExactResource(ext.audience, configuredResource)) return this.createErrorResponse("invalid_token", {
|
|
3920
|
+
if (typeof ext.audience !== "string" || !isExactResource(ext.audience, configuredResource)) return this.createErrorResponse("invalid_token", {
|
|
2725
3921
|
description: "External access token is not bound to the configured resource",
|
|
2726
3922
|
statusCode: 401,
|
|
2727
|
-
headers: { "WWW-Authenticate":
|
|
3923
|
+
headers: { "WWW-Authenticate": challenge("invalid_token") }
|
|
3924
|
+
}, {
|
|
3925
|
+
category: "protected-resource",
|
|
3926
|
+
reason: "external_audience_unbound"
|
|
2728
3927
|
});
|
|
2729
3928
|
if (ext.audience) {
|
|
2730
3929
|
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", {
|
|
3930
|
+
const resourceServer$1 = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}${requestUrl.search}`;
|
|
3931
|
+
if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer$1, aud))) return this.createErrorResponse("invalid_token", {
|
|
2733
3932
|
description: "Token audience does not match resource server",
|
|
2734
3933
|
statusCode: 401,
|
|
2735
|
-
headers: { "WWW-Authenticate":
|
|
3934
|
+
headers: { "WWW-Authenticate": challenge("invalid_token", "Invalid audience") }
|
|
3935
|
+
}, {
|
|
3936
|
+
category: "protected-resource",
|
|
3937
|
+
reason: "external_audience_mismatch"
|
|
2736
3938
|
});
|
|
2737
3939
|
}
|
|
2738
3940
|
ctx.props = ext.props;
|
|
3941
|
+
ctx.auth = {
|
|
3942
|
+
token: accessToken,
|
|
3943
|
+
audience: configuredResource,
|
|
3944
|
+
scope: []
|
|
3945
|
+
};
|
|
2739
3946
|
}
|
|
2740
3947
|
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
|
-
});
|
|
3948
|
+
const apiHandler = apiRoute.handler;
|
|
2746
3949
|
if (apiHandler.type === HandlerType.EXPORTED_HANDLER) return apiHandler.handler.fetch(request, env, ctx);
|
|
2747
3950
|
else return new apiHandler.handler(ctx, env).fetch(request);
|
|
2748
3951
|
}
|
|
@@ -2766,7 +3969,7 @@ var OAuthProviderImpl = class {
|
|
|
2766
3969
|
const minExpiration = now + KV_MIN_EXPIRATION_TTL_SECONDS + KV_EXPIRATION_CLAMP_MARGIN_SECONDS;
|
|
2767
3970
|
const kvOptions = grantData.expiresAt !== void 0 ? { expiration: Math.max(grantData.expiresAt, minExpiration) } : {};
|
|
2768
3971
|
try {
|
|
2769
|
-
await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
|
|
3972
|
+
await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), grantPutOptions(grantData, kvOptions));
|
|
2770
3973
|
} catch (error) {
|
|
2771
3974
|
this.throwRetryableTokenStorageErrorIfKvRateLimited(error);
|
|
2772
3975
|
throw error;
|
|
@@ -2777,7 +3980,12 @@ var OAuthProviderImpl = class {
|
|
|
2777
3980
|
throw new OAuthError("temporarily_unavailable", {
|
|
2778
3981
|
description: "Token issuance is temporarily unavailable; retry shortly",
|
|
2779
3982
|
statusCode: 429,
|
|
2780
|
-
headers: { "Retry-After": "30" }
|
|
3983
|
+
headers: { "Retry-After": "30" },
|
|
3984
|
+
internal: {
|
|
3985
|
+
category: "token-issuance",
|
|
3986
|
+
reason: "kv_rate_limited",
|
|
3987
|
+
detail: error
|
|
3988
|
+
}
|
|
2781
3989
|
});
|
|
2782
3990
|
}
|
|
2783
3991
|
isKvRateLimitError(error) {
|
|
@@ -2800,6 +4008,51 @@ var OAuthProviderImpl = class {
|
|
|
2800
4008
|
* propagate, and a CIMD metadata fetch failure throws `CimdFetchError`), so an
|
|
2801
4009
|
* upstream outage is distinguishable from an unregistered client.
|
|
2802
4010
|
*/
|
|
4011
|
+
/**
|
|
4012
|
+
* Write a client record that is subject to `clientRegistrationTTL`. The record carries
|
|
4013
|
+
* the expiry it was written with, so a later token request can tell a registration the
|
|
4014
|
+
* provider may renew from a permanent one without listing KV. With the TTL unset the
|
|
4015
|
+
* record is written to last, and any stamp from an earlier configuration is dropped.
|
|
4016
|
+
*/
|
|
4017
|
+
async putRegisteredClient(env, client, now) {
|
|
4018
|
+
const ttl = this.options.clientRegistrationTTL;
|
|
4019
|
+
const { registrationExpiresAt: _previous, ...record } = client;
|
|
4020
|
+
if (ttl === void 0) {
|
|
4021
|
+
await env.OAUTH_KV.put(`client:${client.clientId}`, JSON.stringify(record));
|
|
4022
|
+
return;
|
|
4023
|
+
}
|
|
4024
|
+
await env.OAUTH_KV.put(`client:${client.clientId}`, JSON.stringify({
|
|
4025
|
+
...record,
|
|
4026
|
+
registrationExpiresAt: now + ttl
|
|
4027
|
+
}), { expirationTtl: ttl });
|
|
4028
|
+
}
|
|
4029
|
+
/**
|
|
4030
|
+
* Renew a dynamically registered client whose registration has passed half of
|
|
4031
|
+
* `clientRegistrationTTL`, so a client still exchanging tokens does not vanish from
|
|
4032
|
+
* under its live grants. Without this, a grant that outlives its registration fails
|
|
4033
|
+
* every refresh with `invalid_client` although nothing was revoked. Only records
|
|
4034
|
+
* stamped with `registrationExpiresAt` qualify: CIMD clients are never stored,
|
|
4035
|
+
* `createClient()` records never expire, and a record from before the stamp existed
|
|
4036
|
+
* cannot be told from a permanent one, so it keeps its original schedule and
|
|
4037
|
+
* re-registers once. The half-life rule bounds this to one write per client per half
|
|
4038
|
+
* TTL. Best effort: the token response it follows has already been produced.
|
|
4039
|
+
*
|
|
4040
|
+
* KV has no compare-and-set, so a `deleteClient()` that lands between the read below and
|
|
4041
|
+
* the write is undone by it, as it would be by a concurrent `updateClient()`. The window
|
|
4042
|
+
* is one KV round trip, once per client per half TTL; closing it needs the serialised
|
|
4043
|
+
* storage of #237.
|
|
4044
|
+
*/
|
|
4045
|
+
async renewClientRegistrationIfDue(env, client, now) {
|
|
4046
|
+
const ttl = this.options.clientRegistrationTTL;
|
|
4047
|
+
if (ttl === void 0 || client.registrationExpiresAt === void 0) return;
|
|
4048
|
+
if (client.registrationExpiresAt - now > ttl / 2) return;
|
|
4049
|
+
try {
|
|
4050
|
+
const current = await env.OAUTH_KV.get(`client:${client.clientId}`, { type: "json" });
|
|
4051
|
+
if (current) await this.putRegisteredClient(env, current, now);
|
|
4052
|
+
} catch (error) {
|
|
4053
|
+
console.warn(`Failed to renew client registration ${client.clientId}:`, error);
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
2803
4056
|
async getClient(env, clientId) {
|
|
2804
4057
|
if (this.isClientMetadataUrl(clientId)) {
|
|
2805
4058
|
if (!this.options.clientIdMetadataDocumentEnabled) {
|
|
@@ -2817,33 +4070,140 @@ var OAuthProviderImpl = class {
|
|
|
2817
4070
|
const clientKey = `client:${clientId}`;
|
|
2818
4071
|
return env.OAUTH_KV.get(clientKey, { type: "json" });
|
|
2819
4072
|
}
|
|
4073
|
+
/** Resolve a value to the registry's canonical spelling, requiring one value. */
|
|
4074
|
+
/**
|
|
4075
|
+
* Resolve a stored grant or token resource. A 0.x record may hold an array; it resolves
|
|
4076
|
+
* when exactly one configured resource appears in it, so multi-audience grants for two
|
|
4077
|
+
* resources this server still hosts are ambiguous and fail closed.
|
|
4078
|
+
*/
|
|
4079
|
+
findStoredConfiguredResource(value) {
|
|
4080
|
+
if (!Array.isArray(value)) return this.findConfiguredResource(value);
|
|
4081
|
+
const matches = /* @__PURE__ */ new Set();
|
|
4082
|
+
for (const entry of value) {
|
|
4083
|
+
const configured = this.findConfiguredResource(entry);
|
|
4084
|
+
if (configured) matches.add(configured);
|
|
4085
|
+
}
|
|
4086
|
+
return matches.size === 1 ? [...matches][0] : void 0;
|
|
4087
|
+
}
|
|
4088
|
+
findConfiguredResource(value) {
|
|
4089
|
+
const distinct = Array.isArray(value) ? [...new Set(value)] : [value];
|
|
4090
|
+
const singular = distinct.length === 1 ? distinct[0] : void 0;
|
|
4091
|
+
if (typeof singular !== "string" || !validateResourceUri(singular)) return void 0;
|
|
4092
|
+
return this.resourceServers.map((server) => server.resourceMetadata.resource).find((configured) => isExactResource(singular, configured));
|
|
4093
|
+
}
|
|
4094
|
+
/** Select the audience for a new interactive authorization. */
|
|
4095
|
+
resolveAuthorizationRequestResource(requestedResource) {
|
|
4096
|
+
if (requestedResource === void 0) {
|
|
4097
|
+
const defaultResource = this.getDefaultAuthorizationResource();
|
|
4098
|
+
if (defaultResource) return defaultResource;
|
|
4099
|
+
throw new AuthorizationError("invalid_target", { description: "The resource parameter is required when the authorization server has multiple resources" });
|
|
4100
|
+
}
|
|
4101
|
+
const configured = this.findConfiguredResource(requestedResource);
|
|
4102
|
+
if (!configured) throw new AuthorizationError("invalid_target", { description: "The resource parameter must name exactly one configured protected resource" });
|
|
4103
|
+
return configured;
|
|
4104
|
+
}
|
|
4105
|
+
/** Whether an existing grant belongs to the replacement bucket for a resource. */
|
|
4106
|
+
shouldReplaceGrantForResource(grantResource, resource) {
|
|
4107
|
+
if (grantResource === void 0) return this.getLegacyGrantResource() === resource;
|
|
4108
|
+
return isExactResource(grantResource, resource);
|
|
4109
|
+
}
|
|
4110
|
+
/** Select the audience for a new non-interactive grant such as EMA. */
|
|
4111
|
+
resolveNewTokenResource(requestedResource) {
|
|
4112
|
+
try {
|
|
4113
|
+
return this.resolveAuthorizationRequestResource(requestedResource);
|
|
4114
|
+
} catch (error) {
|
|
4115
|
+
if (error instanceof AuthorizationError) throw new OAuthError("invalid_target", {
|
|
4116
|
+
description: error.description,
|
|
4117
|
+
internal: {
|
|
4118
|
+
category: "resource-indicator",
|
|
4119
|
+
reason: "resource_invalid",
|
|
4120
|
+
detail: error
|
|
4121
|
+
}
|
|
4122
|
+
});
|
|
4123
|
+
throw error;
|
|
4124
|
+
}
|
|
4125
|
+
}
|
|
4126
|
+
/** Validate explicit resource syntax and configured-resource policy. */
|
|
4127
|
+
validateTokenRequestResourceIndicator(requestedResource) {
|
|
4128
|
+
if (requestedResource === void 0) return;
|
|
4129
|
+
if (!this.findConfiguredResource(requestedResource)) throw new OAuthError("invalid_target", {
|
|
4130
|
+
description: "The resource parameter must name exactly one configured protected resource",
|
|
4131
|
+
internal: {
|
|
4132
|
+
category: "resource-indicator",
|
|
4133
|
+
reason: "resource_not_configured"
|
|
4134
|
+
}
|
|
4135
|
+
});
|
|
4136
|
+
}
|
|
4137
|
+
/** Resolve token exchange strictly within the subject token's audience ceiling. */
|
|
4138
|
+
resolveTokenExchangeResource(requestedResource, subjectResource) {
|
|
4139
|
+
const subjectAudience = this.findStoredConfiguredResource(this.resolveStoredTokenAudience(subjectResource));
|
|
4140
|
+
if (!subjectAudience) throw new OAuthError("invalid_target", {
|
|
4141
|
+
description: "Subject token is not bound to a configured resource",
|
|
4142
|
+
internal: {
|
|
4143
|
+
category: "resource-indicator",
|
|
4144
|
+
reason: "subject_token_audience_unbound"
|
|
4145
|
+
}
|
|
4146
|
+
});
|
|
4147
|
+
const resourceWasProvided = requestedResource !== void 0;
|
|
4148
|
+
const requestedAudience = resourceWasProvided ? this.findConfiguredResource(requestedResource) : void 0;
|
|
4149
|
+
if (resourceWasProvided && (!requestedAudience || requestedAudience !== subjectAudience)) throw new OAuthError("invalid_target", {
|
|
4150
|
+
description: "The requested resource must exactly match the subject token audience",
|
|
4151
|
+
internal: {
|
|
4152
|
+
category: "resource-indicator",
|
|
4153
|
+
reason: "resource_subject_token_mismatch"
|
|
4154
|
+
}
|
|
4155
|
+
});
|
|
4156
|
+
return subjectAudience;
|
|
4157
|
+
}
|
|
2820
4158
|
/**
|
|
2821
4159
|
* 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.
|
|
4160
|
+
* The configured canonical resource is inherited when omitted and cannot be overridden.
|
|
2825
4161
|
*/
|
|
2826
|
-
resolveTokenResource(requestedResource,
|
|
4162
|
+
resolveTokenResource(requestedResource, grant) {
|
|
4163
|
+
const grantedResource = grant.resource;
|
|
2827
4164
|
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
4165
|
const grantResourceWasStored = grantedResource !== void 0;
|
|
2831
|
-
const
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
}
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
4166
|
+
const canonicalGrantResource = this.findStoredConfiguredResource(grantedResource);
|
|
4167
|
+
const canonicalRequestedResource = resourceWasProvided ? this.findConfiguredResource(requestedResource) : void 0;
|
|
4168
|
+
if (grantResourceWasStored && !canonicalGrantResource) throw new OAuthError("invalid_grant", {
|
|
4169
|
+
description: "The authorization grant is not bound to a configured resource",
|
|
4170
|
+
internal: {
|
|
4171
|
+
category: "resource-indicator",
|
|
4172
|
+
reason: "grant_audience_unbound"
|
|
4173
|
+
}
|
|
4174
|
+
});
|
|
4175
|
+
if (canonicalGrantResource) {
|
|
4176
|
+
if (resourceWasProvided && canonicalRequestedResource !== canonicalGrantResource) throw new OAuthError("invalid_target", {
|
|
4177
|
+
description: "The requested resource does not match the authorization grant",
|
|
4178
|
+
internal: {
|
|
4179
|
+
category: "resource-indicator",
|
|
4180
|
+
reason: "resource_grant_mismatch"
|
|
4181
|
+
}
|
|
4182
|
+
});
|
|
4183
|
+
return {
|
|
4184
|
+
audience: canonicalGrantResource,
|
|
4185
|
+
...grantedResource === canonicalGrantResource ? {} : { grantResourceBackfill: canonicalGrantResource }
|
|
4186
|
+
};
|
|
2845
4187
|
}
|
|
2846
|
-
|
|
4188
|
+
const legacyGrantResource = this.getLegacyGrantResource();
|
|
4189
|
+
if (!legacyGrantResource) throw new OAuthError("invalid_grant", {
|
|
4190
|
+
description: "This legacy authorization grant has no resource binding and must be reauthorized",
|
|
4191
|
+
internal: {
|
|
4192
|
+
category: "resource-indicator",
|
|
4193
|
+
reason: "legacy_grant_unbound"
|
|
4194
|
+
}
|
|
4195
|
+
});
|
|
4196
|
+
if (resourceWasProvided && canonicalRequestedResource !== legacyGrantResource) throw new OAuthError("invalid_target", {
|
|
4197
|
+
description: "The requested resource does not match the server legacy-grant migration policy",
|
|
4198
|
+
internal: {
|
|
4199
|
+
category: "resource-indicator",
|
|
4200
|
+
reason: "legacy_grant_resource_mismatch"
|
|
4201
|
+
}
|
|
4202
|
+
});
|
|
4203
|
+
return {
|
|
4204
|
+
audience: legacyGrantResource,
|
|
4205
|
+
grantResourceBackfill: legacyGrantResource
|
|
4206
|
+
};
|
|
2847
4207
|
}
|
|
2848
4208
|
/**
|
|
2849
4209
|
* Creates and stores an access token
|
|
@@ -2852,7 +4212,13 @@ var OAuthProviderImpl = class {
|
|
|
2852
4212
|
*/
|
|
2853
4213
|
async createAccessToken(params) {
|
|
2854
4214
|
const { userId, grantId, clientId, scope, encryptedProps, encryptionKey, expiresIn, audience, env } = params;
|
|
2855
|
-
if (expiresIn
|
|
4215
|
+
if (!isValidAccessTokenTTL(expiresIn)) throw new OAuthError("invalid_request", {
|
|
4216
|
+
description: "Requested token lifetime must be at least 60 seconds",
|
|
4217
|
+
internal: {
|
|
4218
|
+
category: "token-issuance",
|
|
4219
|
+
reason: "requested_ttl_too_short"
|
|
4220
|
+
}
|
|
4221
|
+
});
|
|
2856
4222
|
const accessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
2857
4223
|
const now = Math.floor(Date.now() / 1e3);
|
|
2858
4224
|
const accessTokenId = await generateTokenId(accessToken);
|
|
@@ -2908,14 +4274,36 @@ var OAuthProviderImpl = class {
|
|
|
2908
4274
|
/**
|
|
2909
4275
|
* Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
|
|
2910
4276
|
*/
|
|
2911
|
-
buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription, requiredScopes = []) {
|
|
2912
|
-
let header =
|
|
4277
|
+
buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription, requiredScopes = [], resourceServer = this.resourceServers[0]) {
|
|
4278
|
+
let header = "Bearer realm=\"OAuth\"";
|
|
4279
|
+
if (resourceMetadataUrl) header += `, resource_metadata="${resourceMetadataUrl}"`;
|
|
2913
4280
|
if (error) header += `, error="${error}"`;
|
|
2914
|
-
const challengeScopes = requiredScopes.length > 0 ? this.normalizeProtectedResourceScopes(requiredScopes) : this.getProtectedResourceScopes();
|
|
4281
|
+
const challengeScopes = requiredScopes.length > 0 ? this.normalizeProtectedResourceScopes(requiredScopes) : this.getProtectedResourceScopes(resourceServer);
|
|
2915
4282
|
if (challengeScopes.length > 0) header += `, scope="${challengeScopes.join(" ")}"`;
|
|
2916
4283
|
if (errorDescription) header += `, error_description="${errorDescription}"`;
|
|
2917
4284
|
return header;
|
|
2918
4285
|
}
|
|
4286
|
+
/** Build the RFC 9728 well-known URL for the configured canonical resource. */
|
|
4287
|
+
getConfiguredResourceMetadataUrl(resource) {
|
|
4288
|
+
const authorityStart = resource.indexOf("://") + 3;
|
|
4289
|
+
const suffixOffset = resource.slice(authorityStart).search(/[/?]/);
|
|
4290
|
+
const suffixStart = suffixOffset === -1 ? resource.length : authorityStart + suffixOffset;
|
|
4291
|
+
let suffix = resource.slice(suffixStart);
|
|
4292
|
+
if (suffix === "/") suffix = "";
|
|
4293
|
+
if (suffix.startsWith("/?")) suffix = suffix.slice(1);
|
|
4294
|
+
return resource.slice(0, suffixStart) + PROTECTED_RESOURCE_WELL_KNOWN_PREFIX + suffix;
|
|
4295
|
+
}
|
|
4296
|
+
/**
|
|
4297
|
+
* RFC 9728 §3.3 requires metadata fetched from a challenge to identify the
|
|
4298
|
+
* original protected-resource URL. Descendant and alias routes can still use
|
|
4299
|
+
* a base audience, but must not advertise a document for a different URL.
|
|
4300
|
+
*/
|
|
4301
|
+
getResourceMetadataUrlForRequest(requestUrl, resourceServer) {
|
|
4302
|
+
const configuredResource = resourceServer.resourceMetadata.resource;
|
|
4303
|
+
if (isExactResource(requestUrl.href, configuredResource)) return this.getConfiguredResourceMetadataUrl(configuredResource);
|
|
4304
|
+
const resourceUrl = new URL(configuredResource);
|
|
4305
|
+
if (requestUrl.origin === resourceUrl.origin && requestCarriesResourceQuery(requestUrl, resourceUrl) && isPathDescendant(requestUrl.pathname, resourceUrl.pathname)) return this.getConfiguredResourceMetadataUrl(configuredResource);
|
|
4306
|
+
}
|
|
2919
4307
|
/**
|
|
2920
4308
|
* Helper function to create OAuth error responses.
|
|
2921
4309
|
*
|
|
@@ -2936,21 +4324,22 @@ var OAuthProviderImpl = class {
|
|
|
2936
4324
|
description,
|
|
2937
4325
|
status: responseStatus,
|
|
2938
4326
|
headers: responseHeaders,
|
|
2939
|
-
|
|
4327
|
+
internal,
|
|
2940
4328
|
...request ? { request } : {}
|
|
2941
4329
|
});
|
|
2942
|
-
if (customErrorResponse) return customErrorResponse;
|
|
2943
4330
|
const body = JSON.stringify({
|
|
2944
4331
|
error: code,
|
|
2945
4332
|
error_description: description
|
|
2946
4333
|
});
|
|
2947
|
-
|
|
4334
|
+
const response = customErrorResponse ?? new Response(body, {
|
|
2948
4335
|
status: responseStatus,
|
|
2949
4336
|
headers: {
|
|
2950
4337
|
"Content-Type": "application/json",
|
|
2951
4338
|
...responseHeaders
|
|
2952
4339
|
}
|
|
2953
4340
|
});
|
|
4341
|
+
this.errorResponses.add(response);
|
|
4342
|
+
return response;
|
|
2954
4343
|
}
|
|
2955
4344
|
};
|
|
2956
4345
|
/**
|
|
@@ -2978,6 +4367,7 @@ var OAuthProviderImpl = class {
|
|
|
2978
4367
|
* async function refreshUpstream(props) {
|
|
2979
4368
|
* const res = await fetch(...);
|
|
2980
4369
|
* if (res.status === 401) {
|
|
4370
|
+
* // invalid_grant can never recover: the provider also revokes this grant and its tokens.
|
|
2981
4371
|
* throw new OAuthError('invalid_grant', { description: 'upstream refresh token is invalid' });
|
|
2982
4372
|
* }
|
|
2983
4373
|
* if (res.status === 429) {
|
|
@@ -3081,6 +4471,9 @@ const DEFAULT_CLIENT_REGISTRATION_TTL = 2160 * 60 * 60;
|
|
|
3081
4471
|
* to clamp absolute expirations when writing grants back to KV.
|
|
3082
4472
|
*/
|
|
3083
4473
|
const KV_MIN_EXPIRATION_TTL_SECONDS = 60;
|
|
4474
|
+
function isValidAccessTokenTTL(value) {
|
|
4475
|
+
return Number.isInteger(value) && value >= KV_MIN_EXPIRATION_TTL_SECONDS;
|
|
4476
|
+
}
|
|
3084
4477
|
/**
|
|
3085
4478
|
* Safety margin (seconds) added on top of `KV_MIN_EXPIRATION_TTL_SECONDS` when clamping an
|
|
3086
4479
|
* absolute KV expiration. Absolute expirations are validated against KV's clock at the
|
|
@@ -3099,8 +4492,9 @@ const DEFAULT_PURGE_BATCH_SIZE = 50;
|
|
|
3099
4492
|
*/
|
|
3100
4493
|
const MAX_KV_LIST_LIMIT = 1e3;
|
|
3101
4494
|
/**
|
|
3102
|
-
*
|
|
3103
|
-
*
|
|
4495
|
+
* How many grants written without key metadata `completeAuthorization()` reads at
|
|
4496
|
+
* once while looking for grants to replace. Grants written with key metadata are
|
|
4497
|
+
* matched from `list()` alone and never read.
|
|
3104
4498
|
*/
|
|
3105
4499
|
const DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE = 50;
|
|
3106
4500
|
function getRevokeExistingGrantsBatchSize(batchSize) {
|
|
@@ -3108,27 +4502,38 @@ function getRevokeExistingGrantsBatchSize(batchSize) {
|
|
|
3108
4502
|
if (!Number.isFinite(batchSize) || !Number.isInteger(batchSize) || batchSize < 1) throw new Error("revokeExistingGrantsBatchSize must be a positive integer.");
|
|
3109
4503
|
return Math.min(batchSize, MAX_KV_LIST_LIMIT);
|
|
3110
4504
|
}
|
|
4505
|
+
/** KV serialises key metadata as JSON and rejects a write carrying more than this. */
|
|
4506
|
+
const MAX_KV_METADATA_BYTES = 1024;
|
|
3111
4507
|
/**
|
|
3112
|
-
*
|
|
4508
|
+
* Key metadata for a grant record, or `undefined` when it would not fit. A grant
|
|
4509
|
+
* written without metadata is read individually when scanned, exactly like a grant
|
|
4510
|
+
* written before metadata existed; that only happens when a `client_id` and two URLs
|
|
4511
|
+
* together exceed a kilobyte.
|
|
3113
4512
|
*/
|
|
3114
|
-
|
|
4513
|
+
function grantKeyMetadata(grant) {
|
|
4514
|
+
const metadata = { clientId: grant.clientId };
|
|
4515
|
+
if (grant.resource !== void 0) metadata.resource = grant.resource;
|
|
4516
|
+
if (grant.redirectUri !== void 0) metadata.redirectUri = grant.redirectUri;
|
|
4517
|
+
return new TextEncoder().encode(JSON.stringify(metadata)).byteLength <= MAX_KV_METADATA_BYTES ? metadata : void 0;
|
|
4518
|
+
}
|
|
4519
|
+
/** Every grant write goes through here so the key metadata cannot drift from the record. */
|
|
4520
|
+
function grantPutOptions(grant, expiry = {}) {
|
|
4521
|
+
const metadata = grantKeyMetadata(grant);
|
|
4522
|
+
return metadata ? {
|
|
4523
|
+
...expiry,
|
|
4524
|
+
metadata
|
|
4525
|
+
} : expiry;
|
|
4526
|
+
}
|
|
4527
|
+
/** Whether a listed key carries metadata this version can match a grant on. */
|
|
4528
|
+
function isGrantKeyMetadata(value) {
|
|
4529
|
+
if (typeof value !== "object" || value === null) return false;
|
|
4530
|
+
const { clientId, resource, redirectUri } = value;
|
|
4531
|
+
return typeof clientId === "string" && (resource === void 0 || typeof resource === "string" || Array.isArray(resource)) && (redirectUri === void 0 || typeof redirectUri === "string");
|
|
4532
|
+
}
|
|
3115
4533
|
/**
|
|
3116
|
-
*
|
|
3117
|
-
* @param uri - The URI string to validate
|
|
3118
|
-
* @returns true if valid, false otherwise
|
|
4534
|
+
* Length of generated token strings
|
|
3119
4535
|
*/
|
|
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
|
-
}
|
|
4536
|
+
const TOKEN_LENGTH = 32;
|
|
3132
4537
|
/**
|
|
3133
4538
|
* Checks if a resource server matches an audience claim.
|
|
3134
4539
|
* Uses origin comparison (case-insensitive hostname via URL normalization)
|
|
@@ -3142,42 +4547,37 @@ function audienceMatches(resourceServerUrl, audienceValue) {
|
|
|
3142
4547
|
const resource = new URL(resourceServerUrl);
|
|
3143
4548
|
const audience = new URL(audienceValue);
|
|
3144
4549
|
if (resource.origin !== audience.origin) return false;
|
|
3145
|
-
if (
|
|
4550
|
+
if (!requestCarriesResourceQuery(resource, audience)) return false;
|
|
3146
4551
|
if (audience.pathname === "/" || audience.pathname === "") return true;
|
|
3147
|
-
|
|
4552
|
+
const descendantPrefix = audience.pathname.endsWith("/") ? audience.pathname : audience.pathname + "/";
|
|
4553
|
+
return resource.pathname === audience.pathname || resource.pathname.startsWith(descendantPrefix);
|
|
3148
4554
|
} catch {
|
|
3149
4555
|
return false;
|
|
3150
4556
|
}
|
|
3151
4557
|
}
|
|
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;
|
|
4558
|
+
function appendHeaderValue(headers, name, value) {
|
|
4559
|
+
const values = (headers.get(name) ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
4560
|
+
if (!values.some((item) => item.toLowerCase() === value.toLowerCase())) values.push(value);
|
|
4561
|
+
headers.set(name, values.join(", "));
|
|
3164
4562
|
}
|
|
3165
|
-
/** Whether a request or audience names one
|
|
4563
|
+
/** Whether a request or audience names one canonical configured resource. */
|
|
3166
4564
|
function isExactResource(value, configuredResource) {
|
|
3167
|
-
|
|
4565
|
+
if (Array.isArray(value)) return value.some((entry) => typeof entry === "string" && resourceMatches(entry, configuredResource));
|
|
4566
|
+
return typeof value === "string" && resourceMatches(value, configuredResource);
|
|
3168
4567
|
}
|
|
3169
|
-
/**
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
4568
|
+
/** RFC 9110 §9.3.2: a HEAD response carries the GET headers and no body. */
|
|
4569
|
+
function withoutBodyForHead(request, response) {
|
|
4570
|
+
if (request.method !== "HEAD") return response;
|
|
4571
|
+
return new Response(null, {
|
|
4572
|
+
status: response.status,
|
|
4573
|
+
headers: response.headers
|
|
4574
|
+
});
|
|
4575
|
+
}
|
|
4576
|
+
/** Whether `candidate` is `base` or a path-boundary descendant of it (trailing slashes ignored). */
|
|
4577
|
+
function isPathDescendant(candidate, base) {
|
|
4578
|
+
if (base === "" || base === "/") return true;
|
|
4579
|
+
if (base.endsWith("/")) return candidate.startsWith(base);
|
|
4580
|
+
return candidate === base || candidate.startsWith(`${base}/`);
|
|
3181
4581
|
}
|
|
3182
4582
|
/**
|
|
3183
4583
|
* Hashes a secret value using SHA-256
|
|
@@ -3248,11 +4648,7 @@ async function generateTokenId(token) {
|
|
|
3248
4648
|
*/
|
|
3249
4649
|
function isLoopbackUri(uri) {
|
|
3250
4650
|
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;
|
|
4651
|
+
return isLoopbackHostname(new URL(uri).hostname);
|
|
3256
4652
|
} catch {
|
|
3257
4653
|
return false;
|
|
3258
4654
|
}
|
|
@@ -3431,11 +4827,11 @@ const WRAPPING_KEY_HMAC_KEY = new Uint8Array([
|
|
|
3431
4827
|
*/
|
|
3432
4828
|
async function deriveKeyFromToken(tokenStr) {
|
|
3433
4829
|
const encoder = new TextEncoder();
|
|
3434
|
-
const hmacKey = await crypto.subtle.importKey("raw", WRAPPING_KEY_HMAC_KEY, {
|
|
4830
|
+
const hmacKey$1 = await crypto.subtle.importKey("raw", WRAPPING_KEY_HMAC_KEY, {
|
|
3435
4831
|
name: "HMAC",
|
|
3436
4832
|
hash: "SHA-256"
|
|
3437
4833
|
}, false, ["sign"]);
|
|
3438
|
-
const hmacResult = await crypto.subtle.sign("HMAC", hmacKey, encoder.encode(tokenStr));
|
|
4834
|
+
const hmacResult = await crypto.subtle.sign("HMAC", hmacKey$1, encoder.encode(tokenStr));
|
|
3439
4835
|
return await crypto.subtle.importKey("raw", hmacResult, { name: "AES-KW" }, false, ["wrapKey", "unwrapKey"]);
|
|
3440
4836
|
}
|
|
3441
4837
|
/**
|
|
@@ -3482,6 +4878,7 @@ var OAuthHelpersImpl = class {
|
|
|
3482
4878
|
*/
|
|
3483
4879
|
async parseAuthRequest(request) {
|
|
3484
4880
|
const url = new URL(request.url);
|
|
4881
|
+
if (this.provider.hasExplicitIssuer && !this.provider.isAuthorizationEndpointRequest(url)) throw new AuthorizationError("invalid_request", { description: "Authorization request was sent to an unconfigured endpoint" });
|
|
3485
4882
|
const responseType = url.searchParams.get("response_type") || "";
|
|
3486
4883
|
const clientId = url.searchParams.get("client_id") || "";
|
|
3487
4884
|
const redirectUri = url.searchParams.get("redirect_uri") || "";
|
|
@@ -3504,13 +4901,12 @@ var OAuthHelpersImpl = class {
|
|
|
3504
4901
|
const withRedirect = (error) => {
|
|
3505
4902
|
throw withAuthorizationRedirect(error, redirectUri, state || void 0, issuer);
|
|
3506
4903
|
};
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
resource = configuredResource;
|
|
4904
|
+
let resource;
|
|
4905
|
+
try {
|
|
4906
|
+
resource = this.provider.resolveAuthorizationRequestResource(resourceParam);
|
|
4907
|
+
} catch (error) {
|
|
4908
|
+
if (error instanceof AuthorizationError) withRedirect(error);
|
|
4909
|
+
throw error;
|
|
3514
4910
|
}
|
|
3515
4911
|
try {
|
|
3516
4912
|
validateAuthorizationResponseType(this.provider.serverCapabilities, responseType, clientInfo.responseTypes);
|
|
@@ -3565,26 +4961,13 @@ var OAuthHelpersImpl = class {
|
|
|
3565
4961
|
const clientInfo = await this.lookupClient(clientId);
|
|
3566
4962
|
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
4963
|
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;
|
|
4964
|
+
const effectiveResource = this.provider.resolveAuthorizationRequestResource(options.request.resource);
|
|
3574
4965
|
validateAuthorizationPkce(this.provider.serverCapabilities, options.request, clientInfo);
|
|
3575
4966
|
let grantsToRevoke = [];
|
|
3576
4967
|
if (options.revokeExistingGrants !== false) {
|
|
3577
4968
|
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);
|
|
4969
|
+
const readConcurrency = getRevokeExistingGrantsBatchSize(options.revokeExistingGrantsBatchSize);
|
|
4970
|
+
grantsToRevoke = await this.findGrantIds(options.userId, (grant) => grant.clientId === clientId && (!isCimdClient || grant.redirectUri === options.request.redirectUri) && this.provider.shouldReplaceGrantForResource(grant.resource, effectiveResource), readConcurrency);
|
|
3588
4971
|
}
|
|
3589
4972
|
const grantId = generateRandomString(16);
|
|
3590
4973
|
const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
|
|
@@ -3596,8 +4979,7 @@ var OAuthHelpersImpl = class {
|
|
|
3596
4979
|
const accessTokenTTL = this.provider.options.accessTokenTTL || DEFAULT_ACCESS_TOKEN_TTL;
|
|
3597
4980
|
const accessTokenExpiresAt = now + accessTokenTTL;
|
|
3598
4981
|
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");
|
|
4982
|
+
const audience = effectiveResource;
|
|
3601
4983
|
const grant = {
|
|
3602
4984
|
id: grantId,
|
|
3603
4985
|
clientId: options.request.clientId,
|
|
@@ -3610,7 +4992,7 @@ var OAuthHelpersImpl = class {
|
|
|
3610
4992
|
redirectUri: options.request.redirectUri
|
|
3611
4993
|
};
|
|
3612
4994
|
const grantKey = `grant:${options.userId}:${grantId}`;
|
|
3613
|
-
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant));
|
|
4995
|
+
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), grantPutOptions(grant));
|
|
3614
4996
|
const accessTokenData = {
|
|
3615
4997
|
id: accessTokenId,
|
|
3616
4998
|
grantId,
|
|
@@ -3633,6 +5015,7 @@ var OAuthHelpersImpl = class {
|
|
|
3633
5015
|
fragment.set("token_type", "bearer");
|
|
3634
5016
|
fragment.set("expires_in", accessTokenTTL.toString());
|
|
3635
5017
|
fragment.set("scope", options.scope.join(" "));
|
|
5018
|
+
fragment.set("resource", effectiveResource);
|
|
3636
5019
|
if (options.request.state) fragment.set("state", options.request.state);
|
|
3637
5020
|
if (options.request.issuer) fragment.set("iss", options.request.issuer);
|
|
3638
5021
|
redirectUrl.hash = fragment.toString();
|
|
@@ -3661,7 +5044,7 @@ var OAuthHelpersImpl = class {
|
|
|
3661
5044
|
redirectUri: options.request.redirectUri
|
|
3662
5045
|
};
|
|
3663
5046
|
const grantKey = `grant:${options.userId}:${grantId}`;
|
|
3664
|
-
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), { expirationTtl: 600 });
|
|
5047
|
+
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), grantPutOptions(grant, { expirationTtl: 600 }));
|
|
3665
5048
|
const redirectUrl = new URL(options.request.redirectUri);
|
|
3666
5049
|
redirectUrl.searchParams.set("code", authCode);
|
|
3667
5050
|
if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
|
|
@@ -3673,6 +5056,39 @@ var OAuthHelpersImpl = class {
|
|
|
3673
5056
|
}
|
|
3674
5057
|
}
|
|
3675
5058
|
/**
|
|
5059
|
+
* IDs of the user's grants that satisfy `matches`, judged from KV key metadata
|
|
5060
|
+
* wherever a grant carries it. Only grants written without metadata, by a version
|
|
5061
|
+
* before 1.0 or because theirs would not fit, are read, `readConcurrency` at a time.
|
|
5062
|
+
*
|
|
5063
|
+
* This runs on the interactive authorization path, where the pre-metadata cost of one
|
|
5064
|
+
* `get()` per grant broke re-authorization for a user with more grants than a Worker
|
|
5065
|
+
* has subrequests. The cost is now one `list()` per thousand grants plus one `get()`
|
|
5066
|
+
* per legacy grant, and every refresh rewrites its grant with metadata, so the legacy
|
|
5067
|
+
* share shrinks on its own.
|
|
5068
|
+
*/
|
|
5069
|
+
async findGrantIds(userId, matches, readConcurrency) {
|
|
5070
|
+
const prefix = `grant:${userId}:`;
|
|
5071
|
+
const found = [];
|
|
5072
|
+
let cursor;
|
|
5073
|
+
do {
|
|
5074
|
+
const page = await this.env.OAUTH_KV.list({
|
|
5075
|
+
prefix,
|
|
5076
|
+
limit: MAX_KV_LIST_LIMIT,
|
|
5077
|
+
cursor
|
|
5078
|
+
});
|
|
5079
|
+
const toRead = [];
|
|
5080
|
+
for (const key of page.keys) if (isGrantKeyMetadata(key.metadata)) {
|
|
5081
|
+
if (matches(key.metadata)) found.push(key.name.slice(prefix.length));
|
|
5082
|
+
} else toRead.push(key.name);
|
|
5083
|
+
for (let start = 0; start < toRead.length; start += readConcurrency) {
|
|
5084
|
+
const grants = await Promise.all(toRead.slice(start, start + readConcurrency).map((name) => this.env.OAUTH_KV.get(name, { type: "json" })));
|
|
5085
|
+
for (const grant of grants) if (grant && matches(grant)) found.push(grant.id);
|
|
5086
|
+
}
|
|
5087
|
+
cursor = page.list_complete ? void 0 : page.cursor;
|
|
5088
|
+
} while (cursor);
|
|
5089
|
+
return found;
|
|
5090
|
+
}
|
|
5091
|
+
/**
|
|
3676
5092
|
* Creates a new OAuth client
|
|
3677
5093
|
* @param clientInfo - Partial client information to create the client with
|
|
3678
5094
|
* @returns A Promise resolving to the created client info
|
|
@@ -3764,9 +5180,8 @@ var OAuthHelpersImpl = class {
|
|
|
3764
5180
|
};
|
|
3765
5181
|
if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
|
|
3766
5182
|
else delete updatedClient.clientSecret;
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(updatedClient), clientKvOptions);
|
|
5183
|
+
if (client.registrationExpiresAt === void 0) await this.env.OAUTH_KV.put(`client:${updatedClient.clientId}`, JSON.stringify(updatedClient));
|
|
5184
|
+
else await this.provider.putRegisteredClient(this.env, updatedClient, Math.floor(Date.now() / 1e3));
|
|
3770
5185
|
const response = toPublicClientInfo(updatedClient);
|
|
3771
5186
|
if (!isPublicClient && originalSecret) response.clientSecret = originalSecret;
|
|
3772
5187
|
return response;
|
|
@@ -3816,7 +5231,8 @@ var OAuthHelpersImpl = class {
|
|
|
3816
5231
|
metadata: grantData.metadata,
|
|
3817
5232
|
createdAt: grantData.createdAt,
|
|
3818
5233
|
expiresAt: grantData.expiresAt,
|
|
3819
|
-
redirectUri: grantData.redirectUri
|
|
5234
|
+
redirectUri: grantData.redirectUri,
|
|
5235
|
+
resource: grantData.resource
|
|
3820
5236
|
};
|
|
3821
5237
|
grantSummaries.push(summary);
|
|
3822
5238
|
}
|
|
@@ -3833,6 +5249,30 @@ var OAuthHelpersImpl = class {
|
|
|
3833
5249
|
* @param userId - The ID of the user who owns the grant
|
|
3834
5250
|
* @returns A Promise resolving when the revocation is confirmed.
|
|
3835
5251
|
*/
|
|
5252
|
+
/** {@inheritDoc OAuthHelpers.isConsentRemembered} */
|
|
5253
|
+
isConsentRemembered(request, authRequest, remember) {
|
|
5254
|
+
return isConsentRemembered(this.provider.consentCookies, request, authRequest, remember);
|
|
5255
|
+
}
|
|
5256
|
+
/** {@inheritDoc OAuthHelpers.beginConsent} */
|
|
5257
|
+
beginConsent(authRequest) {
|
|
5258
|
+
return beginConsent(this.env.OAUTH_KV, this.provider.consentCookies, authRequest);
|
|
5259
|
+
}
|
|
5260
|
+
/** {@inheritDoc OAuthHelpers.approveConsent} */
|
|
5261
|
+
approveConsent(request, handle, options) {
|
|
5262
|
+
return approveConsent(this.env.OAUTH_KV, this.provider.consentCookies, this.provider.options.scopesSupported, request, handle, options);
|
|
5263
|
+
}
|
|
5264
|
+
/** {@inheritDoc OAuthHelpers.denyConsent} */
|
|
5265
|
+
denyConsent(request, handle, options) {
|
|
5266
|
+
return denyConsent(this.env.OAUTH_KV, this.provider.consentCookies, request, handle, options);
|
|
5267
|
+
}
|
|
5268
|
+
/** {@inheritDoc OAuthHelpers.beginUpstream} */
|
|
5269
|
+
beginUpstream(authRequest, options) {
|
|
5270
|
+
return beginUpstream(this.env.OAUTH_KV, this.provider.consentCookies, authRequest, options);
|
|
5271
|
+
}
|
|
5272
|
+
/** {@inheritDoc OAuthHelpers.finishUpstream} */
|
|
5273
|
+
finishUpstream(request) {
|
|
5274
|
+
return finishUpstream(this.env.OAUTH_KV, this.provider.consentCookies, request);
|
|
5275
|
+
}
|
|
3836
5276
|
async revokeGrant(grantId, userId) {
|
|
3837
5277
|
const grantKey = `grant:${userId}:${grantId}`;
|
|
3838
5278
|
const tokenPrefix = `token:${userId}:${grantId}:`;
|
|
@@ -3966,4 +5406,4 @@ var OAuthHelpersImpl = class {
|
|
|
3966
5406
|
var oauth_provider_default = OAuthProvider;
|
|
3967
5407
|
|
|
3968
5408
|
//#endregion
|
|
3969
|
-
export { AuthorizationError, CimdFetchError, ExternalTokenError, GrantType, OAuthError, OAuthProvider, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
|
|
5409
|
+
export { AuthorizationError, CimdFetchError, ExternalTokenError, GrantType, OAuthAuthorizationServer, OAuthError, OAuthProvider, OAuthResourceServer, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, insufficientScope, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
|