@jeffjassky/oauth-host 0.3.0 → 0.4.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/dist/index.cjs +724 -407
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +724 -407
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
- package/types/index.d.ts +13 -0
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import mongoose from 'mongoose';
|
|
2
2
|
import express2 from 'express';
|
|
3
3
|
import { createHash, randomBytes, createPrivateKey, createPublicKey, generateKeyPairSync, timingSafeEqual, sign, createHmac } from 'crypto';
|
|
4
|
+
import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from 'jose';
|
|
4
5
|
|
|
5
6
|
// src/server/models.ts
|
|
6
7
|
var DEFAULT_NAMES = {
|
|
@@ -35,6 +36,18 @@ function clientSchema() {
|
|
|
35
36
|
metadataUrl: String,
|
|
36
37
|
metadataFetchedAt: Date,
|
|
37
38
|
metadataEtag: String,
|
|
39
|
+
// The usable (intersected) token endpoint auth method set for a CIMD
|
|
40
|
+
// client — absent on a manual registration, whose behavior derives from
|
|
41
|
+
// `type` exactly as before. A row with no value here supports no
|
|
42
|
+
// assertion-based method; see `authenticateClient`'s fallback rule.
|
|
43
|
+
tokenEndpointAuthMethods: [String],
|
|
44
|
+
// Exactly one of these two is set when `private_key_jwt` is in
|
|
45
|
+
// `tokenEndpointAuthMethods` — `validateMetadataDocument` refuses a
|
|
46
|
+
// document declaring both. Neither is ever required at the schema
|
|
47
|
+
// level: a manual client, or a CIMD client that only offers `none`,
|
|
48
|
+
// legitimately has neither.
|
|
49
|
+
jwksUri: String,
|
|
50
|
+
jwks: mongoose.Schema.Types.Mixed,
|
|
38
51
|
trusted: { type: Boolean, default: false },
|
|
39
52
|
// An array, not a string: rotation needs two live secrets at once or it
|
|
40
53
|
// cannot be deployed without downtime.
|
|
@@ -378,7 +391,10 @@ function resolveCimd(input, scopeIndex) {
|
|
|
378
391
|
fetchTimeoutMs: 5e3,
|
|
379
392
|
maxBytes: 65536,
|
|
380
393
|
allowedScopes: [],
|
|
381
|
-
failures: /* @__PURE__ */ new Map()
|
|
394
|
+
failures: /* @__PURE__ */ new Map(),
|
|
395
|
+
jwksCache: /* @__PURE__ */ new Map(),
|
|
396
|
+
jwksForceRefetchAt: /* @__PURE__ */ new Map(),
|
|
397
|
+
assertionReplay: /* @__PURE__ */ new Map()
|
|
382
398
|
};
|
|
383
399
|
if (!input || !input.enabled) return off;
|
|
384
400
|
if (!Array.isArray(input.allowedHosts) || input.allowedHosts.length === 0) {
|
|
@@ -411,7 +427,10 @@ function resolveCimd(input, scopeIndex) {
|
|
|
411
427
|
fetchTimeoutMs: positive(input.fetchTimeoutMs, "fetchTimeoutMs", 5e3),
|
|
412
428
|
maxBytes: positive(input.maxBytes, "maxBytes", 65536),
|
|
413
429
|
allowedScopes: [...allowedScopes],
|
|
414
|
-
failures: /* @__PURE__ */ new Map()
|
|
430
|
+
failures: /* @__PURE__ */ new Map(),
|
|
431
|
+
jwksCache: /* @__PURE__ */ new Map(),
|
|
432
|
+
jwksForceRefetchAt: /* @__PURE__ */ new Map(),
|
|
433
|
+
assertionReplay: /* @__PURE__ */ new Map()
|
|
415
434
|
};
|
|
416
435
|
}
|
|
417
436
|
function resolveConfig(config) {
|
|
@@ -659,15 +678,19 @@ function authorizationServerMetadata(ctx, mountPath) {
|
|
|
659
678
|
// S256 only. `plain` is not implemented anywhere in this package, so
|
|
660
679
|
// advertising it would be a lie a client would discover at redemption.
|
|
661
680
|
code_challenge_methods_supported: ["S256"],
|
|
662
|
-
// `none`
|
|
663
|
-
// would tell every client that secretless
|
|
664
|
-
// and the only clients that can use it
|
|
665
|
-
// refuse to register.
|
|
681
|
+
// `none` and `private_key_jwt` are advertised only when CIMD is on.
|
|
682
|
+
// Listing either unconditionally would tell every client that secretless
|
|
683
|
+
// authentication is available here, and the only clients that can use it
|
|
684
|
+
// are the ones this server would then refuse to register.
|
|
666
685
|
token_endpoint_auth_methods_supported: [
|
|
667
686
|
"client_secret_basic",
|
|
668
687
|
"client_secret_post",
|
|
669
|
-
...ctx.cimd.enabled ? ["none"] : []
|
|
688
|
+
...ctx.cimd.enabled ? ["none", "private_key_jwt"] : []
|
|
670
689
|
],
|
|
690
|
+
// RFC 7523 §2.2 — the only two algorithms `authenticateClient` accepts
|
|
691
|
+
// for a `client_assertion` signature. Same gate as the line above: it
|
|
692
|
+
// names a capability that only exists for a CIMD client.
|
|
693
|
+
...ctx.cimd.enabled ? { token_endpoint_auth_signing_alg_values_supported: ["RS256", "ES256"] } : {},
|
|
671
694
|
// What tells Claude and ChatGPT to skip registration entirely and send
|
|
672
695
|
// their metadata document URL as `client_id`.
|
|
673
696
|
...ctx.cimd.enabled ? { client_id_metadata_document_supported: true } : {},
|
|
@@ -1270,135 +1293,537 @@ function createKeyManager(ctx) {
|
|
|
1270
1293
|
};
|
|
1271
1294
|
}
|
|
1272
1295
|
|
|
1273
|
-
// src/server/services/
|
|
1274
|
-
var
|
|
1275
|
-
var
|
|
1276
|
-
function
|
|
1277
|
-
|
|
1278
|
-
if (n < 1) return 1;
|
|
1279
|
-
return Math.min(n, MAX_LIST);
|
|
1296
|
+
// src/server/services/cimd.ts
|
|
1297
|
+
var NEGATIVE_TTL_MS = 6e4;
|
|
1298
|
+
var MAX_REMEMBERED_FAILURES = 1e3;
|
|
1299
|
+
function refuse(description) {
|
|
1300
|
+
return new UnredirectableError("invalid_client", description);
|
|
1280
1301
|
}
|
|
1281
|
-
function
|
|
1282
|
-
|
|
1283
|
-
return n > 0 ? n : 0;
|
|
1302
|
+
function isMetadataUrl(clientId) {
|
|
1303
|
+
return /^https:\/\//i.test(clientId);
|
|
1284
1304
|
}
|
|
1285
|
-
function
|
|
1286
|
-
|
|
1305
|
+
function hostAllowed(rules, url) {
|
|
1306
|
+
const hostname = url.hostname.toLowerCase();
|
|
1307
|
+
return rules.some((rule) => {
|
|
1308
|
+
if (url.port !== rule.port) return false;
|
|
1309
|
+
if (hostname === rule.host) return true;
|
|
1310
|
+
return rule.subdomains && hostname.endsWith(`.${rule.host}`);
|
|
1311
|
+
});
|
|
1287
1312
|
}
|
|
1288
|
-
function
|
|
1313
|
+
function assertFetchableUrl(ctx, clientId) {
|
|
1314
|
+
let url;
|
|
1315
|
+
try {
|
|
1316
|
+
url = new URL(clientId);
|
|
1317
|
+
} catch {
|
|
1318
|
+
throw refuse(`client_id is not a valid URL: '${clientId}'`);
|
|
1319
|
+
}
|
|
1320
|
+
if (url.protocol !== "https:") {
|
|
1321
|
+
throw refuse(`client_id metadata must be served over https (got '${url.protocol}')`);
|
|
1322
|
+
}
|
|
1323
|
+
if (url.username || url.password) {
|
|
1324
|
+
throw refuse("client_id must not contain URL credentials");
|
|
1325
|
+
}
|
|
1326
|
+
if (url.hash) {
|
|
1327
|
+
throw refuse("client_id must not contain a fragment");
|
|
1328
|
+
}
|
|
1329
|
+
if (!hostAllowed(ctx.cimd.allowedHosts, url)) {
|
|
1330
|
+
throw refuse(`client_id host '${url.host}' is not in clientIdMetadata.allowedHosts`);
|
|
1331
|
+
}
|
|
1332
|
+
return url;
|
|
1333
|
+
}
|
|
1334
|
+
var JSON_CONTENT_TYPE = /^application\/(?:[\w.+-]+\+)?json\s*(?:;|$)/i;
|
|
1335
|
+
async function readCapped(res, maxBytes, docSubject) {
|
|
1336
|
+
const body = res.body;
|
|
1337
|
+
if (!body) throw refuse(`${docSubject} was empty`);
|
|
1338
|
+
const reader = body.getReader();
|
|
1339
|
+
const chunks = [];
|
|
1340
|
+
let total = 0;
|
|
1341
|
+
for (; ; ) {
|
|
1342
|
+
const { done, value } = await reader.read();
|
|
1343
|
+
if (done) break;
|
|
1344
|
+
if (!value) continue;
|
|
1345
|
+
total += value.byteLength;
|
|
1346
|
+
if (total > maxBytes) {
|
|
1347
|
+
void reader.cancel();
|
|
1348
|
+
throw refuse(`${docSubject} exceeds ${maxBytes} bytes`);
|
|
1349
|
+
}
|
|
1350
|
+
chunks.push(Buffer.from(value));
|
|
1351
|
+
}
|
|
1352
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1353
|
+
}
|
|
1354
|
+
async function hardenedFetch(ctx, url, opts) {
|
|
1355
|
+
const { subject, redirectAdvice, etag } = opts;
|
|
1356
|
+
const docSubject = `${subject} document`;
|
|
1357
|
+
let res;
|
|
1358
|
+
try {
|
|
1359
|
+
res = await fetch(url.toString(), {
|
|
1360
|
+
method: "GET",
|
|
1361
|
+
// The single most important line in this file. A 3xx is a failure below,
|
|
1362
|
+
// not a hop: following redirects would let an allowlisted host forward us
|
|
1363
|
+
// to any address it likes, allowlist intact.
|
|
1364
|
+
redirect: "manual",
|
|
1365
|
+
headers: {
|
|
1366
|
+
accept: "application/json",
|
|
1367
|
+
...etag ? { "if-none-match": etag } : {}
|
|
1368
|
+
},
|
|
1369
|
+
signal: AbortSignal.timeout(ctx.cimd.fetchTimeoutMs)
|
|
1370
|
+
});
|
|
1371
|
+
} catch (err) {
|
|
1372
|
+
const name = err?.name;
|
|
1373
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
1374
|
+
throw refuse(`${subject} fetch timed out after ${ctx.cimd.fetchTimeoutMs}ms`);
|
|
1375
|
+
}
|
|
1376
|
+
throw refuse(`${subject} could not be fetched: ${err?.message ?? "network error"}`);
|
|
1377
|
+
}
|
|
1378
|
+
if (res.status === 304) return { document: null };
|
|
1379
|
+
if (res.status >= 300 && res.status < 400) {
|
|
1380
|
+
throw refuse(`${subject} returned a ${res.status} redirect, which is not followed \u2014 ${redirectAdvice}`);
|
|
1381
|
+
}
|
|
1382
|
+
if (res.status !== 200) {
|
|
1383
|
+
throw refuse(`${subject} returned HTTP ${res.status}`);
|
|
1384
|
+
}
|
|
1385
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
1386
|
+
if (!JSON_CONTENT_TYPE.test(contentType)) {
|
|
1387
|
+
throw refuse(`${subject} must be JSON (got content-type '${contentType || "none"}')`);
|
|
1388
|
+
}
|
|
1389
|
+
const text = await readCapped(res, ctx.cimd.maxBytes, docSubject);
|
|
1390
|
+
let parsed;
|
|
1391
|
+
try {
|
|
1392
|
+
parsed = JSON.parse(text);
|
|
1393
|
+
} catch {
|
|
1394
|
+
throw refuse(`${docSubject} is not valid JSON`);
|
|
1395
|
+
}
|
|
1396
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1397
|
+
throw refuse(`${docSubject} must be a JSON object`);
|
|
1398
|
+
}
|
|
1289
1399
|
return {
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
type: doc.type,
|
|
1293
|
-
// Defaulted rather than read straight through: rows written before the
|
|
1294
|
-
// field existed have no value, and `undefined` here would read as "unknown
|
|
1295
|
-
// provenance" on a client that plainly was registered by hand.
|
|
1296
|
-
registration: doc.registration ?? "manual",
|
|
1297
|
-
...doc.metadataUrl ? { metadataUrl: doc.metadataUrl } : {},
|
|
1298
|
-
trusted: Boolean(doc.trusted),
|
|
1299
|
-
redirectUris: [...doc.redirectUris ?? []],
|
|
1300
|
-
allowedScopes: [...doc.allowedScopes ?? []],
|
|
1301
|
-
allowedResources: [...doc.allowedResources ?? []],
|
|
1302
|
-
branding: { ...doc.branding ?? {} },
|
|
1303
|
-
status: doc.status,
|
|
1304
|
-
createdAt: doc.createdAt
|
|
1400
|
+
document: parsed,
|
|
1401
|
+
...res.headers.get("etag") ? { etag: res.headers.get("etag") } : {}
|
|
1305
1402
|
};
|
|
1306
1403
|
}
|
|
1307
|
-
function
|
|
1308
|
-
|
|
1309
|
-
|
|
1404
|
+
async function fetchMetadata(ctx, url, etag) {
|
|
1405
|
+
return hardenedFetch(ctx, url, {
|
|
1406
|
+
subject: "client_id metadata",
|
|
1407
|
+
redirectAdvice: "serve the document at the client_id URL itself",
|
|
1408
|
+
etag
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
async function fetchJwks(ctx, jwksUri) {
|
|
1412
|
+
const url = new URL(jwksUri);
|
|
1413
|
+
const { document } = await hardenedFetch(ctx, url, {
|
|
1414
|
+
subject: "jwks_uri",
|
|
1415
|
+
redirectAdvice: "serve the JWK Set at the jwks_uri URL itself"
|
|
1416
|
+
});
|
|
1417
|
+
if (!document) throw refuse("jwks_uri fetch returned no document");
|
|
1418
|
+
return document;
|
|
1419
|
+
}
|
|
1420
|
+
var SERVER_CIMD_AUTH_METHODS = ["none", "private_key_jwt"];
|
|
1421
|
+
function assertJwksUri(ctx, value) {
|
|
1422
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
1423
|
+
throw refuse("client_id metadata field 'jwks_uri' must be a non-empty string");
|
|
1310
1424
|
}
|
|
1311
1425
|
let url;
|
|
1312
1426
|
try {
|
|
1313
1427
|
url = new URL(value);
|
|
1314
1428
|
} catch {
|
|
1315
|
-
throw
|
|
1429
|
+
throw refuse(`client_id metadata field 'jwks_uri' is not a valid URL: '${value}'`);
|
|
1316
1430
|
}
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
);
|
|
1431
|
+
if (url.protocol !== "https:") {
|
|
1432
|
+
throw refuse(`client_id metadata field 'jwks_uri' must be served over https (got '${url.protocol}')`);
|
|
1433
|
+
}
|
|
1434
|
+
if (url.username || url.password) {
|
|
1435
|
+
throw refuse("client_id metadata field 'jwks_uri' must not contain URL credentials");
|
|
1322
1436
|
}
|
|
1323
1437
|
if (url.hash) {
|
|
1324
|
-
throw
|
|
1438
|
+
throw refuse("client_id metadata field 'jwks_uri' must not contain a fragment");
|
|
1325
1439
|
}
|
|
1326
|
-
|
|
1327
|
-
}
|
|
1328
|
-
function assertRedirectUris(input) {
|
|
1329
|
-
if (!Array.isArray(input) || input.length === 0) {
|
|
1330
|
-
throw new TypeError("oauth-host: a client needs at least one redirectUri");
|
|
1440
|
+
if (!hostAllowed(ctx.cimd.allowedHosts, url)) {
|
|
1441
|
+
throw refuse(`client_id metadata field 'jwks_uri' host '${url.host}' is not in clientIdMetadata.allowedHosts`);
|
|
1331
1442
|
}
|
|
1332
|
-
return
|
|
1443
|
+
return url.href;
|
|
1333
1444
|
}
|
|
1334
|
-
function
|
|
1335
|
-
if (!
|
|
1336
|
-
throw
|
|
1445
|
+
function assertInlineJwks(value) {
|
|
1446
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1447
|
+
throw refuse("client_id metadata field 'jwks' must be a JSON object");
|
|
1337
1448
|
}
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1449
|
+
const keys = value.keys;
|
|
1450
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
1451
|
+
throw refuse("client_id metadata field 'jwks' must contain a non-empty 'keys' array");
|
|
1452
|
+
}
|
|
1453
|
+
for (const key of keys) {
|
|
1454
|
+
if (!key || typeof key !== "object" || Array.isArray(key)) {
|
|
1455
|
+
throw refuse("client_id metadata field 'jwks' contains a non-object entry in 'keys'");
|
|
1343
1456
|
}
|
|
1344
1457
|
}
|
|
1345
|
-
return
|
|
1458
|
+
return value;
|
|
1346
1459
|
}
|
|
1347
|
-
function
|
|
1348
|
-
const
|
|
1349
|
-
if (
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
for (const id of input) {
|
|
1354
|
-
if (typeof id !== "string" || !declared.has(id)) {
|
|
1355
|
-
throw new TypeError(
|
|
1356
|
-
`oauth-host: allowedResources contains '${String(id)}', which is not a configured resource`
|
|
1460
|
+
function offeredAuthMethods(doc) {
|
|
1461
|
+
const plural = doc.token_endpoint_auth_methods_supported;
|
|
1462
|
+
if (plural !== void 0) {
|
|
1463
|
+
if (!Array.isArray(plural) || plural.length === 0 || !plural.every((entry) => typeof entry === "string" && entry.length > 0)) {
|
|
1464
|
+
throw refuse(
|
|
1465
|
+
"client_id metadata field 'token_endpoint_auth_methods_supported' must be a non-empty array of strings"
|
|
1357
1466
|
);
|
|
1358
1467
|
}
|
|
1468
|
+
return [...new Set(plural)];
|
|
1359
1469
|
}
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
`oauth-host: client type must be 'confidential' or 'public' (got ${JSON.stringify(value)})`
|
|
1367
|
-
);
|
|
1470
|
+
const singular = doc.token_endpoint_auth_method;
|
|
1471
|
+
if (singular !== void 0) {
|
|
1472
|
+
if (typeof singular !== "string" || !singular) {
|
|
1473
|
+
throw refuse("client_id metadata field 'token_endpoint_auth_method' must be a string");
|
|
1474
|
+
}
|
|
1475
|
+
return [singular];
|
|
1368
1476
|
}
|
|
1369
|
-
return
|
|
1477
|
+
return ["none"];
|
|
1370
1478
|
}
|
|
1371
|
-
function
|
|
1479
|
+
function requireString(doc, field) {
|
|
1480
|
+
const value = doc[field];
|
|
1372
1481
|
if (typeof value !== "string" || !value.trim()) {
|
|
1373
|
-
throw
|
|
1482
|
+
throw refuse(`client_id metadata field '${field}' must be a non-empty string`);
|
|
1374
1483
|
}
|
|
1375
1484
|
return value.trim();
|
|
1376
1485
|
}
|
|
1377
|
-
|
|
1378
|
-
const
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1486
|
+
function optionalHttpsUri(doc, field) {
|
|
1487
|
+
const value = doc[field];
|
|
1488
|
+
if (value === void 0 || value === null) return void 0;
|
|
1489
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
1490
|
+
throw refuse(`client_id metadata field '${field}' must be a string`);
|
|
1491
|
+
}
|
|
1492
|
+
let url;
|
|
1493
|
+
try {
|
|
1494
|
+
url = new URL(value);
|
|
1495
|
+
} catch {
|
|
1496
|
+
throw refuse(`client_id metadata field '${field}' must be an absolute URL: '${value}'`);
|
|
1497
|
+
}
|
|
1498
|
+
if (url.protocol !== "https:") {
|
|
1499
|
+
throw refuse(`client_id metadata field '${field}' must be https (got '${url.protocol}')`);
|
|
1500
|
+
}
|
|
1501
|
+
return value;
|
|
1383
1502
|
}
|
|
1384
|
-
|
|
1385
|
-
const
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
return
|
|
1503
|
+
function assertRedirectUris(doc) {
|
|
1504
|
+
const value = doc.redirect_uris;
|
|
1505
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
1506
|
+
throw refuse("client_id metadata field 'redirect_uris' must be a non-empty array");
|
|
1507
|
+
}
|
|
1508
|
+
return value.map((entry) => {
|
|
1509
|
+
if (typeof entry !== "string" || !entry) {
|
|
1510
|
+
throw refuse(`client_id metadata 'redirect_uris' entries must be strings (got ${JSON.stringify(entry)})`);
|
|
1511
|
+
}
|
|
1512
|
+
let url;
|
|
1513
|
+
try {
|
|
1514
|
+
url = new URL(entry);
|
|
1515
|
+
} catch {
|
|
1516
|
+
throw refuse(`client_id metadata 'redirect_uris' entry must be an absolute URI: '${entry}'`);
|
|
1517
|
+
}
|
|
1518
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
1519
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
1520
|
+
throw refuse(
|
|
1521
|
+
`client_id metadata 'redirect_uris' entry must be https (http is allowed only on localhost/127.0.0.1): '${entry}'`
|
|
1522
|
+
);
|
|
1523
|
+
}
|
|
1524
|
+
if (url.hash) {
|
|
1525
|
+
throw refuse(`client_id metadata 'redirect_uris' entry must not contain a fragment: '${entry}'`);
|
|
1526
|
+
}
|
|
1527
|
+
return entry;
|
|
1528
|
+
});
|
|
1390
1529
|
}
|
|
1391
|
-
function
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1530
|
+
function validateMetadataDocument(ctx, url, doc) {
|
|
1531
|
+
const declared = requireString(doc, "client_id");
|
|
1532
|
+
let declaredUrl;
|
|
1533
|
+
try {
|
|
1534
|
+
declaredUrl = new URL(declared);
|
|
1535
|
+
} catch {
|
|
1536
|
+
throw refuse(`client_id metadata field 'client_id' is not a URL: '${declared}'`);
|
|
1537
|
+
}
|
|
1538
|
+
if (declaredUrl.href !== url.href) {
|
|
1539
|
+
throw refuse(
|
|
1540
|
+
`client_id metadata field 'client_id' is '${declared}', which is not the URL it was fetched from ('${url.href}')`
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1543
|
+
const offered = offeredAuthMethods(doc);
|
|
1544
|
+
let usableMethods = offered.filter((m) => SERVER_CIMD_AUTH_METHODS.includes(m));
|
|
1545
|
+
if (usableMethods.length === 0) {
|
|
1546
|
+
throw refuse(
|
|
1547
|
+
`client_id metadata offers token endpoint auth methods [${offered.join(", ")}]; this server supports [${SERVER_CIMD_AUTH_METHODS.join(", ")}] for CIMD clients \u2014 no usable method in the intersection`
|
|
1548
|
+
);
|
|
1549
|
+
}
|
|
1550
|
+
let jwksUri;
|
|
1551
|
+
let jwks;
|
|
1552
|
+
if (usableMethods.includes("private_key_jwt")) {
|
|
1553
|
+
const hasJwksUri = doc.jwks_uri !== void 0 && doc.jwks_uri !== null;
|
|
1554
|
+
const hasJwks = doc.jwks !== void 0 && doc.jwks !== null;
|
|
1555
|
+
if (hasJwksUri && hasJwks) {
|
|
1556
|
+
throw refuse("client_id metadata must not contain both 'jwks' and 'jwks_uri'");
|
|
1395
1557
|
}
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1558
|
+
if (hasJwksUri) {
|
|
1559
|
+
jwksUri = assertJwksUri(ctx, doc.jwks_uri);
|
|
1560
|
+
} else if (hasJwks) {
|
|
1561
|
+
jwks = assertInlineJwks(doc.jwks);
|
|
1562
|
+
} else if (usableMethods.includes("none")) {
|
|
1563
|
+
usableMethods = usableMethods.filter((m) => m !== "private_key_jwt");
|
|
1564
|
+
} else {
|
|
1565
|
+
throw refuse("client_id metadata offers private_key_jwt but carries no 'jwks' or 'jwks_uri'");
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
const name = requireString(doc, "client_name");
|
|
1569
|
+
const redirectUris = assertRedirectUris(doc);
|
|
1570
|
+
const permitted = new Set(ctx.cimd.allowedScopes.filter((id) => ctx.scopeIndex.has(id)));
|
|
1571
|
+
let allowedScopes;
|
|
1572
|
+
if (doc.scope === void 0 || doc.scope === null) {
|
|
1573
|
+
allowedScopes = [...permitted];
|
|
1574
|
+
} else {
|
|
1575
|
+
if (typeof doc.scope !== "string") {
|
|
1576
|
+
throw refuse("client_id metadata field 'scope' must be a space-delimited string");
|
|
1577
|
+
}
|
|
1578
|
+
const requested = new Set(doc.scope.split(/\s+/).filter(Boolean));
|
|
1579
|
+
allowedScopes = [...permitted].filter((id) => requested.has(id));
|
|
1580
|
+
}
|
|
1581
|
+
if (allowedScopes.length === 0) {
|
|
1582
|
+
throw refuse(
|
|
1583
|
+
"client_id metadata field 'scope' has no overlap with what this server offers \u2014 there is nothing this client could be granted"
|
|
1584
|
+
);
|
|
1585
|
+
}
|
|
1586
|
+
const branding = {};
|
|
1587
|
+
const logoUrl = optionalHttpsUri(doc, "logo_uri");
|
|
1588
|
+
const homepageUrl = optionalHttpsUri(doc, "client_uri");
|
|
1589
|
+
const tosUrl = optionalHttpsUri(doc, "tos_uri");
|
|
1590
|
+
const privacyUrl = optionalHttpsUri(doc, "policy_uri");
|
|
1591
|
+
if (logoUrl) branding.logoUrl = logoUrl;
|
|
1592
|
+
if (homepageUrl) branding.homepageUrl = homepageUrl;
|
|
1593
|
+
if (tosUrl) branding.tosUrl = tosUrl;
|
|
1594
|
+
if (privacyUrl) branding.privacyUrl = privacyUrl;
|
|
1595
|
+
return {
|
|
1596
|
+
name,
|
|
1597
|
+
redirectUris,
|
|
1598
|
+
allowedScopes,
|
|
1599
|
+
branding,
|
|
1600
|
+
usableMethods,
|
|
1601
|
+
...jwksUri ? { jwksUri } : {},
|
|
1602
|
+
...jwks ? { jwks } : {}
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
function rememberFailure(ctx, key, message) {
|
|
1606
|
+
const { failures } = ctx.cimd;
|
|
1607
|
+
if (failures.size >= MAX_REMEMBERED_FAILURES) {
|
|
1608
|
+
const oldest = failures.keys().next();
|
|
1609
|
+
if (!oldest.done) failures.delete(oldest.value);
|
|
1610
|
+
}
|
|
1611
|
+
failures.set(key, { until: Date.now() + NEGATIVE_TTL_MS, message });
|
|
1612
|
+
}
|
|
1613
|
+
function rememberedFailure(ctx, key) {
|
|
1614
|
+
const found = ctx.cimd.failures.get(key);
|
|
1615
|
+
if (!found) return null;
|
|
1616
|
+
if (found.until <= Date.now()) {
|
|
1617
|
+
ctx.cimd.failures.delete(key);
|
|
1618
|
+
return null;
|
|
1619
|
+
}
|
|
1620
|
+
return found.message;
|
|
1621
|
+
}
|
|
1622
|
+
function isFresh(ctx, client) {
|
|
1623
|
+
const fetchedAt = client.metadataFetchedAt?.getTime();
|
|
1624
|
+
if (fetchedAt === void 0) return false;
|
|
1625
|
+
return Date.now() - fetchedAt < ctx.cimd.cacheTtlMs;
|
|
1626
|
+
}
|
|
1627
|
+
async function persist(ctx, clientId, url, registration, etag) {
|
|
1628
|
+
const unset = {};
|
|
1629
|
+
const jwksFields = {};
|
|
1630
|
+
if (registration.jwksUri) jwksFields.jwksUri = registration.jwksUri;
|
|
1631
|
+
else unset.jwksUri = "";
|
|
1632
|
+
if (registration.jwks) jwksFields.jwks = registration.jwks;
|
|
1633
|
+
else unset.jwks = "";
|
|
1634
|
+
const doc = await ctx.models.Client.findOneAndUpdate(
|
|
1635
|
+
{ clientId },
|
|
1636
|
+
{
|
|
1637
|
+
$set: {
|
|
1638
|
+
name: registration.name,
|
|
1639
|
+
redirectUris: registration.redirectUris,
|
|
1640
|
+
allowedScopes: registration.allowedScopes,
|
|
1641
|
+
// Every declared resource. A CIMD client cannot express an audience
|
|
1642
|
+
// preference, and RFC 8707 validation at `/authorize` narrows it anyway.
|
|
1643
|
+
allowedResources: ctx.resources.map((r) => r.id),
|
|
1644
|
+
branding: registration.branding,
|
|
1645
|
+
metadataUrl: url.href,
|
|
1646
|
+
metadataFetchedAt: /* @__PURE__ */ new Date(),
|
|
1647
|
+
tokenEndpointAuthMethods: registration.usableMethods,
|
|
1648
|
+
...jwksFields,
|
|
1649
|
+
...etag ? { metadataEtag: etag } : {}
|
|
1650
|
+
},
|
|
1651
|
+
...Object.keys(unset).length ? { $unset: unset } : {},
|
|
1652
|
+
$setOnInsert: {
|
|
1653
|
+
type: "public",
|
|
1654
|
+
registration: "cimd",
|
|
1655
|
+
trusted: false,
|
|
1656
|
+
secrets: [],
|
|
1657
|
+
status: "active"
|
|
1658
|
+
}
|
|
1659
|
+
},
|
|
1660
|
+
{ upsert: true, returnDocument: "after", setDefaultsOnInsert: false }
|
|
1661
|
+
).exec();
|
|
1662
|
+
return doc;
|
|
1663
|
+
}
|
|
1664
|
+
async function resolveCimdClient(ctx, clientId) {
|
|
1665
|
+
const url = assertFetchableUrl(ctx, clientId);
|
|
1666
|
+
const existing = await ctx.models.Client.findOne({ clientId }).exec();
|
|
1667
|
+
if (existing) {
|
|
1668
|
+
if (existing.status !== "active") {
|
|
1669
|
+
throw refuse(`client '${clientId}' is disabled`);
|
|
1670
|
+
}
|
|
1671
|
+
if (existing.registration !== "cimd") return existing;
|
|
1672
|
+
if (isFresh(ctx, existing)) return existing;
|
|
1673
|
+
}
|
|
1674
|
+
const remembered = rememberedFailure(ctx, url.href);
|
|
1675
|
+
if (remembered) throw refuse(remembered);
|
|
1676
|
+
try {
|
|
1677
|
+
const { document, etag } = await fetchMetadata(ctx, url, existing?.metadataEtag);
|
|
1678
|
+
if (!document) {
|
|
1679
|
+
if (existing) {
|
|
1680
|
+
existing.metadataFetchedAt = /* @__PURE__ */ new Date();
|
|
1681
|
+
await existing.save();
|
|
1682
|
+
return existing;
|
|
1683
|
+
}
|
|
1684
|
+
throw refuse("client_id metadata returned 304 with nothing cached to answer from");
|
|
1685
|
+
}
|
|
1686
|
+
const registration = validateMetadataDocument(ctx, url, document);
|
|
1687
|
+
const client = await persist(ctx, clientId, url, registration, etag);
|
|
1688
|
+
ctx.logger.debug?.({ clientId }, "oauth-host: client_id metadata document resolved");
|
|
1689
|
+
return client;
|
|
1690
|
+
} catch (err) {
|
|
1691
|
+
const message = err instanceof UnredirectableError ? err.description ?? "client_id metadata could not be resolved" : "client_id metadata could not be resolved";
|
|
1692
|
+
rememberFailure(ctx, url.href, message);
|
|
1693
|
+
ctx.logger.warn?.({ clientId, err }, "oauth-host: client_id metadata resolution failed");
|
|
1694
|
+
throw err instanceof UnredirectableError ? err : refuse(message);
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// src/server/services/admin.ts
|
|
1699
|
+
var MAX_LIST = 200;
|
|
1700
|
+
var DEFAULT_LIST = 50;
|
|
1701
|
+
function clampLimit(requested) {
|
|
1702
|
+
const n = typeof requested === "number" && Number.isFinite(requested) ? Math.floor(requested) : DEFAULT_LIST;
|
|
1703
|
+
if (n < 1) return 1;
|
|
1704
|
+
return Math.min(n, MAX_LIST);
|
|
1705
|
+
}
|
|
1706
|
+
function clampSkip(requested) {
|
|
1707
|
+
const n = typeof requested === "number" && Number.isFinite(requested) ? Math.floor(requested) : 0;
|
|
1708
|
+
return n > 0 ? n : 0;
|
|
1709
|
+
}
|
|
1710
|
+
function notFound(clientId) {
|
|
1711
|
+
return new Error(`oauth-host: no client registered with clientId '${clientId}'`);
|
|
1712
|
+
}
|
|
1713
|
+
function toPublicClient(doc) {
|
|
1714
|
+
return {
|
|
1715
|
+
clientId: doc.clientId,
|
|
1716
|
+
name: doc.name,
|
|
1717
|
+
type: doc.type,
|
|
1718
|
+
// Defaulted rather than read straight through: rows written before the
|
|
1719
|
+
// field existed have no value, and `undefined` here would read as "unknown
|
|
1720
|
+
// provenance" on a client that plainly was registered by hand.
|
|
1721
|
+
registration: doc.registration ?? "manual",
|
|
1722
|
+
...doc.metadataUrl ? { metadataUrl: doc.metadataUrl } : {},
|
|
1723
|
+
trusted: Boolean(doc.trusted),
|
|
1724
|
+
redirectUris: [...doc.redirectUris ?? []],
|
|
1725
|
+
allowedScopes: [...doc.allowedScopes ?? []],
|
|
1726
|
+
allowedResources: [...doc.allowedResources ?? []],
|
|
1727
|
+
branding: { ...doc.branding ?? {} },
|
|
1728
|
+
status: doc.status,
|
|
1729
|
+
createdAt: doc.createdAt
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1732
|
+
function assertRedirectUri(value) {
|
|
1733
|
+
if (typeof value !== "string" || !value) {
|
|
1734
|
+
throw new TypeError(`oauth-host: every redirectUri must be a string (got ${JSON.stringify(value)})`);
|
|
1735
|
+
}
|
|
1736
|
+
let url;
|
|
1737
|
+
try {
|
|
1738
|
+
url = new URL(value);
|
|
1739
|
+
} catch {
|
|
1740
|
+
throw new TypeError(`oauth-host: redirectUri must be an absolute URI: '${value}'`);
|
|
1741
|
+
}
|
|
1742
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
1743
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
1744
|
+
throw new TypeError(
|
|
1745
|
+
`oauth-host: redirectUri must be https (http is allowed only on localhost/127.0.0.1): '${value}'`
|
|
1746
|
+
);
|
|
1747
|
+
}
|
|
1748
|
+
if (url.hash) {
|
|
1749
|
+
throw new TypeError(`oauth-host: redirectUri must not contain a fragment: '${value}'`);
|
|
1750
|
+
}
|
|
1751
|
+
return value;
|
|
1752
|
+
}
|
|
1753
|
+
function assertRedirectUris2(input) {
|
|
1754
|
+
if (!Array.isArray(input) || input.length === 0) {
|
|
1755
|
+
throw new TypeError("oauth-host: a client needs at least one redirectUri");
|
|
1756
|
+
}
|
|
1757
|
+
return input.map(assertRedirectUri);
|
|
1758
|
+
}
|
|
1759
|
+
function assertScopes(ctx, input) {
|
|
1760
|
+
if (!Array.isArray(input) || input.length === 0) {
|
|
1761
|
+
throw new TypeError("oauth-host: a client needs at least one entry in allowedScopes");
|
|
1762
|
+
}
|
|
1763
|
+
for (const id of input) {
|
|
1764
|
+
if (typeof id !== "string" || !ctx.scopeIndex.has(id)) {
|
|
1765
|
+
throw new TypeError(
|
|
1766
|
+
`oauth-host: allowedScopes contains '${String(id)}', which is not in the configured scope catalog`
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
return [...input];
|
|
1771
|
+
}
|
|
1772
|
+
function assertResources(ctx, input) {
|
|
1773
|
+
const declared = new Set(ctx.resources.map((r) => r.id));
|
|
1774
|
+
if (input === void 0) return ctx.resources.map((r) => r.id);
|
|
1775
|
+
if (!Array.isArray(input) || input.length === 0) {
|
|
1776
|
+
throw new TypeError("oauth-host: allowedResources, when given, must list at least one resource");
|
|
1777
|
+
}
|
|
1778
|
+
for (const id of input) {
|
|
1779
|
+
if (typeof id !== "string" || !declared.has(id)) {
|
|
1780
|
+
throw new TypeError(
|
|
1781
|
+
`oauth-host: allowedResources contains '${String(id)}', which is not a configured resource`
|
|
1782
|
+
);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
return [...input];
|
|
1786
|
+
}
|
|
1787
|
+
function assertClientType(value) {
|
|
1788
|
+
if (value === void 0) return "confidential";
|
|
1789
|
+
if (value !== "confidential" && value !== "public") {
|
|
1790
|
+
throw new TypeError(
|
|
1791
|
+
`oauth-host: client type must be 'confidential' or 'public' (got ${JSON.stringify(value)})`
|
|
1792
|
+
);
|
|
1793
|
+
}
|
|
1794
|
+
return value;
|
|
1795
|
+
}
|
|
1796
|
+
function assertName(value) {
|
|
1797
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
1798
|
+
throw new TypeError("oauth-host: a client needs a non-empty `name` \u2014 it is what the consent screen shows");
|
|
1799
|
+
}
|
|
1800
|
+
return value.trim();
|
|
1801
|
+
}
|
|
1802
|
+
async function revokeGrantsMatching(ctx, filter, by) {
|
|
1803
|
+
const res = await ctx.models.Grant.updateMany(
|
|
1804
|
+
{ ...filter, revokedAt: null },
|
|
1805
|
+
{ $set: { revokedAt: /* @__PURE__ */ new Date(), revokedBy: by } }
|
|
1806
|
+
);
|
|
1807
|
+
return { grantsRevoked: res.modifiedCount ?? 0 };
|
|
1808
|
+
}
|
|
1809
|
+
async function revokeTokensMatching(ctx, filter) {
|
|
1810
|
+
const res = await ctx.models.Token.updateMany(
|
|
1811
|
+
{ ...filter, revokedAt: null },
|
|
1812
|
+
{ $set: { revokedAt: /* @__PURE__ */ new Date() } }
|
|
1813
|
+
);
|
|
1814
|
+
return res.modifiedCount ?? 0;
|
|
1815
|
+
}
|
|
1816
|
+
function createClientsApi(ctx) {
|
|
1817
|
+
async function create(spec) {
|
|
1818
|
+
if (!spec || typeof spec !== "object") {
|
|
1819
|
+
throw new TypeError("oauth-host: clients.create(spec) requires a spec object");
|
|
1820
|
+
}
|
|
1821
|
+
const name = assertName(spec.name);
|
|
1822
|
+
const type = assertClientType(spec.type);
|
|
1823
|
+
const redirectUris = assertRedirectUris2(spec.redirectUris);
|
|
1824
|
+
const allowedScopes = assertScopes(ctx, spec.allowedScopes);
|
|
1825
|
+
const allowedResources = assertResources(ctx, spec.allowedResources);
|
|
1826
|
+
const clientId = spec.clientId ?? generateClientId();
|
|
1402
1827
|
const clientSecret = type === "confidential" ? generateClientSecret() : void 0;
|
|
1403
1828
|
const doc = await ctx.models.Client.create({
|
|
1404
1829
|
clientId,
|
|
@@ -1455,7 +1880,7 @@ function createClientsApi(ctx) {
|
|
|
1455
1880
|
const doc = await ctx.models.Client.findOne({ clientId });
|
|
1456
1881
|
if (!doc) throw notFound(clientId);
|
|
1457
1882
|
if (patch.name !== void 0) doc.name = assertName(patch.name);
|
|
1458
|
-
if (patch.redirectUris !== void 0) doc.redirectUris =
|
|
1883
|
+
if (patch.redirectUris !== void 0) doc.redirectUris = assertRedirectUris2(patch.redirectUris);
|
|
1459
1884
|
if (patch.allowedScopes !== void 0) doc.allowedScopes = assertScopes(ctx, patch.allowedScopes);
|
|
1460
1885
|
if (patch.allowedResources !== void 0) {
|
|
1461
1886
|
doc.allowedResources = assertResources(ctx, patch.allowedResources);
|
|
@@ -1529,19 +1954,202 @@ function authFailure(viaBasic) {
|
|
|
1529
1954
|
headers: viaBasic ? { "WWW-Authenticate": 'Basic realm="oauth", charset="UTF-8"' } : {}
|
|
1530
1955
|
});
|
|
1531
1956
|
}
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1957
|
+
var CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
|
|
1958
|
+
var MAX_ASSERTION_BYTES = 8192;
|
|
1959
|
+
var COMPACT_JWS_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
1960
|
+
var ASSERTION_ALGORITHMS = ["RS256", "ES256"];
|
|
1961
|
+
var MAX_ASSERTION_AGE_S = 3600;
|
|
1962
|
+
var MAX_JTI_LEN = 256;
|
|
1963
|
+
var REPLAY_CACHE_MAX = 1e4;
|
|
1964
|
+
var JWKS_CACHE_MAX = 100;
|
|
1965
|
+
var JWKS_FORCE_REFETCH_INTERVAL_MS = 6e4;
|
|
1966
|
+
var ASSERTION_CLOCK_TOLERANCE_S = 60;
|
|
1967
|
+
function tokenEndpointUrl(ctx) {
|
|
1968
|
+
return `${ctx.issuer}${ctx.mountPath}/token`;
|
|
1969
|
+
}
|
|
1970
|
+
function isJwkSet(doc) {
|
|
1971
|
+
return Boolean(doc) && typeof doc === "object" && Array.isArray(doc.keys);
|
|
1972
|
+
}
|
|
1973
|
+
function assertionFailure(reason) {
|
|
1974
|
+
return new Error(reason);
|
|
1975
|
+
}
|
|
1976
|
+
function selectCandidateKeys(keys, header) {
|
|
1977
|
+
return keys.filter((key) => {
|
|
1978
|
+
if (header.kid !== void 0 && key.kid !== header.kid) return false;
|
|
1979
|
+
const use = key.use;
|
|
1980
|
+
if (use !== void 0 && use !== "sig") return false;
|
|
1981
|
+
const alg = key.alg;
|
|
1982
|
+
if (alg !== void 0 && alg !== header.alg) return false;
|
|
1983
|
+
return true;
|
|
1984
|
+
});
|
|
1985
|
+
}
|
|
1986
|
+
async function resolveJwksKeys(ctx, jwksUri, forceRefetch) {
|
|
1987
|
+
const { jwksCache, jwksForceRefetchAt } = ctx.cimd;
|
|
1542
1988
|
const now = Date.now();
|
|
1543
|
-
|
|
1544
|
-
|
|
1989
|
+
const cached = jwksCache.get(jwksUri);
|
|
1990
|
+
const fresh = cached !== void 0 && now - cached.fetchedAt < ctx.cimd.cacheTtlMs;
|
|
1991
|
+
if (fresh && !forceRefetch) return cached.keys;
|
|
1992
|
+
if (forceRefetch) {
|
|
1993
|
+
const lastForced = jwksForceRefetchAt.get(jwksUri) ?? 0;
|
|
1994
|
+
if (now - lastForced < JWKS_FORCE_REFETCH_INTERVAL_MS) {
|
|
1995
|
+
return cached?.keys ?? [];
|
|
1996
|
+
}
|
|
1997
|
+
jwksForceRefetchAt.set(jwksUri, now);
|
|
1998
|
+
}
|
|
1999
|
+
const doc = await fetchJwks(ctx, jwksUri);
|
|
2000
|
+
if (!isJwkSet(doc)) throw assertionFailure(`jwks_uri '${jwksUri}' did not serve a JWK Set`);
|
|
2001
|
+
if (!jwksCache.has(jwksUri) && jwksCache.size >= JWKS_CACHE_MAX) {
|
|
2002
|
+
const oldest = jwksCache.keys().next();
|
|
2003
|
+
if (!oldest.done) jwksCache.delete(oldest.value);
|
|
2004
|
+
}
|
|
2005
|
+
jwksCache.set(jwksUri, { keys: doc.keys, fetchedAt: now });
|
|
2006
|
+
return doc.keys;
|
|
2007
|
+
}
|
|
2008
|
+
async function loadClientJwks(ctx, client) {
|
|
2009
|
+
if (client.jwks !== void 0 && client.jwks !== null) {
|
|
2010
|
+
if (!isJwkSet(client.jwks)) {
|
|
2011
|
+
throw assertionFailure(`client '${client.clientId}' has a malformed stored 'jwks'`);
|
|
2012
|
+
}
|
|
2013
|
+
return client.jwks.keys;
|
|
2014
|
+
}
|
|
2015
|
+
if (client.jwksUri) {
|
|
2016
|
+
return resolveJwksKeys(ctx, client.jwksUri, false);
|
|
2017
|
+
}
|
|
2018
|
+
throw assertionFailure(`client '${client.clientId}' has neither 'jwks' nor 'jwksUri' stored`);
|
|
2019
|
+
}
|
|
2020
|
+
async function verifyClientAssertion(ctx, body) {
|
|
2021
|
+
if (body.client_assertion_type !== CLIENT_ASSERTION_TYPE) {
|
|
2022
|
+
throw assertionFailure(
|
|
2023
|
+
`client_assertion_type must be '${CLIENT_ASSERTION_TYPE}' (got ${JSON.stringify(body.client_assertion_type)})`
|
|
2024
|
+
);
|
|
2025
|
+
}
|
|
2026
|
+
const assertion = body.client_assertion;
|
|
2027
|
+
if (typeof assertion !== "string" || !assertion || Buffer.byteLength(assertion, "utf8") > MAX_ASSERTION_BYTES || !COMPACT_JWS_RE.test(assertion)) {
|
|
2028
|
+
throw assertionFailure("client_assertion is not a well-formed compact JWS");
|
|
2029
|
+
}
|
|
2030
|
+
if (typeof body.client_secret === "string" && body.client_secret) {
|
|
2031
|
+
throw assertionFailure("client_assertion was presented alongside a client_secret");
|
|
2032
|
+
}
|
|
2033
|
+
let unverified;
|
|
2034
|
+
try {
|
|
2035
|
+
unverified = decodeJwt(assertion);
|
|
2036
|
+
} catch {
|
|
2037
|
+
throw assertionFailure("client_assertion could not be decoded");
|
|
2038
|
+
}
|
|
2039
|
+
const iss = unverified.iss;
|
|
2040
|
+
if (typeof iss !== "string" || !iss) {
|
|
2041
|
+
throw assertionFailure("client_assertion is missing iss");
|
|
2042
|
+
}
|
|
2043
|
+
if (typeof body.client_id === "string" && body.client_id && body.client_id !== iss) {
|
|
2044
|
+
throw assertionFailure("client_assertion iss does not match client_id");
|
|
2045
|
+
}
|
|
2046
|
+
const client = await ctx.models.Client.findOne({ clientId: iss });
|
|
2047
|
+
if (!client || client.status !== "active" || !client.tokenEndpointAuthMethods?.includes("private_key_jwt")) {
|
|
2048
|
+
throw assertionFailure(`client '${iss}' does not support private_key_jwt`);
|
|
2049
|
+
}
|
|
2050
|
+
let header;
|
|
2051
|
+
try {
|
|
2052
|
+
header = decodeProtectedHeader(assertion);
|
|
2053
|
+
} catch {
|
|
2054
|
+
throw assertionFailure("client_assertion header could not be decoded");
|
|
2055
|
+
}
|
|
2056
|
+
const alg = header.alg;
|
|
2057
|
+
if (alg !== "RS256" && alg !== "ES256") {
|
|
2058
|
+
throw assertionFailure(`client_assertion alg '${String(alg)}' is not permitted`);
|
|
2059
|
+
}
|
|
2060
|
+
const stored = await loadClientJwks(ctx, client);
|
|
2061
|
+
let candidates = selectCandidateKeys(stored, { alg, kid: header.kid });
|
|
2062
|
+
if (candidates.length === 0 && client.jwksUri) {
|
|
2063
|
+
const refetched = await resolveJwksKeys(ctx, client.jwksUri, true);
|
|
2064
|
+
candidates = selectCandidateKeys(refetched, { alg, kid: header.kid });
|
|
2065
|
+
}
|
|
2066
|
+
if (candidates.length === 0) {
|
|
2067
|
+
throw assertionFailure(`no candidate key for kid '${String(header.kid)}'`);
|
|
2068
|
+
}
|
|
2069
|
+
const tokenEndpoint = tokenEndpointUrl(ctx);
|
|
2070
|
+
let verified;
|
|
2071
|
+
let lastJoseError;
|
|
2072
|
+
for (const jwk of candidates) {
|
|
2073
|
+
try {
|
|
2074
|
+
const key = await importJWK(jwk, alg);
|
|
2075
|
+
verified = await jwtVerify(assertion, key, {
|
|
2076
|
+
// Passed explicitly rather than trusting the header a second time —
|
|
2077
|
+
// this is the actual alg-confusion guard, not the earlier check,
|
|
2078
|
+
// which only short-circuits the common case before a fetch.
|
|
2079
|
+
algorithms: [...ASSERTION_ALGORITHMS],
|
|
2080
|
+
audience: [tokenEndpoint, ctx.issuer],
|
|
2081
|
+
clockTolerance: ASSERTION_CLOCK_TOLERANCE_S
|
|
2082
|
+
});
|
|
2083
|
+
break;
|
|
2084
|
+
} catch (err) {
|
|
2085
|
+
lastJoseError = err;
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
if (!verified) {
|
|
2089
|
+
throw assertionFailure(
|
|
2090
|
+
`client_assertion did not verify: ${lastJoseError instanceof Error ? lastJoseError.message : "unknown error"}`
|
|
2091
|
+
);
|
|
2092
|
+
}
|
|
2093
|
+
const { payload } = verified;
|
|
2094
|
+
if (payload.iss !== client.clientId || payload.sub !== client.clientId) {
|
|
2095
|
+
throw assertionFailure("client_assertion iss/sub must both equal the client_id");
|
|
2096
|
+
}
|
|
2097
|
+
const exp = payload.exp;
|
|
2098
|
+
const nowS = Math.floor(Date.now() / 1e3);
|
|
2099
|
+
if (typeof exp !== "number" || exp - nowS > MAX_ASSERTION_AGE_S) {
|
|
2100
|
+
throw assertionFailure("client_assertion exp is too far in the future");
|
|
2101
|
+
}
|
|
2102
|
+
const jti = payload.jti;
|
|
2103
|
+
if (typeof jti !== "string" || !jti || jti.length > MAX_JTI_LEN) {
|
|
2104
|
+
throw assertionFailure("client_assertion is missing jti");
|
|
2105
|
+
}
|
|
2106
|
+
const replay = ctx.cimd.assertionReplay;
|
|
2107
|
+
const replayKey = `${client.clientId}:${jti}`;
|
|
2108
|
+
const existing = replay.get(replayKey);
|
|
2109
|
+
if (existing !== void 0 && existing > Date.now()) {
|
|
2110
|
+
ctx.logger.warn?.({ clientId: client.clientId }, "oauth-host: private_key_jwt assertion replay detected");
|
|
2111
|
+
throw assertionFailure("client_assertion jti has already been used");
|
|
2112
|
+
}
|
|
2113
|
+
if (!replay.has(replayKey) && replay.size >= REPLAY_CACHE_MAX) {
|
|
2114
|
+
const oldest = replay.keys().next();
|
|
2115
|
+
if (!oldest.done) replay.delete(oldest.value);
|
|
2116
|
+
}
|
|
2117
|
+
replay.set(replayKey, (exp + 60) * 1e3);
|
|
2118
|
+
return client;
|
|
2119
|
+
}
|
|
2120
|
+
async function authenticateClient(ctx, req) {
|
|
2121
|
+
const body = req.body ?? {};
|
|
2122
|
+
const header = req.headers?.authorization;
|
|
2123
|
+
const viaBasic = typeof header === "string" && /^Basic /i.test(header);
|
|
2124
|
+
if (body.client_assertion !== void 0 || body.client_assertion_type !== void 0) {
|
|
2125
|
+
if (viaBasic) {
|
|
2126
|
+
ctx.logger.debug?.(
|
|
2127
|
+
"oauth-host: client_assertion presented alongside a Basic Authorization header"
|
|
2128
|
+
);
|
|
2129
|
+
throw authFailure(viaBasic);
|
|
2130
|
+
}
|
|
2131
|
+
try {
|
|
2132
|
+
return await verifyClientAssertion(ctx, body);
|
|
2133
|
+
} catch (err) {
|
|
2134
|
+
ctx.logger.debug?.(
|
|
2135
|
+
{ err: err instanceof Error ? err.message : err },
|
|
2136
|
+
"oauth-host: private_key_jwt assertion rejected"
|
|
2137
|
+
);
|
|
2138
|
+
throw authFailure(viaBasic);
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
const creds = readCredentials(req);
|
|
2142
|
+
if (!creds || !creds.clientId) throw authFailure(Boolean(creds?.viaBasic));
|
|
2143
|
+
const client = await ctx.models.Client.findOne({ clientId: creds.clientId });
|
|
2144
|
+
if (!client || client.status !== "active") throw authFailure(creds.viaBasic);
|
|
2145
|
+
if (client.type === "public") {
|
|
2146
|
+
if (creds.clientSecret !== null) throw authFailure(creds.viaBasic);
|
|
2147
|
+
return client;
|
|
2148
|
+
}
|
|
2149
|
+
if (creds.clientSecret === null) throw authFailure(creds.viaBasic);
|
|
2150
|
+
const now = Date.now();
|
|
2151
|
+
let matched;
|
|
2152
|
+
for (const record of client.secrets) {
|
|
1545
2153
|
if (record.retiresAt && record.retiresAt.getTime() <= now) continue;
|
|
1546
2154
|
if (safeEqual(sha256(creds.clientSecret), record.hash)) matched = record;
|
|
1547
2155
|
}
|
|
@@ -1736,297 +2344,6 @@ function createContextsApi(ctx) {
|
|
|
1736
2344
|
};
|
|
1737
2345
|
}
|
|
1738
2346
|
|
|
1739
|
-
// src/server/services/cimd.ts
|
|
1740
|
-
var NEGATIVE_TTL_MS = 6e4;
|
|
1741
|
-
var MAX_REMEMBERED_FAILURES = 1e3;
|
|
1742
|
-
function refuse(description) {
|
|
1743
|
-
return new UnredirectableError("invalid_client", description);
|
|
1744
|
-
}
|
|
1745
|
-
function isMetadataUrl(clientId) {
|
|
1746
|
-
return /^https:\/\//i.test(clientId);
|
|
1747
|
-
}
|
|
1748
|
-
function hostAllowed(rules, url) {
|
|
1749
|
-
const hostname = url.hostname.toLowerCase();
|
|
1750
|
-
return rules.some((rule) => {
|
|
1751
|
-
if (url.port !== rule.port) return false;
|
|
1752
|
-
if (hostname === rule.host) return true;
|
|
1753
|
-
return rule.subdomains && hostname.endsWith(`.${rule.host}`);
|
|
1754
|
-
});
|
|
1755
|
-
}
|
|
1756
|
-
function assertFetchableUrl(ctx, clientId) {
|
|
1757
|
-
let url;
|
|
1758
|
-
try {
|
|
1759
|
-
url = new URL(clientId);
|
|
1760
|
-
} catch {
|
|
1761
|
-
throw refuse(`client_id is not a valid URL: '${clientId}'`);
|
|
1762
|
-
}
|
|
1763
|
-
if (url.protocol !== "https:") {
|
|
1764
|
-
throw refuse(`client_id metadata must be served over https (got '${url.protocol}')`);
|
|
1765
|
-
}
|
|
1766
|
-
if (url.username || url.password) {
|
|
1767
|
-
throw refuse("client_id must not contain URL credentials");
|
|
1768
|
-
}
|
|
1769
|
-
if (url.hash) {
|
|
1770
|
-
throw refuse("client_id must not contain a fragment");
|
|
1771
|
-
}
|
|
1772
|
-
if (!hostAllowed(ctx.cimd.allowedHosts, url)) {
|
|
1773
|
-
throw refuse(`client_id host '${url.host}' is not in clientIdMetadata.allowedHosts`);
|
|
1774
|
-
}
|
|
1775
|
-
return url;
|
|
1776
|
-
}
|
|
1777
|
-
var JSON_CONTENT_TYPE = /^application\/(?:[\w.+-]+\+)?json\s*(?:;|$)/i;
|
|
1778
|
-
async function readCapped(res, maxBytes) {
|
|
1779
|
-
const body = res.body;
|
|
1780
|
-
if (!body) throw refuse("client_id metadata document was empty");
|
|
1781
|
-
const reader = body.getReader();
|
|
1782
|
-
const chunks = [];
|
|
1783
|
-
let total = 0;
|
|
1784
|
-
for (; ; ) {
|
|
1785
|
-
const { done, value } = await reader.read();
|
|
1786
|
-
if (done) break;
|
|
1787
|
-
if (!value) continue;
|
|
1788
|
-
total += value.byteLength;
|
|
1789
|
-
if (total > maxBytes) {
|
|
1790
|
-
void reader.cancel();
|
|
1791
|
-
throw refuse(`client_id metadata document exceeds ${maxBytes} bytes`);
|
|
1792
|
-
}
|
|
1793
|
-
chunks.push(Buffer.from(value));
|
|
1794
|
-
}
|
|
1795
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
1796
|
-
}
|
|
1797
|
-
async function fetchMetadata(ctx, url, etag) {
|
|
1798
|
-
let res;
|
|
1799
|
-
try {
|
|
1800
|
-
res = await fetch(url.toString(), {
|
|
1801
|
-
method: "GET",
|
|
1802
|
-
// The single most important line in this file. A 3xx is a failure below,
|
|
1803
|
-
// not a hop: following redirects would let an allowlisted host forward us
|
|
1804
|
-
// to any address it likes, allowlist intact.
|
|
1805
|
-
redirect: "manual",
|
|
1806
|
-
headers: {
|
|
1807
|
-
accept: "application/json",
|
|
1808
|
-
...etag ? { "if-none-match": etag } : {}
|
|
1809
|
-
},
|
|
1810
|
-
signal: AbortSignal.timeout(ctx.cimd.fetchTimeoutMs)
|
|
1811
|
-
});
|
|
1812
|
-
} catch (err) {
|
|
1813
|
-
const name = err?.name;
|
|
1814
|
-
if (name === "TimeoutError" || name === "AbortError") {
|
|
1815
|
-
throw refuse(`client_id metadata fetch timed out after ${ctx.cimd.fetchTimeoutMs}ms`);
|
|
1816
|
-
}
|
|
1817
|
-
throw refuse(`client_id metadata could not be fetched: ${err?.message ?? "network error"}`);
|
|
1818
|
-
}
|
|
1819
|
-
if (res.status === 304) return { document: null };
|
|
1820
|
-
if (res.status >= 300 && res.status < 400) {
|
|
1821
|
-
throw refuse(
|
|
1822
|
-
`client_id metadata returned a ${res.status} redirect, which is not followed \u2014 serve the document at the client_id URL itself`
|
|
1823
|
-
);
|
|
1824
|
-
}
|
|
1825
|
-
if (res.status !== 200) {
|
|
1826
|
-
throw refuse(`client_id metadata returned HTTP ${res.status}`);
|
|
1827
|
-
}
|
|
1828
|
-
const contentType = res.headers.get("content-type") ?? "";
|
|
1829
|
-
if (!JSON_CONTENT_TYPE.test(contentType)) {
|
|
1830
|
-
throw refuse(`client_id metadata must be JSON (got content-type '${contentType || "none"}')`);
|
|
1831
|
-
}
|
|
1832
|
-
const text = await readCapped(res, ctx.cimd.maxBytes);
|
|
1833
|
-
let parsed;
|
|
1834
|
-
try {
|
|
1835
|
-
parsed = JSON.parse(text);
|
|
1836
|
-
} catch {
|
|
1837
|
-
throw refuse("client_id metadata document is not valid JSON");
|
|
1838
|
-
}
|
|
1839
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1840
|
-
throw refuse("client_id metadata document must be a JSON object");
|
|
1841
|
-
}
|
|
1842
|
-
return {
|
|
1843
|
-
document: parsed,
|
|
1844
|
-
...res.headers.get("etag") ? { etag: res.headers.get("etag") } : {}
|
|
1845
|
-
};
|
|
1846
|
-
}
|
|
1847
|
-
function requireString(doc, field) {
|
|
1848
|
-
const value = doc[field];
|
|
1849
|
-
if (typeof value !== "string" || !value.trim()) {
|
|
1850
|
-
throw refuse(`client_id metadata field '${field}' must be a non-empty string`);
|
|
1851
|
-
}
|
|
1852
|
-
return value.trim();
|
|
1853
|
-
}
|
|
1854
|
-
function optionalHttpsUri(doc, field) {
|
|
1855
|
-
const value = doc[field];
|
|
1856
|
-
if (value === void 0 || value === null) return void 0;
|
|
1857
|
-
if (typeof value !== "string" || !value.trim()) {
|
|
1858
|
-
throw refuse(`client_id metadata field '${field}' must be a string`);
|
|
1859
|
-
}
|
|
1860
|
-
let url;
|
|
1861
|
-
try {
|
|
1862
|
-
url = new URL(value);
|
|
1863
|
-
} catch {
|
|
1864
|
-
throw refuse(`client_id metadata field '${field}' must be an absolute URL: '${value}'`);
|
|
1865
|
-
}
|
|
1866
|
-
if (url.protocol !== "https:") {
|
|
1867
|
-
throw refuse(`client_id metadata field '${field}' must be https (got '${url.protocol}')`);
|
|
1868
|
-
}
|
|
1869
|
-
return value;
|
|
1870
|
-
}
|
|
1871
|
-
function assertRedirectUris2(doc) {
|
|
1872
|
-
const value = doc.redirect_uris;
|
|
1873
|
-
if (!Array.isArray(value) || value.length === 0) {
|
|
1874
|
-
throw refuse("client_id metadata field 'redirect_uris' must be a non-empty array");
|
|
1875
|
-
}
|
|
1876
|
-
return value.map((entry) => {
|
|
1877
|
-
if (typeof entry !== "string" || !entry) {
|
|
1878
|
-
throw refuse(`client_id metadata 'redirect_uris' entries must be strings (got ${JSON.stringify(entry)})`);
|
|
1879
|
-
}
|
|
1880
|
-
let url;
|
|
1881
|
-
try {
|
|
1882
|
-
url = new URL(entry);
|
|
1883
|
-
} catch {
|
|
1884
|
-
throw refuse(`client_id metadata 'redirect_uris' entry must be an absolute URI: '${entry}'`);
|
|
1885
|
-
}
|
|
1886
|
-
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
1887
|
-
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
1888
|
-
throw refuse(
|
|
1889
|
-
`client_id metadata 'redirect_uris' entry must be https (http is allowed only on localhost/127.0.0.1): '${entry}'`
|
|
1890
|
-
);
|
|
1891
|
-
}
|
|
1892
|
-
if (url.hash) {
|
|
1893
|
-
throw refuse(`client_id metadata 'redirect_uris' entry must not contain a fragment: '${entry}'`);
|
|
1894
|
-
}
|
|
1895
|
-
return entry;
|
|
1896
|
-
});
|
|
1897
|
-
}
|
|
1898
|
-
function validateMetadataDocument(ctx, url, doc) {
|
|
1899
|
-
const declared = requireString(doc, "client_id");
|
|
1900
|
-
let declaredUrl;
|
|
1901
|
-
try {
|
|
1902
|
-
declaredUrl = new URL(declared);
|
|
1903
|
-
} catch {
|
|
1904
|
-
throw refuse(`client_id metadata field 'client_id' is not a URL: '${declared}'`);
|
|
1905
|
-
}
|
|
1906
|
-
if (declaredUrl.href !== url.href) {
|
|
1907
|
-
throw refuse(
|
|
1908
|
-
`client_id metadata field 'client_id' is '${declared}', which is not the URL it was fetched from ('${url.href}')`
|
|
1909
|
-
);
|
|
1910
|
-
}
|
|
1911
|
-
const method = doc.token_endpoint_auth_method;
|
|
1912
|
-
if (method !== void 0 && method !== "none") {
|
|
1913
|
-
throw refuse(
|
|
1914
|
-
`client_id metadata field 'token_endpoint_auth_method' must be 'none' (got ${JSON.stringify(method)}) \u2014 a CIMD client holds no secret`
|
|
1915
|
-
);
|
|
1916
|
-
}
|
|
1917
|
-
const name = requireString(doc, "client_name");
|
|
1918
|
-
const redirectUris = assertRedirectUris2(doc);
|
|
1919
|
-
const permitted = new Set(ctx.cimd.allowedScopes.filter((id) => ctx.scopeIndex.has(id)));
|
|
1920
|
-
let allowedScopes;
|
|
1921
|
-
if (doc.scope === void 0 || doc.scope === null) {
|
|
1922
|
-
allowedScopes = [...permitted];
|
|
1923
|
-
} else {
|
|
1924
|
-
if (typeof doc.scope !== "string") {
|
|
1925
|
-
throw refuse("client_id metadata field 'scope' must be a space-delimited string");
|
|
1926
|
-
}
|
|
1927
|
-
const requested = new Set(doc.scope.split(/\s+/).filter(Boolean));
|
|
1928
|
-
allowedScopes = [...permitted].filter((id) => requested.has(id));
|
|
1929
|
-
}
|
|
1930
|
-
if (allowedScopes.length === 0) {
|
|
1931
|
-
throw refuse(
|
|
1932
|
-
"client_id metadata field 'scope' has no overlap with what this server offers \u2014 there is nothing this client could be granted"
|
|
1933
|
-
);
|
|
1934
|
-
}
|
|
1935
|
-
const branding = {};
|
|
1936
|
-
const logoUrl = optionalHttpsUri(doc, "logo_uri");
|
|
1937
|
-
const homepageUrl = optionalHttpsUri(doc, "client_uri");
|
|
1938
|
-
const tosUrl = optionalHttpsUri(doc, "tos_uri");
|
|
1939
|
-
const privacyUrl = optionalHttpsUri(doc, "policy_uri");
|
|
1940
|
-
if (logoUrl) branding.logoUrl = logoUrl;
|
|
1941
|
-
if (homepageUrl) branding.homepageUrl = homepageUrl;
|
|
1942
|
-
if (tosUrl) branding.tosUrl = tosUrl;
|
|
1943
|
-
if (privacyUrl) branding.privacyUrl = privacyUrl;
|
|
1944
|
-
return { name, redirectUris, allowedScopes, branding };
|
|
1945
|
-
}
|
|
1946
|
-
function rememberFailure(ctx, key, message) {
|
|
1947
|
-
const { failures } = ctx.cimd;
|
|
1948
|
-
if (failures.size >= MAX_REMEMBERED_FAILURES) {
|
|
1949
|
-
const oldest = failures.keys().next();
|
|
1950
|
-
if (!oldest.done) failures.delete(oldest.value);
|
|
1951
|
-
}
|
|
1952
|
-
failures.set(key, { until: Date.now() + NEGATIVE_TTL_MS, message });
|
|
1953
|
-
}
|
|
1954
|
-
function rememberedFailure(ctx, key) {
|
|
1955
|
-
const found = ctx.cimd.failures.get(key);
|
|
1956
|
-
if (!found) return null;
|
|
1957
|
-
if (found.until <= Date.now()) {
|
|
1958
|
-
ctx.cimd.failures.delete(key);
|
|
1959
|
-
return null;
|
|
1960
|
-
}
|
|
1961
|
-
return found.message;
|
|
1962
|
-
}
|
|
1963
|
-
function isFresh(ctx, client) {
|
|
1964
|
-
const fetchedAt = client.metadataFetchedAt?.getTime();
|
|
1965
|
-
if (fetchedAt === void 0) return false;
|
|
1966
|
-
return Date.now() - fetchedAt < ctx.cimd.cacheTtlMs;
|
|
1967
|
-
}
|
|
1968
|
-
async function persist(ctx, clientId, url, registration, etag) {
|
|
1969
|
-
const doc = await ctx.models.Client.findOneAndUpdate(
|
|
1970
|
-
{ clientId },
|
|
1971
|
-
{
|
|
1972
|
-
$set: {
|
|
1973
|
-
name: registration.name,
|
|
1974
|
-
redirectUris: registration.redirectUris,
|
|
1975
|
-
allowedScopes: registration.allowedScopes,
|
|
1976
|
-
// Every declared resource. A CIMD client cannot express an audience
|
|
1977
|
-
// preference, and RFC 8707 validation at `/authorize` narrows it anyway.
|
|
1978
|
-
allowedResources: ctx.resources.map((r) => r.id),
|
|
1979
|
-
branding: registration.branding,
|
|
1980
|
-
metadataUrl: url.href,
|
|
1981
|
-
metadataFetchedAt: /* @__PURE__ */ new Date(),
|
|
1982
|
-
...etag ? { metadataEtag: etag } : {}
|
|
1983
|
-
},
|
|
1984
|
-
$setOnInsert: {
|
|
1985
|
-
type: "public",
|
|
1986
|
-
registration: "cimd",
|
|
1987
|
-
trusted: false,
|
|
1988
|
-
secrets: [],
|
|
1989
|
-
status: "active"
|
|
1990
|
-
}
|
|
1991
|
-
},
|
|
1992
|
-
{ upsert: true, returnDocument: "after", setDefaultsOnInsert: false }
|
|
1993
|
-
).exec();
|
|
1994
|
-
return doc;
|
|
1995
|
-
}
|
|
1996
|
-
async function resolveCimdClient(ctx, clientId) {
|
|
1997
|
-
const url = assertFetchableUrl(ctx, clientId);
|
|
1998
|
-
const existing = await ctx.models.Client.findOne({ clientId }).exec();
|
|
1999
|
-
if (existing) {
|
|
2000
|
-
if (existing.status !== "active") {
|
|
2001
|
-
throw refuse(`client '${clientId}' is disabled`);
|
|
2002
|
-
}
|
|
2003
|
-
if (existing.registration !== "cimd") return existing;
|
|
2004
|
-
if (isFresh(ctx, existing)) return existing;
|
|
2005
|
-
}
|
|
2006
|
-
const remembered = rememberedFailure(ctx, url.href);
|
|
2007
|
-
if (remembered) throw refuse(remembered);
|
|
2008
|
-
try {
|
|
2009
|
-
const { document, etag } = await fetchMetadata(ctx, url, existing?.metadataEtag);
|
|
2010
|
-
if (!document) {
|
|
2011
|
-
if (existing) {
|
|
2012
|
-
existing.metadataFetchedAt = /* @__PURE__ */ new Date();
|
|
2013
|
-
await existing.save();
|
|
2014
|
-
return existing;
|
|
2015
|
-
}
|
|
2016
|
-
throw refuse("client_id metadata returned 304 with nothing cached to answer from");
|
|
2017
|
-
}
|
|
2018
|
-
const registration = validateMetadataDocument(ctx, url, document);
|
|
2019
|
-
const client = await persist(ctx, clientId, url, registration, etag);
|
|
2020
|
-
ctx.logger.debug?.({ clientId }, "oauth-host: client_id metadata document resolved");
|
|
2021
|
-
return client;
|
|
2022
|
-
} catch (err) {
|
|
2023
|
-
const message = err instanceof UnredirectableError ? err.description ?? "client_id metadata could not be resolved" : "client_id metadata could not be resolved";
|
|
2024
|
-
rememberFailure(ctx, url.href, message);
|
|
2025
|
-
ctx.logger.warn?.({ clientId, err }, "oauth-host: client_id metadata resolution failed");
|
|
2026
|
-
throw err instanceof UnredirectableError ? err : refuse(message);
|
|
2027
|
-
}
|
|
2028
|
-
}
|
|
2029
|
-
|
|
2030
2347
|
// src/server/services/scopes.ts
|
|
2031
2348
|
function parseScope(raw) {
|
|
2032
2349
|
if (typeof raw !== "string") return [];
|