@jeffjassky/oauth-host 0.2.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 +982 -651
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +982 -651
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
- package/types/index.d.ts +73 -6
package/dist/index.cjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
var mongoose = require('mongoose');
|
|
4
4
|
var express2 = require('express');
|
|
5
5
|
var crypto = require('crypto');
|
|
6
|
+
var jose = require('jose');
|
|
6
7
|
|
|
7
8
|
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
8
9
|
|
|
@@ -29,9 +30,11 @@ function clientSchema() {
|
|
|
29
30
|
{
|
|
30
31
|
clientId: { type: String, required: true, unique: true },
|
|
31
32
|
name: { type: String, required: true },
|
|
32
|
-
// `public`
|
|
33
|
-
//
|
|
34
|
-
//
|
|
33
|
+
// `public` is a client that holds no secret and is bound by PKCE instead.
|
|
34
|
+
// Two ways in and they are independent: a CIMD row is written public, and
|
|
35
|
+
// `clients.create({ type: 'public' })` registers one by hand. The enum was
|
|
36
|
+
// written with this widening in mind, so it was one member rather than a
|
|
37
|
+
// migration.
|
|
35
38
|
type: { type: String, enum: ["confidential", "public"], default: "confidential" },
|
|
36
39
|
// How the row got here. A `cimd` row is re-derived from the client's own
|
|
37
40
|
// metadata document, so this is what tells the re-fetch path which fields
|
|
@@ -40,6 +43,18 @@ function clientSchema() {
|
|
|
40
43
|
metadataUrl: String,
|
|
41
44
|
metadataFetchedAt: Date,
|
|
42
45
|
metadataEtag: String,
|
|
46
|
+
// The usable (intersected) token endpoint auth method set for a CIMD
|
|
47
|
+
// client — absent on a manual registration, whose behavior derives from
|
|
48
|
+
// `type` exactly as before. A row with no value here supports no
|
|
49
|
+
// assertion-based method; see `authenticateClient`'s fallback rule.
|
|
50
|
+
tokenEndpointAuthMethods: [String],
|
|
51
|
+
// Exactly one of these two is set when `private_key_jwt` is in
|
|
52
|
+
// `tokenEndpointAuthMethods` — `validateMetadataDocument` refuses a
|
|
53
|
+
// document declaring both. Neither is ever required at the schema
|
|
54
|
+
// level: a manual client, or a CIMD client that only offers `none`,
|
|
55
|
+
// legitimately has neither.
|
|
56
|
+
jwksUri: String,
|
|
57
|
+
jwks: mongoose__default.default.Schema.Types.Mixed,
|
|
43
58
|
trusted: { type: Boolean, default: false },
|
|
44
59
|
// An array, not a string: rotation needs two live secrets at once or it
|
|
45
60
|
// cannot be deployed without downtime.
|
|
@@ -383,7 +398,10 @@ function resolveCimd(input, scopeIndex) {
|
|
|
383
398
|
fetchTimeoutMs: 5e3,
|
|
384
399
|
maxBytes: 65536,
|
|
385
400
|
allowedScopes: [],
|
|
386
|
-
failures: /* @__PURE__ */ new Map()
|
|
401
|
+
failures: /* @__PURE__ */ new Map(),
|
|
402
|
+
jwksCache: /* @__PURE__ */ new Map(),
|
|
403
|
+
jwksForceRefetchAt: /* @__PURE__ */ new Map(),
|
|
404
|
+
assertionReplay: /* @__PURE__ */ new Map()
|
|
387
405
|
};
|
|
388
406
|
if (!input || !input.enabled) return off;
|
|
389
407
|
if (!Array.isArray(input.allowedHosts) || input.allowedHosts.length === 0) {
|
|
@@ -416,7 +434,10 @@ function resolveCimd(input, scopeIndex) {
|
|
|
416
434
|
fetchTimeoutMs: positive(input.fetchTimeoutMs, "fetchTimeoutMs", 5e3),
|
|
417
435
|
maxBytes: positive(input.maxBytes, "maxBytes", 65536),
|
|
418
436
|
allowedScopes: [...allowedScopes],
|
|
419
|
-
failures: /* @__PURE__ */ new Map()
|
|
437
|
+
failures: /* @__PURE__ */ new Map(),
|
|
438
|
+
jwksCache: /* @__PURE__ */ new Map(),
|
|
439
|
+
jwksForceRefetchAt: /* @__PURE__ */ new Map(),
|
|
440
|
+
assertionReplay: /* @__PURE__ */ new Map()
|
|
420
441
|
};
|
|
421
442
|
}
|
|
422
443
|
function resolveConfig(config) {
|
|
@@ -664,15 +685,19 @@ function authorizationServerMetadata(ctx, mountPath) {
|
|
|
664
685
|
// S256 only. `plain` is not implemented anywhere in this package, so
|
|
665
686
|
// advertising it would be a lie a client would discover at redemption.
|
|
666
687
|
code_challenge_methods_supported: ["S256"],
|
|
667
|
-
// `none`
|
|
668
|
-
// would tell every client that secretless
|
|
669
|
-
// and the only clients that can use it
|
|
670
|
-
// refuse to register.
|
|
688
|
+
// `none` and `private_key_jwt` are advertised only when CIMD is on.
|
|
689
|
+
// Listing either unconditionally would tell every client that secretless
|
|
690
|
+
// authentication is available here, and the only clients that can use it
|
|
691
|
+
// are the ones this server would then refuse to register.
|
|
671
692
|
token_endpoint_auth_methods_supported: [
|
|
672
693
|
"client_secret_basic",
|
|
673
694
|
"client_secret_post",
|
|
674
|
-
...ctx.cimd.enabled ? ["none"] : []
|
|
695
|
+
...ctx.cimd.enabled ? ["none", "private_key_jwt"] : []
|
|
675
696
|
],
|
|
697
|
+
// RFC 7523 §2.2 — the only two algorithms `authenticateClient` accepts
|
|
698
|
+
// for a `client_assertion` signature. Same gate as the line above: it
|
|
699
|
+
// names a capability that only exists for a CIMD client.
|
|
700
|
+
...ctx.cimd.enabled ? { token_endpoint_auth_signing_alg_values_supported: ["RS256", "ES256"] } : {},
|
|
676
701
|
// What tells Claude and ChatGPT to skip registration entirely and send
|
|
677
702
|
// their metadata document URL as `client_id`.
|
|
678
703
|
...ctx.cimd.enabled ? { client_id_metadata_document_supported: true } : {},
|
|
@@ -1275,749 +1300,1055 @@ function createKeyManager(ctx) {
|
|
|
1275
1300
|
};
|
|
1276
1301
|
}
|
|
1277
1302
|
|
|
1278
|
-
// src/server/services/
|
|
1279
|
-
var
|
|
1280
|
-
var
|
|
1281
|
-
function
|
|
1282
|
-
|
|
1283
|
-
if (n < 1) return 1;
|
|
1284
|
-
return Math.min(n, MAX_LIST);
|
|
1303
|
+
// src/server/services/cimd.ts
|
|
1304
|
+
var NEGATIVE_TTL_MS = 6e4;
|
|
1305
|
+
var MAX_REMEMBERED_FAILURES = 1e3;
|
|
1306
|
+
function refuse(description) {
|
|
1307
|
+
return new UnredirectableError("invalid_client", description);
|
|
1285
1308
|
}
|
|
1286
|
-
function
|
|
1287
|
-
|
|
1288
|
-
return n > 0 ? n : 0;
|
|
1309
|
+
function isMetadataUrl(clientId) {
|
|
1310
|
+
return /^https:\/\//i.test(clientId);
|
|
1289
1311
|
}
|
|
1290
|
-
function
|
|
1291
|
-
|
|
1312
|
+
function hostAllowed(rules, url) {
|
|
1313
|
+
const hostname = url.hostname.toLowerCase();
|
|
1314
|
+
return rules.some((rule) => {
|
|
1315
|
+
if (url.port !== rule.port) return false;
|
|
1316
|
+
if (hostname === rule.host) return true;
|
|
1317
|
+
return rule.subdomains && hostname.endsWith(`.${rule.host}`);
|
|
1318
|
+
});
|
|
1292
1319
|
}
|
|
1293
|
-
function
|
|
1320
|
+
function assertFetchableUrl(ctx, clientId) {
|
|
1321
|
+
let url;
|
|
1322
|
+
try {
|
|
1323
|
+
url = new URL(clientId);
|
|
1324
|
+
} catch {
|
|
1325
|
+
throw refuse(`client_id is not a valid URL: '${clientId}'`);
|
|
1326
|
+
}
|
|
1327
|
+
if (url.protocol !== "https:") {
|
|
1328
|
+
throw refuse(`client_id metadata must be served over https (got '${url.protocol}')`);
|
|
1329
|
+
}
|
|
1330
|
+
if (url.username || url.password) {
|
|
1331
|
+
throw refuse("client_id must not contain URL credentials");
|
|
1332
|
+
}
|
|
1333
|
+
if (url.hash) {
|
|
1334
|
+
throw refuse("client_id must not contain a fragment");
|
|
1335
|
+
}
|
|
1336
|
+
if (!hostAllowed(ctx.cimd.allowedHosts, url)) {
|
|
1337
|
+
throw refuse(`client_id host '${url.host}' is not in clientIdMetadata.allowedHosts`);
|
|
1338
|
+
}
|
|
1339
|
+
return url;
|
|
1340
|
+
}
|
|
1341
|
+
var JSON_CONTENT_TYPE = /^application\/(?:[\w.+-]+\+)?json\s*(?:;|$)/i;
|
|
1342
|
+
async function readCapped(res, maxBytes, docSubject) {
|
|
1343
|
+
const body = res.body;
|
|
1344
|
+
if (!body) throw refuse(`${docSubject} was empty`);
|
|
1345
|
+
const reader = body.getReader();
|
|
1346
|
+
const chunks = [];
|
|
1347
|
+
let total = 0;
|
|
1348
|
+
for (; ; ) {
|
|
1349
|
+
const { done, value } = await reader.read();
|
|
1350
|
+
if (done) break;
|
|
1351
|
+
if (!value) continue;
|
|
1352
|
+
total += value.byteLength;
|
|
1353
|
+
if (total > maxBytes) {
|
|
1354
|
+
void reader.cancel();
|
|
1355
|
+
throw refuse(`${docSubject} exceeds ${maxBytes} bytes`);
|
|
1356
|
+
}
|
|
1357
|
+
chunks.push(Buffer.from(value));
|
|
1358
|
+
}
|
|
1359
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1360
|
+
}
|
|
1361
|
+
async function hardenedFetch(ctx, url, opts) {
|
|
1362
|
+
const { subject, redirectAdvice, etag } = opts;
|
|
1363
|
+
const docSubject = `${subject} document`;
|
|
1364
|
+
let res;
|
|
1365
|
+
try {
|
|
1366
|
+
res = await fetch(url.toString(), {
|
|
1367
|
+
method: "GET",
|
|
1368
|
+
// The single most important line in this file. A 3xx is a failure below,
|
|
1369
|
+
// not a hop: following redirects would let an allowlisted host forward us
|
|
1370
|
+
// to any address it likes, allowlist intact.
|
|
1371
|
+
redirect: "manual",
|
|
1372
|
+
headers: {
|
|
1373
|
+
accept: "application/json",
|
|
1374
|
+
...etag ? { "if-none-match": etag } : {}
|
|
1375
|
+
},
|
|
1376
|
+
signal: AbortSignal.timeout(ctx.cimd.fetchTimeoutMs)
|
|
1377
|
+
});
|
|
1378
|
+
} catch (err) {
|
|
1379
|
+
const name = err?.name;
|
|
1380
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
1381
|
+
throw refuse(`${subject} fetch timed out after ${ctx.cimd.fetchTimeoutMs}ms`);
|
|
1382
|
+
}
|
|
1383
|
+
throw refuse(`${subject} could not be fetched: ${err?.message ?? "network error"}`);
|
|
1384
|
+
}
|
|
1385
|
+
if (res.status === 304) return { document: null };
|
|
1386
|
+
if (res.status >= 300 && res.status < 400) {
|
|
1387
|
+
throw refuse(`${subject} returned a ${res.status} redirect, which is not followed \u2014 ${redirectAdvice}`);
|
|
1388
|
+
}
|
|
1389
|
+
if (res.status !== 200) {
|
|
1390
|
+
throw refuse(`${subject} returned HTTP ${res.status}`);
|
|
1391
|
+
}
|
|
1392
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
1393
|
+
if (!JSON_CONTENT_TYPE.test(contentType)) {
|
|
1394
|
+
throw refuse(`${subject} must be JSON (got content-type '${contentType || "none"}')`);
|
|
1395
|
+
}
|
|
1396
|
+
const text = await readCapped(res, ctx.cimd.maxBytes, docSubject);
|
|
1397
|
+
let parsed;
|
|
1398
|
+
try {
|
|
1399
|
+
parsed = JSON.parse(text);
|
|
1400
|
+
} catch {
|
|
1401
|
+
throw refuse(`${docSubject} is not valid JSON`);
|
|
1402
|
+
}
|
|
1403
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1404
|
+
throw refuse(`${docSubject} must be a JSON object`);
|
|
1405
|
+
}
|
|
1294
1406
|
return {
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
type: doc.type,
|
|
1298
|
-
// Defaulted rather than read straight through: rows written before the
|
|
1299
|
-
// field existed have no value, and `undefined` here would read as "unknown
|
|
1300
|
-
// provenance" on a client that plainly was registered by hand.
|
|
1301
|
-
registration: doc.registration ?? "manual",
|
|
1302
|
-
...doc.metadataUrl ? { metadataUrl: doc.metadataUrl } : {},
|
|
1303
|
-
trusted: Boolean(doc.trusted),
|
|
1304
|
-
redirectUris: [...doc.redirectUris ?? []],
|
|
1305
|
-
allowedScopes: [...doc.allowedScopes ?? []],
|
|
1306
|
-
allowedResources: [...doc.allowedResources ?? []],
|
|
1307
|
-
branding: { ...doc.branding ?? {} },
|
|
1308
|
-
status: doc.status,
|
|
1309
|
-
createdAt: doc.createdAt
|
|
1407
|
+
document: parsed,
|
|
1408
|
+
...res.headers.get("etag") ? { etag: res.headers.get("etag") } : {}
|
|
1310
1409
|
};
|
|
1311
1410
|
}
|
|
1312
|
-
function
|
|
1313
|
-
|
|
1314
|
-
|
|
1411
|
+
async function fetchMetadata(ctx, url, etag) {
|
|
1412
|
+
return hardenedFetch(ctx, url, {
|
|
1413
|
+
subject: "client_id metadata",
|
|
1414
|
+
redirectAdvice: "serve the document at the client_id URL itself",
|
|
1415
|
+
etag
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
async function fetchJwks(ctx, jwksUri) {
|
|
1419
|
+
const url = new URL(jwksUri);
|
|
1420
|
+
const { document } = await hardenedFetch(ctx, url, {
|
|
1421
|
+
subject: "jwks_uri",
|
|
1422
|
+
redirectAdvice: "serve the JWK Set at the jwks_uri URL itself"
|
|
1423
|
+
});
|
|
1424
|
+
if (!document) throw refuse("jwks_uri fetch returned no document");
|
|
1425
|
+
return document;
|
|
1426
|
+
}
|
|
1427
|
+
var SERVER_CIMD_AUTH_METHODS = ["none", "private_key_jwt"];
|
|
1428
|
+
function assertJwksUri(ctx, value) {
|
|
1429
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
1430
|
+
throw refuse("client_id metadata field 'jwks_uri' must be a non-empty string");
|
|
1315
1431
|
}
|
|
1316
1432
|
let url;
|
|
1317
1433
|
try {
|
|
1318
1434
|
url = new URL(value);
|
|
1319
1435
|
} catch {
|
|
1320
|
-
throw
|
|
1436
|
+
throw refuse(`client_id metadata field 'jwks_uri' is not a valid URL: '${value}'`);
|
|
1321
1437
|
}
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
);
|
|
1438
|
+
if (url.protocol !== "https:") {
|
|
1439
|
+
throw refuse(`client_id metadata field 'jwks_uri' must be served over https (got '${url.protocol}')`);
|
|
1440
|
+
}
|
|
1441
|
+
if (url.username || url.password) {
|
|
1442
|
+
throw refuse("client_id metadata field 'jwks_uri' must not contain URL credentials");
|
|
1327
1443
|
}
|
|
1328
1444
|
if (url.hash) {
|
|
1329
|
-
throw
|
|
1445
|
+
throw refuse("client_id metadata field 'jwks_uri' must not contain a fragment");
|
|
1330
1446
|
}
|
|
1331
|
-
|
|
1332
|
-
}
|
|
1333
|
-
function assertRedirectUris(input) {
|
|
1334
|
-
if (!Array.isArray(input) || input.length === 0) {
|
|
1335
|
-
throw new TypeError("oauth-host: a client needs at least one redirectUri");
|
|
1447
|
+
if (!hostAllowed(ctx.cimd.allowedHosts, url)) {
|
|
1448
|
+
throw refuse(`client_id metadata field 'jwks_uri' host '${url.host}' is not in clientIdMetadata.allowedHosts`);
|
|
1336
1449
|
}
|
|
1337
|
-
return
|
|
1450
|
+
return url.href;
|
|
1338
1451
|
}
|
|
1339
|
-
function
|
|
1340
|
-
if (!
|
|
1341
|
-
throw
|
|
1452
|
+
function assertInlineJwks(value) {
|
|
1453
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1454
|
+
throw refuse("client_id metadata field 'jwks' must be a JSON object");
|
|
1342
1455
|
}
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1456
|
+
const keys = value.keys;
|
|
1457
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
1458
|
+
throw refuse("client_id metadata field 'jwks' must contain a non-empty 'keys' array");
|
|
1459
|
+
}
|
|
1460
|
+
for (const key of keys) {
|
|
1461
|
+
if (!key || typeof key !== "object" || Array.isArray(key)) {
|
|
1462
|
+
throw refuse("client_id metadata field 'jwks' contains a non-object entry in 'keys'");
|
|
1348
1463
|
}
|
|
1349
1464
|
}
|
|
1350
|
-
return
|
|
1465
|
+
return value;
|
|
1351
1466
|
}
|
|
1352
|
-
function
|
|
1353
|
-
const
|
|
1354
|
-
if (
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
for (const id of input) {
|
|
1359
|
-
if (typeof id !== "string" || !declared.has(id)) {
|
|
1360
|
-
throw new TypeError(
|
|
1361
|
-
`oauth-host: allowedResources contains '${String(id)}', which is not a configured resource`
|
|
1467
|
+
function offeredAuthMethods(doc) {
|
|
1468
|
+
const plural = doc.token_endpoint_auth_methods_supported;
|
|
1469
|
+
if (plural !== void 0) {
|
|
1470
|
+
if (!Array.isArray(plural) || plural.length === 0 || !plural.every((entry) => typeof entry === "string" && entry.length > 0)) {
|
|
1471
|
+
throw refuse(
|
|
1472
|
+
"client_id metadata field 'token_endpoint_auth_methods_supported' must be a non-empty array of strings"
|
|
1362
1473
|
);
|
|
1363
1474
|
}
|
|
1475
|
+
return [...new Set(plural)];
|
|
1364
1476
|
}
|
|
1365
|
-
|
|
1477
|
+
const singular = doc.token_endpoint_auth_method;
|
|
1478
|
+
if (singular !== void 0) {
|
|
1479
|
+
if (typeof singular !== "string" || !singular) {
|
|
1480
|
+
throw refuse("client_id metadata field 'token_endpoint_auth_method' must be a string");
|
|
1481
|
+
}
|
|
1482
|
+
return [singular];
|
|
1483
|
+
}
|
|
1484
|
+
return ["none"];
|
|
1366
1485
|
}
|
|
1367
|
-
function
|
|
1486
|
+
function requireString(doc, field) {
|
|
1487
|
+
const value = doc[field];
|
|
1368
1488
|
if (typeof value !== "string" || !value.trim()) {
|
|
1369
|
-
throw
|
|
1489
|
+
throw refuse(`client_id metadata field '${field}' must be a non-empty string`);
|
|
1370
1490
|
}
|
|
1371
1491
|
return value.trim();
|
|
1372
1492
|
}
|
|
1373
|
-
|
|
1374
|
-
const
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1493
|
+
function optionalHttpsUri(doc, field) {
|
|
1494
|
+
const value = doc[field];
|
|
1495
|
+
if (value === void 0 || value === null) return void 0;
|
|
1496
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
1497
|
+
throw refuse(`client_id metadata field '${field}' must be a string`);
|
|
1498
|
+
}
|
|
1499
|
+
let url;
|
|
1500
|
+
try {
|
|
1501
|
+
url = new URL(value);
|
|
1502
|
+
} catch {
|
|
1503
|
+
throw refuse(`client_id metadata field '${field}' must be an absolute URL: '${value}'`);
|
|
1504
|
+
}
|
|
1505
|
+
if (url.protocol !== "https:") {
|
|
1506
|
+
throw refuse(`client_id metadata field '${field}' must be https (got '${url.protocol}')`);
|
|
1507
|
+
}
|
|
1508
|
+
return value;
|
|
1379
1509
|
}
|
|
1380
|
-
|
|
1381
|
-
const
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
return
|
|
1510
|
+
function assertRedirectUris(doc) {
|
|
1511
|
+
const value = doc.redirect_uris;
|
|
1512
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
1513
|
+
throw refuse("client_id metadata field 'redirect_uris' must be a non-empty array");
|
|
1514
|
+
}
|
|
1515
|
+
return value.map((entry) => {
|
|
1516
|
+
if (typeof entry !== "string" || !entry) {
|
|
1517
|
+
throw refuse(`client_id metadata 'redirect_uris' entries must be strings (got ${JSON.stringify(entry)})`);
|
|
1518
|
+
}
|
|
1519
|
+
let url;
|
|
1520
|
+
try {
|
|
1521
|
+
url = new URL(entry);
|
|
1522
|
+
} catch {
|
|
1523
|
+
throw refuse(`client_id metadata 'redirect_uris' entry must be an absolute URI: '${entry}'`);
|
|
1524
|
+
}
|
|
1525
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
1526
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
1527
|
+
throw refuse(
|
|
1528
|
+
`client_id metadata 'redirect_uris' entry must be https (http is allowed only on localhost/127.0.0.1): '${entry}'`
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1531
|
+
if (url.hash) {
|
|
1532
|
+
throw refuse(`client_id metadata 'redirect_uris' entry must not contain a fragment: '${entry}'`);
|
|
1533
|
+
}
|
|
1534
|
+
return entry;
|
|
1535
|
+
});
|
|
1386
1536
|
}
|
|
1387
|
-
function
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
if (!spec || typeof spec !== "object") {
|
|
1391
|
-
throw new TypeError("oauth-host: clients.create(spec) requires a spec object");
|
|
1392
|
-
}
|
|
1393
|
-
const name = assertName(spec.name);
|
|
1394
|
-
const redirectUris = assertRedirectUris(spec.redirectUris);
|
|
1395
|
-
const allowedScopes = assertScopes(ctx, spec.allowedScopes);
|
|
1396
|
-
const allowedResources = assertResources(ctx, spec.allowedResources);
|
|
1397
|
-
const clientId = spec.clientId ?? generateClientId();
|
|
1398
|
-
const clientSecret = generateClientSecret();
|
|
1399
|
-
const doc = await ctx.models.Client.create({
|
|
1400
|
-
clientId,
|
|
1401
|
-
name,
|
|
1402
|
-
type: "confidential",
|
|
1403
|
-
registration: "manual",
|
|
1404
|
-
trusted: Boolean(spec.trusted),
|
|
1405
|
-
// Only the digest is stored. `clientSecret` below is the only time the
|
|
1406
|
-
// raw value exists outside the caller's variable.
|
|
1407
|
-
secrets: [{ hash: sha256(clientSecret), label: "initial", createdAt: /* @__PURE__ */ new Date() }],
|
|
1408
|
-
redirectUris,
|
|
1409
|
-
allowedScopes,
|
|
1410
|
-
allowedResources,
|
|
1411
|
-
branding: spec.branding ?? {},
|
|
1412
|
-
status: "active"
|
|
1413
|
-
});
|
|
1414
|
-
await ctx.audit({ type: "oauth.client_created", actor: "admin", clientId });
|
|
1415
|
-
return { client: toPublicClient(doc), clientId, clientSecret };
|
|
1416
|
-
},
|
|
1417
|
-
async rotateSecret(clientId, opts = {}) {
|
|
1418
|
-
const doc = await ctx.models.Client.findOne({ clientId });
|
|
1419
|
-
if (!doc) throw notFound(clientId);
|
|
1420
|
-
if (doc.type === "public") {
|
|
1421
|
-
throw new Error(
|
|
1422
|
-
`oauth-host: client '${clientId}' is a public client and has no secret to rotate. Public clients authenticate with client_id and PKCE.`
|
|
1423
|
-
);
|
|
1424
|
-
}
|
|
1425
|
-
const retireAfter = typeof opts.retireAfter === "number" && opts.retireAfter > 0 ? opts.retireAfter : 0;
|
|
1426
|
-
const retiresAt = new Date(Date.now() + retireAfter);
|
|
1427
|
-
for (const record of doc.secrets) {
|
|
1428
|
-
record.retiresAt = record.retiresAt && record.retiresAt < retiresAt ? record.retiresAt : retiresAt;
|
|
1429
|
-
}
|
|
1430
|
-
const clientSecret = generateClientSecret();
|
|
1431
|
-
doc.secrets.push({
|
|
1432
|
-
hash: sha256(clientSecret),
|
|
1433
|
-
label: opts.label ?? `rotated-${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
1434
|
-
createdAt: /* @__PURE__ */ new Date()
|
|
1435
|
-
});
|
|
1436
|
-
doc.markModified("secrets");
|
|
1437
|
-
await doc.save();
|
|
1438
|
-
ctx.track({ type: "oauth.client_secret_rotated", clientId });
|
|
1439
|
-
await ctx.audit({
|
|
1440
|
-
type: "oauth.client_secret_rotated",
|
|
1441
|
-
actor: "admin",
|
|
1442
|
-
clientId,
|
|
1443
|
-
meta: { retireAfter, retiresAt }
|
|
1444
|
-
});
|
|
1445
|
-
return { client: toPublicClient(doc), clientId, clientSecret };
|
|
1446
|
-
},
|
|
1447
|
-
async update(clientId, patch) {
|
|
1448
|
-
const doc = await ctx.models.Client.findOne({ clientId });
|
|
1449
|
-
if (!doc) throw notFound(clientId);
|
|
1450
|
-
if (patch.name !== void 0) doc.name = assertName(patch.name);
|
|
1451
|
-
if (patch.redirectUris !== void 0) doc.redirectUris = assertRedirectUris(patch.redirectUris);
|
|
1452
|
-
if (patch.allowedScopes !== void 0) doc.allowedScopes = assertScopes(ctx, patch.allowedScopes);
|
|
1453
|
-
if (patch.allowedResources !== void 0) {
|
|
1454
|
-
doc.allowedResources = assertResources(ctx, patch.allowedResources);
|
|
1455
|
-
}
|
|
1456
|
-
if (patch.branding !== void 0) doc.branding = patch.branding;
|
|
1457
|
-
if (patch.trusted !== void 0) doc.trusted = Boolean(patch.trusted);
|
|
1458
|
-
await doc.save();
|
|
1459
|
-
await ctx.audit({ type: "oauth.client_updated", actor: "admin", clientId });
|
|
1460
|
-
return toPublicClient(doc);
|
|
1461
|
-
},
|
|
1462
|
-
async list(query = {}) {
|
|
1463
|
-
const limit = clampLimit(query.limit);
|
|
1464
|
-
const skip = clampSkip(query.skip);
|
|
1465
|
-
const filter = {};
|
|
1466
|
-
if (query.status) filter.status = query.status;
|
|
1467
|
-
const docs = await ctx.models.Client.find(filter).sort({ createdAt: -1 }).skip(skip).limit(limit).lean();
|
|
1468
|
-
return { items: docs.map(toPublicClient), limit };
|
|
1469
|
-
},
|
|
1470
|
-
async get(clientId) {
|
|
1471
|
-
const doc = await ctx.models.Client.findOne({ clientId }).lean();
|
|
1472
|
-
return doc ? toPublicClient(doc) : null;
|
|
1473
|
-
},
|
|
1474
|
-
async disable(clientId) {
|
|
1475
|
-
const doc = await ctx.models.Client.findOne({ clientId });
|
|
1476
|
-
if (!doc) throw notFound(clientId);
|
|
1477
|
-
doc.status = "disabled";
|
|
1478
|
-
await doc.save();
|
|
1479
|
-
const { grantsRevoked } = await revokeGrantsMatching(ctx, { clientId }, "admin");
|
|
1480
|
-
const tokensRevoked = await revokeTokensMatching(ctx, { clientId });
|
|
1481
|
-
await ctx.models.Code.deleteMany({ clientId });
|
|
1482
|
-
await ctx.models.Request.deleteMany({ clientId });
|
|
1483
|
-
await ctx.audit({
|
|
1484
|
-
type: "oauth.client_disabled",
|
|
1485
|
-
actor: "admin",
|
|
1486
|
-
clientId,
|
|
1487
|
-
meta: { grantsRevoked, tokensRevoked }
|
|
1488
|
-
});
|
|
1489
|
-
return { grantsRevoked, tokensRevoked };
|
|
1490
|
-
}
|
|
1491
|
-
};
|
|
1492
|
-
}
|
|
1493
|
-
function formUrlDecode(value) {
|
|
1537
|
+
function validateMetadataDocument(ctx, url, doc) {
|
|
1538
|
+
const declared = requireString(doc, "client_id");
|
|
1539
|
+
let declaredUrl;
|
|
1494
1540
|
try {
|
|
1495
|
-
|
|
1541
|
+
declaredUrl = new URL(declared);
|
|
1496
1542
|
} catch {
|
|
1497
|
-
|
|
1543
|
+
throw refuse(`client_id metadata field 'client_id' is not a URL: '${declared}'`);
|
|
1498
1544
|
}
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
const decoded = Buffer.from(header.slice(6).trim(), "base64").toString("utf8");
|
|
1504
|
-
const sep = decoded.indexOf(":");
|
|
1505
|
-
const clientId = formUrlDecode(sep < 0 ? decoded : decoded.slice(0, sep));
|
|
1506
|
-
const secret = sep < 0 ? "" : formUrlDecode(decoded.slice(sep + 1));
|
|
1507
|
-
return { clientId, clientSecret: secret === "" ? null : secret, viaBasic: true };
|
|
1545
|
+
if (declaredUrl.href !== url.href) {
|
|
1546
|
+
throw refuse(
|
|
1547
|
+
`client_id metadata field 'client_id' is '${declared}', which is not the URL it was fetched from ('${url.href}')`
|
|
1548
|
+
);
|
|
1508
1549
|
}
|
|
1509
|
-
const
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
};
|
|
1550
|
+
const offered = offeredAuthMethods(doc);
|
|
1551
|
+
let usableMethods = offered.filter((m) => SERVER_CIMD_AUTH_METHODS.includes(m));
|
|
1552
|
+
if (usableMethods.length === 0) {
|
|
1553
|
+
throw refuse(
|
|
1554
|
+
`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`
|
|
1555
|
+
);
|
|
1516
1556
|
}
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1557
|
+
let jwksUri;
|
|
1558
|
+
let jwks;
|
|
1559
|
+
if (usableMethods.includes("private_key_jwt")) {
|
|
1560
|
+
const hasJwksUri = doc.jwks_uri !== void 0 && doc.jwks_uri !== null;
|
|
1561
|
+
const hasJwks = doc.jwks !== void 0 && doc.jwks !== null;
|
|
1562
|
+
if (hasJwksUri && hasJwks) {
|
|
1563
|
+
throw refuse("client_id metadata must not contain both 'jwks' and 'jwks_uri'");
|
|
1564
|
+
}
|
|
1565
|
+
if (hasJwksUri) {
|
|
1566
|
+
jwksUri = assertJwksUri(ctx, doc.jwks_uri);
|
|
1567
|
+
} else if (hasJwks) {
|
|
1568
|
+
jwks = assertInlineJwks(doc.jwks);
|
|
1569
|
+
} else if (usableMethods.includes("none")) {
|
|
1570
|
+
usableMethods = usableMethods.filter((m) => m !== "private_key_jwt");
|
|
1571
|
+
} else {
|
|
1572
|
+
throw refuse("client_id metadata offers private_key_jwt but carries no 'jwks' or 'jwks_uri'");
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
const name = requireString(doc, "client_name");
|
|
1576
|
+
const redirectUris = assertRedirectUris(doc);
|
|
1577
|
+
const permitted = new Set(ctx.cimd.allowedScopes.filter((id) => ctx.scopeIndex.has(id)));
|
|
1578
|
+
let allowedScopes;
|
|
1579
|
+
if (doc.scope === void 0 || doc.scope === null) {
|
|
1580
|
+
allowedScopes = [...permitted];
|
|
1581
|
+
} else {
|
|
1582
|
+
if (typeof doc.scope !== "string") {
|
|
1583
|
+
throw refuse("client_id metadata field 'scope' must be a space-delimited string");
|
|
1584
|
+
}
|
|
1585
|
+
const requested = new Set(doc.scope.split(/\s+/).filter(Boolean));
|
|
1586
|
+
allowedScopes = [...permitted].filter((id) => requested.has(id));
|
|
1587
|
+
}
|
|
1588
|
+
if (allowedScopes.length === 0) {
|
|
1589
|
+
throw refuse(
|
|
1590
|
+
"client_id metadata field 'scope' has no overlap with what this server offers \u2014 there is nothing this client could be granted"
|
|
1591
|
+
);
|
|
1592
|
+
}
|
|
1593
|
+
const branding = {};
|
|
1594
|
+
const logoUrl = optionalHttpsUri(doc, "logo_uri");
|
|
1595
|
+
const homepageUrl = optionalHttpsUri(doc, "client_uri");
|
|
1596
|
+
const tosUrl = optionalHttpsUri(doc, "tos_uri");
|
|
1597
|
+
const privacyUrl = optionalHttpsUri(doc, "policy_uri");
|
|
1598
|
+
if (logoUrl) branding.logoUrl = logoUrl;
|
|
1599
|
+
if (homepageUrl) branding.homepageUrl = homepageUrl;
|
|
1600
|
+
if (tosUrl) branding.tosUrl = tosUrl;
|
|
1601
|
+
if (privacyUrl) branding.privacyUrl = privacyUrl;
|
|
1602
|
+
return {
|
|
1603
|
+
name,
|
|
1604
|
+
redirectUris,
|
|
1605
|
+
allowedScopes,
|
|
1606
|
+
branding,
|
|
1607
|
+
usableMethods,
|
|
1608
|
+
...jwksUri ? { jwksUri } : {},
|
|
1609
|
+
...jwks ? { jwks } : {}
|
|
1610
|
+
};
|
|
1524
1611
|
}
|
|
1525
|
-
|
|
1526
|
-
const
|
|
1527
|
-
if (
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
if (client.type === "public") {
|
|
1531
|
-
if (creds.clientSecret !== null) throw authFailure(creds.viaBasic);
|
|
1532
|
-
return client;
|
|
1612
|
+
function rememberFailure(ctx, key, message) {
|
|
1613
|
+
const { failures } = ctx.cimd;
|
|
1614
|
+
if (failures.size >= MAX_REMEMBERED_FAILURES) {
|
|
1615
|
+
const oldest = failures.keys().next();
|
|
1616
|
+
if (!oldest.done) failures.delete(oldest.value);
|
|
1533
1617
|
}
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1618
|
+
failures.set(key, { until: Date.now() + NEGATIVE_TTL_MS, message });
|
|
1619
|
+
}
|
|
1620
|
+
function rememberedFailure(ctx, key) {
|
|
1621
|
+
const found = ctx.cimd.failures.get(key);
|
|
1622
|
+
if (!found) return null;
|
|
1623
|
+
if (found.until <= Date.now()) {
|
|
1624
|
+
ctx.cimd.failures.delete(key);
|
|
1625
|
+
return null;
|
|
1540
1626
|
}
|
|
1541
|
-
|
|
1542
|
-
const usedAt = /* @__PURE__ */ new Date();
|
|
1543
|
-
matched.lastUsedAt = usedAt;
|
|
1544
|
-
await ctx.models.Client.updateOne(
|
|
1545
|
-
{ clientId: client.clientId, "secrets.hash": matched.hash },
|
|
1546
|
-
{ $set: { "secrets.$.lastUsedAt": usedAt } }
|
|
1547
|
-
);
|
|
1548
|
-
return client;
|
|
1627
|
+
return found.message;
|
|
1549
1628
|
}
|
|
1550
|
-
function
|
|
1551
|
-
|
|
1629
|
+
function isFresh(ctx, client) {
|
|
1630
|
+
const fetchedAt = client.metadataFetchedAt?.getTime();
|
|
1631
|
+
if (fetchedAt === void 0) return false;
|
|
1632
|
+
return Date.now() - fetchedAt < ctx.cimd.cacheTtlMs;
|
|
1552
1633
|
}
|
|
1553
|
-
function
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
}
|
|
1585
|
-
summary.context = available.find((c) => c.id === grant.contextId) ?? { id: grant.contextId, label: grant.contextId };
|
|
1586
|
-
}
|
|
1587
|
-
items.push(summary);
|
|
1634
|
+
async function persist(ctx, clientId, url, registration, etag) {
|
|
1635
|
+
const unset = {};
|
|
1636
|
+
const jwksFields = {};
|
|
1637
|
+
if (registration.jwksUri) jwksFields.jwksUri = registration.jwksUri;
|
|
1638
|
+
else unset.jwksUri = "";
|
|
1639
|
+
if (registration.jwks) jwksFields.jwks = registration.jwks;
|
|
1640
|
+
else unset.jwks = "";
|
|
1641
|
+
const doc = await ctx.models.Client.findOneAndUpdate(
|
|
1642
|
+
{ clientId },
|
|
1643
|
+
{
|
|
1644
|
+
$set: {
|
|
1645
|
+
name: registration.name,
|
|
1646
|
+
redirectUris: registration.redirectUris,
|
|
1647
|
+
allowedScopes: registration.allowedScopes,
|
|
1648
|
+
// Every declared resource. A CIMD client cannot express an audience
|
|
1649
|
+
// preference, and RFC 8707 validation at `/authorize` narrows it anyway.
|
|
1650
|
+
allowedResources: ctx.resources.map((r) => r.id),
|
|
1651
|
+
branding: registration.branding,
|
|
1652
|
+
metadataUrl: url.href,
|
|
1653
|
+
metadataFetchedAt: /* @__PURE__ */ new Date(),
|
|
1654
|
+
tokenEndpointAuthMethods: registration.usableMethods,
|
|
1655
|
+
...jwksFields,
|
|
1656
|
+
...etag ? { metadataEtag: etag } : {}
|
|
1657
|
+
},
|
|
1658
|
+
...Object.keys(unset).length ? { $unset: unset } : {},
|
|
1659
|
+
$setOnInsert: {
|
|
1660
|
+
type: "public",
|
|
1661
|
+
registration: "cimd",
|
|
1662
|
+
trusted: false,
|
|
1663
|
+
secrets: [],
|
|
1664
|
+
status: "active"
|
|
1588
1665
|
}
|
|
1589
|
-
return { items, limit };
|
|
1590
1666
|
},
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
if (!grant) return { tokensRevoked: 0 };
|
|
1595
|
-
const alreadyRevoked = Boolean(grant.revokedAt);
|
|
1596
|
-
if (!alreadyRevoked) {
|
|
1597
|
-
grant.revokedAt = /* @__PURE__ */ new Date();
|
|
1598
|
-
grant.revokedBy = by;
|
|
1599
|
-
await grant.save();
|
|
1600
|
-
}
|
|
1601
|
-
const tokensRevoked = await revokeTokensMatching(ctx, { grantId: grant._id });
|
|
1602
|
-
if (!alreadyRevoked) {
|
|
1603
|
-
ctx.track({
|
|
1604
|
-
type: "oauth.grant_revoked",
|
|
1605
|
-
userId: grant.userId,
|
|
1606
|
-
clientId: grant.clientId,
|
|
1607
|
-
grantId: String(grant._id),
|
|
1608
|
-
...grant.contextId ? { contextId: grant.contextId } : {},
|
|
1609
|
-
scopes: grant.scopes
|
|
1610
|
-
});
|
|
1611
|
-
await ctx.audit({
|
|
1612
|
-
type: "oauth.grant_revoked",
|
|
1613
|
-
actor: by,
|
|
1614
|
-
clientId: grant.clientId,
|
|
1615
|
-
userId: grant.userId,
|
|
1616
|
-
grantId: grant._id,
|
|
1617
|
-
meta: { tokensRevoked }
|
|
1618
|
-
});
|
|
1619
|
-
}
|
|
1620
|
-
return { tokensRevoked };
|
|
1621
|
-
}
|
|
1622
|
-
};
|
|
1667
|
+
{ upsert: true, returnDocument: "after", setDefaultsOnInsert: false }
|
|
1668
|
+
).exec();
|
|
1669
|
+
return doc;
|
|
1623
1670
|
}
|
|
1624
|
-
function
|
|
1625
|
-
const
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
return {
|
|
1631
|
-
/**
|
|
1632
|
-
* Erasure. The user is gone and every trace of them has to go with them.
|
|
1633
|
-
*
|
|
1634
|
-
* This is the destructive twin of `revokeAll`, and the difference is the
|
|
1635
|
-
* whole reason both exist: `forget` DELETES the grant documents and the
|
|
1636
|
-
* user's audit rows, `revokeAll` keeps both. A password change must leave a
|
|
1637
|
-
* history an operator can read; a deletion request must not.
|
|
1638
|
-
*
|
|
1639
|
-
* Idempotent — it will be called twice, by a retry or by a host that wires
|
|
1640
|
-
* it to both a soft-delete and a hard-delete hook.
|
|
1641
|
-
*/
|
|
1642
|
-
async forget(userId) {
|
|
1643
|
-
const grantsResult = await ctx.models.Grant.deleteMany({ userId });
|
|
1644
|
-
const tokensResult = await ctx.models.Token.deleteMany({ userId });
|
|
1645
|
-
await ctx.models.Code.deleteMany({ userId });
|
|
1646
|
-
await ctx.models.Request.deleteMany({ userId });
|
|
1647
|
-
const key = pairwiseKey(userId);
|
|
1648
|
-
if (key) {
|
|
1649
|
-
await ctx.models.Client.updateMany(
|
|
1650
|
-
{ [`pairwiseSubjects.${key}`]: { $exists: true } },
|
|
1651
|
-
{ $unset: { [`pairwiseSubjects.${key}`]: "" } }
|
|
1652
|
-
);
|
|
1653
|
-
}
|
|
1654
|
-
await ctx.models.Audit.deleteMany({ userId });
|
|
1655
|
-
const grants = grantsResult.deletedCount ?? 0;
|
|
1656
|
-
const tokens = tokensResult.deletedCount ?? 0;
|
|
1657
|
-
await ctx.audit({ type: "oauth.user_forgotten", actor: "system", meta: { grants, tokens } });
|
|
1658
|
-
return { grants, tokens };
|
|
1659
|
-
},
|
|
1660
|
-
/**
|
|
1661
|
-
* Password change, deactivation, suspected compromise.
|
|
1662
|
-
*
|
|
1663
|
-
* Kills live access and KEEPS everything else: the grant documents stay so
|
|
1664
|
-
* the audit trail still resolves and so the user's connected apps remain
|
|
1665
|
-
* visible, and the audit rows stay because that is the record the operator
|
|
1666
|
-
* called this to create. See `forget` above for the destructive twin.
|
|
1667
|
-
*/
|
|
1668
|
-
async revokeAll(userId, opts = {}) {
|
|
1669
|
-
const live = await ctx.models.Grant.find({ userId, revokedAt: null }).limit(MAX_LIST).lean();
|
|
1670
|
-
const { grantsRevoked } = await revokeGrantsMatching(ctx, { userId }, "system");
|
|
1671
|
-
const tokensRevoked = await revokeTokensMatching(ctx, { userId });
|
|
1672
|
-
await ctx.models.Code.deleteMany({ userId });
|
|
1673
|
-
await ctx.models.Request.deleteMany({ userId });
|
|
1674
|
-
for (const grant of live) {
|
|
1675
|
-
ctx.track({
|
|
1676
|
-
type: "oauth.grant_revoked",
|
|
1677
|
-
userId,
|
|
1678
|
-
clientId: grant.clientId,
|
|
1679
|
-
grantId: String(grant._id),
|
|
1680
|
-
...grant.contextId ? { contextId: grant.contextId } : {},
|
|
1681
|
-
scopes: grant.scopes
|
|
1682
|
-
});
|
|
1683
|
-
}
|
|
1684
|
-
await ctx.audit({
|
|
1685
|
-
type: "oauth.user_access_revoked",
|
|
1686
|
-
actor: "system",
|
|
1687
|
-
userId,
|
|
1688
|
-
meta: { grantsRevoked, tokensRevoked, ...opts.reason ? { reason: opts.reason } : {} }
|
|
1689
|
-
});
|
|
1690
|
-
return { grantsRevoked, tokensRevoked };
|
|
1671
|
+
async function resolveCimdClient(ctx, clientId) {
|
|
1672
|
+
const url = assertFetchableUrl(ctx, clientId);
|
|
1673
|
+
const existing = await ctx.models.Client.findOne({ clientId }).exec();
|
|
1674
|
+
if (existing) {
|
|
1675
|
+
if (existing.status !== "active") {
|
|
1676
|
+
throw refuse(`client '${clientId}' is disabled`);
|
|
1691
1677
|
}
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
async revoked(userId, contextId) {
|
|
1705
|
-
const live = await ctx.models.Grant.find({ userId, contextId, revokedAt: null }).limit(MAX_LIST).lean();
|
|
1706
|
-
const { grantsRevoked } = await revokeGrantsMatching(ctx, { userId, contextId }, "system");
|
|
1707
|
-
const tokensRevoked = await revokeTokensMatching(ctx, { userId, contextId });
|
|
1708
|
-
await ctx.models.Code.deleteMany({ userId, contextId });
|
|
1709
|
-
for (const grant of live) {
|
|
1710
|
-
ctx.track({
|
|
1711
|
-
type: "oauth.grant_revoked",
|
|
1712
|
-
userId,
|
|
1713
|
-
clientId: grant.clientId,
|
|
1714
|
-
grantId: String(grant._id),
|
|
1715
|
-
contextId,
|
|
1716
|
-
scopes: grant.scopes
|
|
1717
|
-
});
|
|
1718
|
-
await ctx.audit({
|
|
1719
|
-
type: "oauth.grant_revoked",
|
|
1720
|
-
actor: "system",
|
|
1721
|
-
clientId: grant.clientId,
|
|
1722
|
-
userId,
|
|
1723
|
-
grantId: grant._id,
|
|
1724
|
-
meta: { contextId, reason: "context_membership_ended" }
|
|
1725
|
-
});
|
|
1678
|
+
if (existing.registration !== "cimd") return existing;
|
|
1679
|
+
if (isFresh(ctx, existing)) return existing;
|
|
1680
|
+
}
|
|
1681
|
+
const remembered = rememberedFailure(ctx, url.href);
|
|
1682
|
+
if (remembered) throw refuse(remembered);
|
|
1683
|
+
try {
|
|
1684
|
+
const { document, etag } = await fetchMetadata(ctx, url, existing?.metadataEtag);
|
|
1685
|
+
if (!document) {
|
|
1686
|
+
if (existing) {
|
|
1687
|
+
existing.metadataFetchedAt = /* @__PURE__ */ new Date();
|
|
1688
|
+
await existing.save();
|
|
1689
|
+
return existing;
|
|
1726
1690
|
}
|
|
1727
|
-
|
|
1691
|
+
throw refuse("client_id metadata returned 304 with nothing cached to answer from");
|
|
1728
1692
|
}
|
|
1729
|
-
|
|
1693
|
+
const registration = validateMetadataDocument(ctx, url, document);
|
|
1694
|
+
const client = await persist(ctx, clientId, url, registration, etag);
|
|
1695
|
+
ctx.logger.debug?.({ clientId }, "oauth-host: client_id metadata document resolved");
|
|
1696
|
+
return client;
|
|
1697
|
+
} catch (err) {
|
|
1698
|
+
const message = err instanceof UnredirectableError ? err.description ?? "client_id metadata could not be resolved" : "client_id metadata could not be resolved";
|
|
1699
|
+
rememberFailure(ctx, url.href, message);
|
|
1700
|
+
ctx.logger.warn?.({ clientId, err }, "oauth-host: client_id metadata resolution failed");
|
|
1701
|
+
throw err instanceof UnredirectableError ? err : refuse(message);
|
|
1702
|
+
}
|
|
1730
1703
|
}
|
|
1731
1704
|
|
|
1732
|
-
// src/server/services/
|
|
1733
|
-
var
|
|
1734
|
-
var
|
|
1735
|
-
function
|
|
1736
|
-
|
|
1705
|
+
// src/server/services/admin.ts
|
|
1706
|
+
var MAX_LIST = 200;
|
|
1707
|
+
var DEFAULT_LIST = 50;
|
|
1708
|
+
function clampLimit(requested) {
|
|
1709
|
+
const n = typeof requested === "number" && Number.isFinite(requested) ? Math.floor(requested) : DEFAULT_LIST;
|
|
1710
|
+
if (n < 1) return 1;
|
|
1711
|
+
return Math.min(n, MAX_LIST);
|
|
1737
1712
|
}
|
|
1738
|
-
function
|
|
1739
|
-
|
|
1713
|
+
function clampSkip(requested) {
|
|
1714
|
+
const n = typeof requested === "number" && Number.isFinite(requested) ? Math.floor(requested) : 0;
|
|
1715
|
+
return n > 0 ? n : 0;
|
|
1740
1716
|
}
|
|
1741
|
-
function
|
|
1742
|
-
|
|
1743
|
-
return rules.some((rule) => {
|
|
1744
|
-
if (url.port !== rule.port) return false;
|
|
1745
|
-
if (hostname === rule.host) return true;
|
|
1746
|
-
return rule.subdomains && hostname.endsWith(`.${rule.host}`);
|
|
1747
|
-
});
|
|
1717
|
+
function notFound(clientId) {
|
|
1718
|
+
return new Error(`oauth-host: no client registered with clientId '${clientId}'`);
|
|
1748
1719
|
}
|
|
1749
|
-
function
|
|
1720
|
+
function toPublicClient(doc) {
|
|
1721
|
+
return {
|
|
1722
|
+
clientId: doc.clientId,
|
|
1723
|
+
name: doc.name,
|
|
1724
|
+
type: doc.type,
|
|
1725
|
+
// Defaulted rather than read straight through: rows written before the
|
|
1726
|
+
// field existed have no value, and `undefined` here would read as "unknown
|
|
1727
|
+
// provenance" on a client that plainly was registered by hand.
|
|
1728
|
+
registration: doc.registration ?? "manual",
|
|
1729
|
+
...doc.metadataUrl ? { metadataUrl: doc.metadataUrl } : {},
|
|
1730
|
+
trusted: Boolean(doc.trusted),
|
|
1731
|
+
redirectUris: [...doc.redirectUris ?? []],
|
|
1732
|
+
allowedScopes: [...doc.allowedScopes ?? []],
|
|
1733
|
+
allowedResources: [...doc.allowedResources ?? []],
|
|
1734
|
+
branding: { ...doc.branding ?? {} },
|
|
1735
|
+
status: doc.status,
|
|
1736
|
+
createdAt: doc.createdAt
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
function assertRedirectUri(value) {
|
|
1740
|
+
if (typeof value !== "string" || !value) {
|
|
1741
|
+
throw new TypeError(`oauth-host: every redirectUri must be a string (got ${JSON.stringify(value)})`);
|
|
1742
|
+
}
|
|
1750
1743
|
let url;
|
|
1751
1744
|
try {
|
|
1752
|
-
url = new URL(
|
|
1745
|
+
url = new URL(value);
|
|
1753
1746
|
} catch {
|
|
1754
|
-
throw
|
|
1755
|
-
}
|
|
1756
|
-
if (url.protocol !== "https:") {
|
|
1757
|
-
throw refuse(`client_id metadata must be served over https (got '${url.protocol}')`);
|
|
1747
|
+
throw new TypeError(`oauth-host: redirectUri must be an absolute URI: '${value}'`);
|
|
1758
1748
|
}
|
|
1759
|
-
|
|
1760
|
-
|
|
1749
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
1750
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
1751
|
+
throw new TypeError(
|
|
1752
|
+
`oauth-host: redirectUri must be https (http is allowed only on localhost/127.0.0.1): '${value}'`
|
|
1753
|
+
);
|
|
1761
1754
|
}
|
|
1762
1755
|
if (url.hash) {
|
|
1763
|
-
throw
|
|
1756
|
+
throw new TypeError(`oauth-host: redirectUri must not contain a fragment: '${value}'`);
|
|
1764
1757
|
}
|
|
1765
|
-
|
|
1766
|
-
|
|
1758
|
+
return value;
|
|
1759
|
+
}
|
|
1760
|
+
function assertRedirectUris2(input) {
|
|
1761
|
+
if (!Array.isArray(input) || input.length === 0) {
|
|
1762
|
+
throw new TypeError("oauth-host: a client needs at least one redirectUri");
|
|
1767
1763
|
}
|
|
1768
|
-
return
|
|
1764
|
+
return input.map(assertRedirectUri);
|
|
1769
1765
|
}
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
const
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
if (done) break;
|
|
1780
|
-
if (!value) continue;
|
|
1781
|
-
total += value.byteLength;
|
|
1782
|
-
if (total > maxBytes) {
|
|
1783
|
-
void reader.cancel();
|
|
1784
|
-
throw refuse(`client_id metadata document exceeds ${maxBytes} bytes`);
|
|
1766
|
+
function assertScopes(ctx, input) {
|
|
1767
|
+
if (!Array.isArray(input) || input.length === 0) {
|
|
1768
|
+
throw new TypeError("oauth-host: a client needs at least one entry in allowedScopes");
|
|
1769
|
+
}
|
|
1770
|
+
for (const id of input) {
|
|
1771
|
+
if (typeof id !== "string" || !ctx.scopeIndex.has(id)) {
|
|
1772
|
+
throw new TypeError(
|
|
1773
|
+
`oauth-host: allowedScopes contains '${String(id)}', which is not in the configured scope catalog`
|
|
1774
|
+
);
|
|
1785
1775
|
}
|
|
1786
|
-
chunks.push(Buffer.from(value));
|
|
1787
1776
|
}
|
|
1788
|
-
return
|
|
1777
|
+
return [...input];
|
|
1789
1778
|
}
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
...etag ? { "if-none-match": etag } : {}
|
|
1802
|
-
},
|
|
1803
|
-
signal: AbortSignal.timeout(ctx.cimd.fetchTimeoutMs)
|
|
1804
|
-
});
|
|
1805
|
-
} catch (err) {
|
|
1806
|
-
const name = err?.name;
|
|
1807
|
-
if (name === "TimeoutError" || name === "AbortError") {
|
|
1808
|
-
throw refuse(`client_id metadata fetch timed out after ${ctx.cimd.fetchTimeoutMs}ms`);
|
|
1779
|
+
function assertResources(ctx, input) {
|
|
1780
|
+
const declared = new Set(ctx.resources.map((r) => r.id));
|
|
1781
|
+
if (input === void 0) return ctx.resources.map((r) => r.id);
|
|
1782
|
+
if (!Array.isArray(input) || input.length === 0) {
|
|
1783
|
+
throw new TypeError("oauth-host: allowedResources, when given, must list at least one resource");
|
|
1784
|
+
}
|
|
1785
|
+
for (const id of input) {
|
|
1786
|
+
if (typeof id !== "string" || !declared.has(id)) {
|
|
1787
|
+
throw new TypeError(
|
|
1788
|
+
`oauth-host: allowedResources contains '${String(id)}', which is not a configured resource`
|
|
1789
|
+
);
|
|
1809
1790
|
}
|
|
1810
|
-
throw refuse(`client_id metadata could not be fetched: ${err?.message ?? "network error"}`);
|
|
1811
1791
|
}
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1792
|
+
return [...input];
|
|
1793
|
+
}
|
|
1794
|
+
function assertClientType(value) {
|
|
1795
|
+
if (value === void 0) return "confidential";
|
|
1796
|
+
if (value !== "confidential" && value !== "public") {
|
|
1797
|
+
throw new TypeError(
|
|
1798
|
+
`oauth-host: client type must be 'confidential' or 'public' (got ${JSON.stringify(value)})`
|
|
1816
1799
|
);
|
|
1817
1800
|
}
|
|
1818
|
-
|
|
1819
|
-
|
|
1801
|
+
return value;
|
|
1802
|
+
}
|
|
1803
|
+
function assertName(value) {
|
|
1804
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
1805
|
+
throw new TypeError("oauth-host: a client needs a non-empty `name` \u2014 it is what the consent screen shows");
|
|
1820
1806
|
}
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1807
|
+
return value.trim();
|
|
1808
|
+
}
|
|
1809
|
+
async function revokeGrantsMatching(ctx, filter, by) {
|
|
1810
|
+
const res = await ctx.models.Grant.updateMany(
|
|
1811
|
+
{ ...filter, revokedAt: null },
|
|
1812
|
+
{ $set: { revokedAt: /* @__PURE__ */ new Date(), revokedBy: by } }
|
|
1813
|
+
);
|
|
1814
|
+
return { grantsRevoked: res.modifiedCount ?? 0 };
|
|
1815
|
+
}
|
|
1816
|
+
async function revokeTokensMatching(ctx, filter) {
|
|
1817
|
+
const res = await ctx.models.Token.updateMany(
|
|
1818
|
+
{ ...filter, revokedAt: null },
|
|
1819
|
+
{ $set: { revokedAt: /* @__PURE__ */ new Date() } }
|
|
1820
|
+
);
|
|
1821
|
+
return res.modifiedCount ?? 0;
|
|
1822
|
+
}
|
|
1823
|
+
function createClientsApi(ctx) {
|
|
1824
|
+
async function create(spec) {
|
|
1825
|
+
if (!spec || typeof spec !== "object") {
|
|
1826
|
+
throw new TypeError("oauth-host: clients.create(spec) requires a spec object");
|
|
1827
|
+
}
|
|
1828
|
+
const name = assertName(spec.name);
|
|
1829
|
+
const type = assertClientType(spec.type);
|
|
1830
|
+
const redirectUris = assertRedirectUris2(spec.redirectUris);
|
|
1831
|
+
const allowedScopes = assertScopes(ctx, spec.allowedScopes);
|
|
1832
|
+
const allowedResources = assertResources(ctx, spec.allowedResources);
|
|
1833
|
+
const clientId = spec.clientId ?? generateClientId();
|
|
1834
|
+
const clientSecret = type === "confidential" ? generateClientSecret() : void 0;
|
|
1835
|
+
const doc = await ctx.models.Client.create({
|
|
1836
|
+
clientId,
|
|
1837
|
+
name,
|
|
1838
|
+
type,
|
|
1839
|
+
registration: "manual",
|
|
1840
|
+
trusted: Boolean(spec.trusted),
|
|
1841
|
+
// Only the digest is stored. `clientSecret` below is the only time the
|
|
1842
|
+
// raw value exists outside the caller's variable. Empty for a public
|
|
1843
|
+
// client, which is the same shape a CIMD row is written with.
|
|
1844
|
+
secrets: clientSecret ? [{ hash: sha256(clientSecret), label: "initial", createdAt: /* @__PURE__ */ new Date() }] : [],
|
|
1845
|
+
redirectUris,
|
|
1846
|
+
allowedScopes,
|
|
1847
|
+
allowedResources,
|
|
1848
|
+
branding: spec.branding ?? {},
|
|
1849
|
+
status: "active"
|
|
1850
|
+
});
|
|
1851
|
+
await ctx.audit({ type: "oauth.client_created", actor: "admin", clientId, meta: { type } });
|
|
1852
|
+
return clientSecret ? { client: toPublicClient(doc), clientId, type: "confidential", clientSecret } : { client: toPublicClient(doc), clientId, type: "public" };
|
|
1824
1853
|
}
|
|
1825
|
-
|
|
1826
|
-
|
|
1854
|
+
return {
|
|
1855
|
+
create,
|
|
1856
|
+
async rotateSecret(clientId, opts = {}) {
|
|
1857
|
+
const doc = await ctx.models.Client.findOne({ clientId });
|
|
1858
|
+
if (!doc) throw notFound(clientId);
|
|
1859
|
+
if (doc.type === "public") {
|
|
1860
|
+
throw new Error(
|
|
1861
|
+
`oauth-host: client '${clientId}' is a public client and has no secret to rotate. Public clients authenticate with client_id and PKCE.`
|
|
1862
|
+
);
|
|
1863
|
+
}
|
|
1864
|
+
const retireAfter = typeof opts.retireAfter === "number" && opts.retireAfter > 0 ? opts.retireAfter : 0;
|
|
1865
|
+
const retiresAt = new Date(Date.now() + retireAfter);
|
|
1866
|
+
for (const record of doc.secrets) {
|
|
1867
|
+
record.retiresAt = record.retiresAt && record.retiresAt < retiresAt ? record.retiresAt : retiresAt;
|
|
1868
|
+
}
|
|
1869
|
+
const clientSecret = generateClientSecret();
|
|
1870
|
+
doc.secrets.push({
|
|
1871
|
+
hash: sha256(clientSecret),
|
|
1872
|
+
label: opts.label ?? `rotated-${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
1873
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
1874
|
+
});
|
|
1875
|
+
doc.markModified("secrets");
|
|
1876
|
+
await doc.save();
|
|
1877
|
+
ctx.track({ type: "oauth.client_secret_rotated", clientId });
|
|
1878
|
+
await ctx.audit({
|
|
1879
|
+
type: "oauth.client_secret_rotated",
|
|
1880
|
+
actor: "admin",
|
|
1881
|
+
clientId,
|
|
1882
|
+
meta: { retireAfter, retiresAt }
|
|
1883
|
+
});
|
|
1884
|
+
return { client: toPublicClient(doc), clientId, type: "confidential", clientSecret };
|
|
1885
|
+
},
|
|
1886
|
+
async update(clientId, patch) {
|
|
1887
|
+
const doc = await ctx.models.Client.findOne({ clientId });
|
|
1888
|
+
if (!doc) throw notFound(clientId);
|
|
1889
|
+
if (patch.name !== void 0) doc.name = assertName(patch.name);
|
|
1890
|
+
if (patch.redirectUris !== void 0) doc.redirectUris = assertRedirectUris2(patch.redirectUris);
|
|
1891
|
+
if (patch.allowedScopes !== void 0) doc.allowedScopes = assertScopes(ctx, patch.allowedScopes);
|
|
1892
|
+
if (patch.allowedResources !== void 0) {
|
|
1893
|
+
doc.allowedResources = assertResources(ctx, patch.allowedResources);
|
|
1894
|
+
}
|
|
1895
|
+
if (patch.branding !== void 0) doc.branding = patch.branding;
|
|
1896
|
+
if (patch.trusted !== void 0) doc.trusted = Boolean(patch.trusted);
|
|
1897
|
+
await doc.save();
|
|
1898
|
+
await ctx.audit({ type: "oauth.client_updated", actor: "admin", clientId });
|
|
1899
|
+
return toPublicClient(doc);
|
|
1900
|
+
},
|
|
1901
|
+
async list(query = {}) {
|
|
1902
|
+
const limit = clampLimit(query.limit);
|
|
1903
|
+
const skip = clampSkip(query.skip);
|
|
1904
|
+
const filter = {};
|
|
1905
|
+
if (query.status) filter.status = query.status;
|
|
1906
|
+
const docs = await ctx.models.Client.find(filter).sort({ createdAt: -1 }).skip(skip).limit(limit).lean();
|
|
1907
|
+
return { items: docs.map(toPublicClient), limit };
|
|
1908
|
+
},
|
|
1909
|
+
async get(clientId) {
|
|
1910
|
+
const doc = await ctx.models.Client.findOne({ clientId }).lean();
|
|
1911
|
+
return doc ? toPublicClient(doc) : null;
|
|
1912
|
+
},
|
|
1913
|
+
async disable(clientId) {
|
|
1914
|
+
const doc = await ctx.models.Client.findOne({ clientId });
|
|
1915
|
+
if (!doc) throw notFound(clientId);
|
|
1916
|
+
doc.status = "disabled";
|
|
1917
|
+
await doc.save();
|
|
1918
|
+
const { grantsRevoked } = await revokeGrantsMatching(ctx, { clientId }, "admin");
|
|
1919
|
+
const tokensRevoked = await revokeTokensMatching(ctx, { clientId });
|
|
1920
|
+
await ctx.models.Code.deleteMany({ clientId });
|
|
1921
|
+
await ctx.models.Request.deleteMany({ clientId });
|
|
1922
|
+
await ctx.audit({
|
|
1923
|
+
type: "oauth.client_disabled",
|
|
1924
|
+
actor: "admin",
|
|
1925
|
+
clientId,
|
|
1926
|
+
meta: { grantsRevoked, tokensRevoked }
|
|
1927
|
+
});
|
|
1928
|
+
return { grantsRevoked, tokensRevoked };
|
|
1929
|
+
}
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1932
|
+
function formUrlDecode(value) {
|
|
1827
1933
|
try {
|
|
1828
|
-
|
|
1934
|
+
return decodeURIComponent(value.replace(/\+/g, " "));
|
|
1829
1935
|
} catch {
|
|
1830
|
-
|
|
1936
|
+
return value;
|
|
1831
1937
|
}
|
|
1832
|
-
|
|
1833
|
-
|
|
1938
|
+
}
|
|
1939
|
+
function readCredentials(req) {
|
|
1940
|
+
const header = req.headers?.authorization;
|
|
1941
|
+
if (typeof header === "string" && /^Basic /i.test(header)) {
|
|
1942
|
+
const decoded = Buffer.from(header.slice(6).trim(), "base64").toString("utf8");
|
|
1943
|
+
const sep = decoded.indexOf(":");
|
|
1944
|
+
const clientId = formUrlDecode(sep < 0 ? decoded : decoded.slice(0, sep));
|
|
1945
|
+
const secret = sep < 0 ? "" : formUrlDecode(decoded.slice(sep + 1));
|
|
1946
|
+
return { clientId, clientSecret: secret === "" ? null : secret, viaBasic: true };
|
|
1834
1947
|
}
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1948
|
+
const body = req.body ?? {};
|
|
1949
|
+
if (typeof body.client_id === "string" && body.client_id) {
|
|
1950
|
+
return {
|
|
1951
|
+
clientId: body.client_id,
|
|
1952
|
+
clientSecret: typeof body.client_secret === "string" && body.client_secret ? body.client_secret : null,
|
|
1953
|
+
viaBasic: false
|
|
1954
|
+
};
|
|
1955
|
+
}
|
|
1956
|
+
return null;
|
|
1839
1957
|
}
|
|
1840
|
-
function
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1958
|
+
function authFailure(viaBasic) {
|
|
1959
|
+
return new OAuthError(401, "invalid_client", "client authentication failed", {
|
|
1960
|
+
// RFC 6749 §5.2 — a 401 answering a Basic credential MUST carry the challenge.
|
|
1961
|
+
headers: viaBasic ? { "WWW-Authenticate": 'Basic realm="oauth", charset="UTF-8"' } : {}
|
|
1962
|
+
});
|
|
1963
|
+
}
|
|
1964
|
+
var CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
|
|
1965
|
+
var MAX_ASSERTION_BYTES = 8192;
|
|
1966
|
+
var COMPACT_JWS_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
1967
|
+
var ASSERTION_ALGORITHMS = ["RS256", "ES256"];
|
|
1968
|
+
var MAX_ASSERTION_AGE_S = 3600;
|
|
1969
|
+
var MAX_JTI_LEN = 256;
|
|
1970
|
+
var REPLAY_CACHE_MAX = 1e4;
|
|
1971
|
+
var JWKS_CACHE_MAX = 100;
|
|
1972
|
+
var JWKS_FORCE_REFETCH_INTERVAL_MS = 6e4;
|
|
1973
|
+
var ASSERTION_CLOCK_TOLERANCE_S = 60;
|
|
1974
|
+
function tokenEndpointUrl(ctx) {
|
|
1975
|
+
return `${ctx.issuer}${ctx.mountPath}/token`;
|
|
1976
|
+
}
|
|
1977
|
+
function isJwkSet(doc) {
|
|
1978
|
+
return Boolean(doc) && typeof doc === "object" && Array.isArray(doc.keys);
|
|
1979
|
+
}
|
|
1980
|
+
function assertionFailure(reason) {
|
|
1981
|
+
return new Error(reason);
|
|
1982
|
+
}
|
|
1983
|
+
function selectCandidateKeys(keys, header) {
|
|
1984
|
+
return keys.filter((key) => {
|
|
1985
|
+
if (header.kid !== void 0 && key.kid !== header.kid) return false;
|
|
1986
|
+
const use = key.use;
|
|
1987
|
+
if (use !== void 0 && use !== "sig") return false;
|
|
1988
|
+
const alg = key.alg;
|
|
1989
|
+
if (alg !== void 0 && alg !== header.alg) return false;
|
|
1990
|
+
return true;
|
|
1991
|
+
});
|
|
1992
|
+
}
|
|
1993
|
+
async function resolveJwksKeys(ctx, jwksUri, forceRefetch) {
|
|
1994
|
+
const { jwksCache, jwksForceRefetchAt } = ctx.cimd;
|
|
1995
|
+
const now = Date.now();
|
|
1996
|
+
const cached = jwksCache.get(jwksUri);
|
|
1997
|
+
const fresh = cached !== void 0 && now - cached.fetchedAt < ctx.cimd.cacheTtlMs;
|
|
1998
|
+
if (fresh && !forceRefetch) return cached.keys;
|
|
1999
|
+
if (forceRefetch) {
|
|
2000
|
+
const lastForced = jwksForceRefetchAt.get(jwksUri) ?? 0;
|
|
2001
|
+
if (now - lastForced < JWKS_FORCE_REFETCH_INTERVAL_MS) {
|
|
2002
|
+
return cached?.keys ?? [];
|
|
2003
|
+
}
|
|
2004
|
+
jwksForceRefetchAt.set(jwksUri, now);
|
|
1844
2005
|
}
|
|
1845
|
-
|
|
2006
|
+
const doc = await fetchJwks(ctx, jwksUri);
|
|
2007
|
+
if (!isJwkSet(doc)) throw assertionFailure(`jwks_uri '${jwksUri}' did not serve a JWK Set`);
|
|
2008
|
+
if (!jwksCache.has(jwksUri) && jwksCache.size >= JWKS_CACHE_MAX) {
|
|
2009
|
+
const oldest = jwksCache.keys().next();
|
|
2010
|
+
if (!oldest.done) jwksCache.delete(oldest.value);
|
|
2011
|
+
}
|
|
2012
|
+
jwksCache.set(jwksUri, { keys: doc.keys, fetchedAt: now });
|
|
2013
|
+
return doc.keys;
|
|
1846
2014
|
}
|
|
1847
|
-
function
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
2015
|
+
async function loadClientJwks(ctx, client) {
|
|
2016
|
+
if (client.jwks !== void 0 && client.jwks !== null) {
|
|
2017
|
+
if (!isJwkSet(client.jwks)) {
|
|
2018
|
+
throw assertionFailure(`client '${client.clientId}' has a malformed stored 'jwks'`);
|
|
2019
|
+
}
|
|
2020
|
+
return client.jwks.keys;
|
|
1852
2021
|
}
|
|
1853
|
-
|
|
2022
|
+
if (client.jwksUri) {
|
|
2023
|
+
return resolveJwksKeys(ctx, client.jwksUri, false);
|
|
2024
|
+
}
|
|
2025
|
+
throw assertionFailure(`client '${client.clientId}' has neither 'jwks' nor 'jwksUri' stored`);
|
|
2026
|
+
}
|
|
2027
|
+
async function verifyClientAssertion(ctx, body) {
|
|
2028
|
+
if (body.client_assertion_type !== CLIENT_ASSERTION_TYPE) {
|
|
2029
|
+
throw assertionFailure(
|
|
2030
|
+
`client_assertion_type must be '${CLIENT_ASSERTION_TYPE}' (got ${JSON.stringify(body.client_assertion_type)})`
|
|
2031
|
+
);
|
|
2032
|
+
}
|
|
2033
|
+
const assertion = body.client_assertion;
|
|
2034
|
+
if (typeof assertion !== "string" || !assertion || Buffer.byteLength(assertion, "utf8") > MAX_ASSERTION_BYTES || !COMPACT_JWS_RE.test(assertion)) {
|
|
2035
|
+
throw assertionFailure("client_assertion is not a well-formed compact JWS");
|
|
2036
|
+
}
|
|
2037
|
+
if (typeof body.client_secret === "string" && body.client_secret) {
|
|
2038
|
+
throw assertionFailure("client_assertion was presented alongside a client_secret");
|
|
2039
|
+
}
|
|
2040
|
+
let unverified;
|
|
1854
2041
|
try {
|
|
1855
|
-
|
|
2042
|
+
unverified = jose.decodeJwt(assertion);
|
|
1856
2043
|
} catch {
|
|
1857
|
-
throw
|
|
2044
|
+
throw assertionFailure("client_assertion could not be decoded");
|
|
1858
2045
|
}
|
|
1859
|
-
|
|
1860
|
-
|
|
2046
|
+
const iss = unverified.iss;
|
|
2047
|
+
if (typeof iss !== "string" || !iss) {
|
|
2048
|
+
throw assertionFailure("client_assertion is missing iss");
|
|
1861
2049
|
}
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
function assertRedirectUris2(doc) {
|
|
1865
|
-
const value = doc.redirect_uris;
|
|
1866
|
-
if (!Array.isArray(value) || value.length === 0) {
|
|
1867
|
-
throw refuse("client_id metadata field 'redirect_uris' must be a non-empty array");
|
|
2050
|
+
if (typeof body.client_id === "string" && body.client_id && body.client_id !== iss) {
|
|
2051
|
+
throw assertionFailure("client_assertion iss does not match client_id");
|
|
1868
2052
|
}
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
try {
|
|
1875
|
-
url = new URL(entry);
|
|
1876
|
-
} catch {
|
|
1877
|
-
throw refuse(`client_id metadata 'redirect_uris' entry must be an absolute URI: '${entry}'`);
|
|
1878
|
-
}
|
|
1879
|
-
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
1880
|
-
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
1881
|
-
throw refuse(
|
|
1882
|
-
`client_id metadata 'redirect_uris' entry must be https (http is allowed only on localhost/127.0.0.1): '${entry}'`
|
|
1883
|
-
);
|
|
1884
|
-
}
|
|
1885
|
-
if (url.hash) {
|
|
1886
|
-
throw refuse(`client_id metadata 'redirect_uris' entry must not contain a fragment: '${entry}'`);
|
|
1887
|
-
}
|
|
1888
|
-
return entry;
|
|
1889
|
-
});
|
|
1890
|
-
}
|
|
1891
|
-
function validateMetadataDocument(ctx, url, doc) {
|
|
1892
|
-
const declared = requireString(doc, "client_id");
|
|
1893
|
-
let declaredUrl;
|
|
2053
|
+
const client = await ctx.models.Client.findOne({ clientId: iss });
|
|
2054
|
+
if (!client || client.status !== "active" || !client.tokenEndpointAuthMethods?.includes("private_key_jwt")) {
|
|
2055
|
+
throw assertionFailure(`client '${iss}' does not support private_key_jwt`);
|
|
2056
|
+
}
|
|
2057
|
+
let header;
|
|
1894
2058
|
try {
|
|
1895
|
-
|
|
2059
|
+
header = jose.decodeProtectedHeader(assertion);
|
|
1896
2060
|
} catch {
|
|
1897
|
-
throw
|
|
2061
|
+
throw assertionFailure("client_assertion header could not be decoded");
|
|
1898
2062
|
}
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
);
|
|
2063
|
+
const alg = header.alg;
|
|
2064
|
+
if (alg !== "RS256" && alg !== "ES256") {
|
|
2065
|
+
throw assertionFailure(`client_assertion alg '${String(alg)}' is not permitted`);
|
|
1903
2066
|
}
|
|
1904
|
-
const
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
);
|
|
2067
|
+
const stored = await loadClientJwks(ctx, client);
|
|
2068
|
+
let candidates = selectCandidateKeys(stored, { alg, kid: header.kid });
|
|
2069
|
+
if (candidates.length === 0 && client.jwksUri) {
|
|
2070
|
+
const refetched = await resolveJwksKeys(ctx, client.jwksUri, true);
|
|
2071
|
+
candidates = selectCandidateKeys(refetched, { alg, kid: header.kid });
|
|
1909
2072
|
}
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
2073
|
+
if (candidates.length === 0) {
|
|
2074
|
+
throw assertionFailure(`no candidate key for kid '${String(header.kid)}'`);
|
|
2075
|
+
}
|
|
2076
|
+
const tokenEndpoint = tokenEndpointUrl(ctx);
|
|
2077
|
+
let verified;
|
|
2078
|
+
let lastJoseError;
|
|
2079
|
+
for (const jwk of candidates) {
|
|
2080
|
+
try {
|
|
2081
|
+
const key = await jose.importJWK(jwk, alg);
|
|
2082
|
+
verified = await jose.jwtVerify(assertion, key, {
|
|
2083
|
+
// Passed explicitly rather than trusting the header a second time —
|
|
2084
|
+
// this is the actual alg-confusion guard, not the earlier check,
|
|
2085
|
+
// which only short-circuits the common case before a fetch.
|
|
2086
|
+
algorithms: [...ASSERTION_ALGORITHMS],
|
|
2087
|
+
audience: [tokenEndpoint, ctx.issuer],
|
|
2088
|
+
clockTolerance: ASSERTION_CLOCK_TOLERANCE_S
|
|
2089
|
+
});
|
|
2090
|
+
break;
|
|
2091
|
+
} catch (err) {
|
|
2092
|
+
lastJoseError = err;
|
|
1919
2093
|
}
|
|
1920
|
-
const requested = new Set(doc.scope.split(/\s+/).filter(Boolean));
|
|
1921
|
-
allowedScopes = [...permitted].filter((id) => requested.has(id));
|
|
1922
2094
|
}
|
|
1923
|
-
if (
|
|
1924
|
-
throw
|
|
1925
|
-
|
|
2095
|
+
if (!verified) {
|
|
2096
|
+
throw assertionFailure(
|
|
2097
|
+
`client_assertion did not verify: ${lastJoseError instanceof Error ? lastJoseError.message : "unknown error"}`
|
|
1926
2098
|
);
|
|
1927
2099
|
}
|
|
1928
|
-
const
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
const tosUrl = optionalHttpsUri(doc, "tos_uri");
|
|
1932
|
-
const privacyUrl = optionalHttpsUri(doc, "policy_uri");
|
|
1933
|
-
if (logoUrl) branding.logoUrl = logoUrl;
|
|
1934
|
-
if (homepageUrl) branding.homepageUrl = homepageUrl;
|
|
1935
|
-
if (tosUrl) branding.tosUrl = tosUrl;
|
|
1936
|
-
if (privacyUrl) branding.privacyUrl = privacyUrl;
|
|
1937
|
-
return { name, redirectUris, allowedScopes, branding };
|
|
1938
|
-
}
|
|
1939
|
-
function rememberFailure(ctx, key, message) {
|
|
1940
|
-
const { failures } = ctx.cimd;
|
|
1941
|
-
if (failures.size >= MAX_REMEMBERED_FAILURES) {
|
|
1942
|
-
const oldest = failures.keys().next();
|
|
1943
|
-
if (!oldest.done) failures.delete(oldest.value);
|
|
2100
|
+
const { payload } = verified;
|
|
2101
|
+
if (payload.iss !== client.clientId || payload.sub !== client.clientId) {
|
|
2102
|
+
throw assertionFailure("client_assertion iss/sub must both equal the client_id");
|
|
1944
2103
|
}
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
if (!found) return null;
|
|
1950
|
-
if (found.until <= Date.now()) {
|
|
1951
|
-
ctx.cimd.failures.delete(key);
|
|
1952
|
-
return null;
|
|
2104
|
+
const exp = payload.exp;
|
|
2105
|
+
const nowS = Math.floor(Date.now() / 1e3);
|
|
2106
|
+
if (typeof exp !== "number" || exp - nowS > MAX_ASSERTION_AGE_S) {
|
|
2107
|
+
throw assertionFailure("client_assertion exp is too far in the future");
|
|
1953
2108
|
}
|
|
1954
|
-
|
|
2109
|
+
const jti = payload.jti;
|
|
2110
|
+
if (typeof jti !== "string" || !jti || jti.length > MAX_JTI_LEN) {
|
|
2111
|
+
throw assertionFailure("client_assertion is missing jti");
|
|
2112
|
+
}
|
|
2113
|
+
const replay = ctx.cimd.assertionReplay;
|
|
2114
|
+
const replayKey = `${client.clientId}:${jti}`;
|
|
2115
|
+
const existing = replay.get(replayKey);
|
|
2116
|
+
if (existing !== void 0 && existing > Date.now()) {
|
|
2117
|
+
ctx.logger.warn?.({ clientId: client.clientId }, "oauth-host: private_key_jwt assertion replay detected");
|
|
2118
|
+
throw assertionFailure("client_assertion jti has already been used");
|
|
2119
|
+
}
|
|
2120
|
+
if (!replay.has(replayKey) && replay.size >= REPLAY_CACHE_MAX) {
|
|
2121
|
+
const oldest = replay.keys().next();
|
|
2122
|
+
if (!oldest.done) replay.delete(oldest.value);
|
|
2123
|
+
}
|
|
2124
|
+
replay.set(replayKey, (exp + 60) * 1e3);
|
|
2125
|
+
return client;
|
|
1955
2126
|
}
|
|
1956
|
-
function
|
|
1957
|
-
const
|
|
1958
|
-
|
|
1959
|
-
|
|
2127
|
+
async function authenticateClient(ctx, req) {
|
|
2128
|
+
const body = req.body ?? {};
|
|
2129
|
+
const header = req.headers?.authorization;
|
|
2130
|
+
const viaBasic = typeof header === "string" && /^Basic /i.test(header);
|
|
2131
|
+
if (body.client_assertion !== void 0 || body.client_assertion_type !== void 0) {
|
|
2132
|
+
if (viaBasic) {
|
|
2133
|
+
ctx.logger.debug?.(
|
|
2134
|
+
"oauth-host: client_assertion presented alongside a Basic Authorization header"
|
|
2135
|
+
);
|
|
2136
|
+
throw authFailure(viaBasic);
|
|
2137
|
+
}
|
|
2138
|
+
try {
|
|
2139
|
+
return await verifyClientAssertion(ctx, body);
|
|
2140
|
+
} catch (err) {
|
|
2141
|
+
ctx.logger.debug?.(
|
|
2142
|
+
{ err: err instanceof Error ? err.message : err },
|
|
2143
|
+
"oauth-host: private_key_jwt assertion rejected"
|
|
2144
|
+
);
|
|
2145
|
+
throw authFailure(viaBasic);
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
const creds = readCredentials(req);
|
|
2149
|
+
if (!creds || !creds.clientId) throw authFailure(Boolean(creds?.viaBasic));
|
|
2150
|
+
const client = await ctx.models.Client.findOne({ clientId: creds.clientId });
|
|
2151
|
+
if (!client || client.status !== "active") throw authFailure(creds.viaBasic);
|
|
2152
|
+
if (client.type === "public") {
|
|
2153
|
+
if (creds.clientSecret !== null) throw authFailure(creds.viaBasic);
|
|
2154
|
+
return client;
|
|
2155
|
+
}
|
|
2156
|
+
if (creds.clientSecret === null) throw authFailure(creds.viaBasic);
|
|
2157
|
+
const now = Date.now();
|
|
2158
|
+
let matched;
|
|
2159
|
+
for (const record of client.secrets) {
|
|
2160
|
+
if (record.retiresAt && record.retiresAt.getTime() <= now) continue;
|
|
2161
|
+
if (safeEqual(sha256(creds.clientSecret), record.hash)) matched = record;
|
|
2162
|
+
}
|
|
2163
|
+
if (!matched) throw authFailure(creds.viaBasic);
|
|
2164
|
+
const usedAt = /* @__PURE__ */ new Date();
|
|
2165
|
+
matched.lastUsedAt = usedAt;
|
|
2166
|
+
await ctx.models.Client.updateOne(
|
|
2167
|
+
{ clientId: client.clientId, "secrets.hash": matched.hash },
|
|
2168
|
+
{ $set: { "secrets.$.lastUsedAt": usedAt } }
|
|
2169
|
+
);
|
|
2170
|
+
return client;
|
|
1960
2171
|
}
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
2172
|
+
function describeGrantScopes(ctx, ids) {
|
|
2173
|
+
return ids.map((id) => ctx.scopeIndex.get(id) ?? { id, label: id });
|
|
2174
|
+
}
|
|
2175
|
+
function createGrantsApi(ctx) {
|
|
2176
|
+
return {
|
|
2177
|
+
async list(query) {
|
|
2178
|
+
const limit = clampLimit(query?.limit);
|
|
2179
|
+
const skip = clampSkip(query?.skip);
|
|
2180
|
+
const filter = { revokedAt: null };
|
|
2181
|
+
if (query?.userId !== void 0) filter.userId = query.userId;
|
|
2182
|
+
if (query?.clientId !== void 0) filter.clientId = query.clientId;
|
|
2183
|
+
const grants = await ctx.models.Grant.find(filter).sort({ createdAt: -1 }).skip(skip).limit(limit).lean();
|
|
2184
|
+
if (grants.length === 0) return { items: [], limit };
|
|
2185
|
+
const clientIds = [...new Set(grants.map((g) => g.clientId))];
|
|
2186
|
+
const clients = await ctx.models.Client.find({ clientId: { $in: clientIds } }).lean();
|
|
2187
|
+
const byId = new Map(clients.map((c) => [c.clientId, toPublicClient(c)]));
|
|
2188
|
+
const contextCache = /* @__PURE__ */ new Map();
|
|
2189
|
+
const items = [];
|
|
2190
|
+
for (const grant of grants) {
|
|
2191
|
+
const client = byId.get(grant.clientId);
|
|
2192
|
+
const summary = {
|
|
2193
|
+
id: String(grant._id),
|
|
2194
|
+
client: client ? { clientId: client.clientId, name: client.name, branding: client.branding } : { clientId: grant.clientId, name: grant.clientId, branding: {} },
|
|
2195
|
+
scopes: describeGrantScopes(ctx, grant.scopes ?? []),
|
|
2196
|
+
createdAt: grant.createdAt,
|
|
2197
|
+
...grant.lastUsedAt ? { lastUsedAt: grant.lastUsedAt } : {}
|
|
2198
|
+
};
|
|
2199
|
+
if (ctx.grantContext && grant.contextId) {
|
|
2200
|
+
const cacheKey = `${String(grant.userId)}:${grant.clientId}`;
|
|
2201
|
+
let available = contextCache.get(cacheKey);
|
|
2202
|
+
if (!available) {
|
|
2203
|
+
const user = { id: grant.userId };
|
|
2204
|
+
available = client ? await ctx.grantContext.list(user, { client, scopes: grant.scopes ?? [] }) : [];
|
|
2205
|
+
contextCache.set(cacheKey, available);
|
|
2206
|
+
}
|
|
2207
|
+
summary.context = available.find((c) => c.id === grant.contextId) ?? { id: grant.contextId, label: grant.contextId };
|
|
2208
|
+
}
|
|
2209
|
+
items.push(summary);
|
|
1983
2210
|
}
|
|
2211
|
+
return { items, limit };
|
|
1984
2212
|
},
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
2213
|
+
async revoke(grantId, opts = {}) {
|
|
2214
|
+
const by = opts.by ?? "admin";
|
|
2215
|
+
const grant = await ctx.models.Grant.findById(grantId).catch(() => null);
|
|
2216
|
+
if (!grant) return { tokensRevoked: 0 };
|
|
2217
|
+
const alreadyRevoked = Boolean(grant.revokedAt);
|
|
2218
|
+
if (!alreadyRevoked) {
|
|
2219
|
+
grant.revokedAt = /* @__PURE__ */ new Date();
|
|
2220
|
+
grant.revokedBy = by;
|
|
2221
|
+
await grant.save();
|
|
2222
|
+
}
|
|
2223
|
+
const tokensRevoked = await revokeTokensMatching(ctx, { grantId: grant._id });
|
|
2224
|
+
if (!alreadyRevoked) {
|
|
2225
|
+
ctx.track({
|
|
2226
|
+
type: "oauth.grant_revoked",
|
|
2227
|
+
userId: grant.userId,
|
|
2228
|
+
clientId: grant.clientId,
|
|
2229
|
+
grantId: String(grant._id),
|
|
2230
|
+
...grant.contextId ? { contextId: grant.contextId } : {},
|
|
2231
|
+
scopes: grant.scopes
|
|
2232
|
+
});
|
|
2233
|
+
await ctx.audit({
|
|
2234
|
+
type: "oauth.grant_revoked",
|
|
2235
|
+
actor: by,
|
|
2236
|
+
clientId: grant.clientId,
|
|
2237
|
+
userId: grant.userId,
|
|
2238
|
+
grantId: grant._id,
|
|
2239
|
+
meta: { tokensRevoked }
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
return { tokensRevoked };
|
|
2243
|
+
}
|
|
2244
|
+
};
|
|
1988
2245
|
}
|
|
1989
|
-
|
|
1990
|
-
const
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
2246
|
+
function pairwiseKey(userId) {
|
|
2247
|
+
const key = String(userId);
|
|
2248
|
+
if (!key || key.includes(".") || key.startsWith("$")) return null;
|
|
2249
|
+
return key;
|
|
2250
|
+
}
|
|
2251
|
+
function createUsersApi(ctx) {
|
|
2252
|
+
return {
|
|
2253
|
+
/**
|
|
2254
|
+
* Erasure. The user is gone and every trace of them has to go with them.
|
|
2255
|
+
*
|
|
2256
|
+
* This is the destructive twin of `revokeAll`, and the difference is the
|
|
2257
|
+
* whole reason both exist: `forget` DELETES the grant documents and the
|
|
2258
|
+
* user's audit rows, `revokeAll` keeps both. A password change must leave a
|
|
2259
|
+
* history an operator can read; a deletion request must not.
|
|
2260
|
+
*
|
|
2261
|
+
* Idempotent — it will be called twice, by a retry or by a host that wires
|
|
2262
|
+
* it to both a soft-delete and a hard-delete hook.
|
|
2263
|
+
*/
|
|
2264
|
+
async forget(userId) {
|
|
2265
|
+
const grantsResult = await ctx.models.Grant.deleteMany({ userId });
|
|
2266
|
+
const tokensResult = await ctx.models.Token.deleteMany({ userId });
|
|
2267
|
+
await ctx.models.Code.deleteMany({ userId });
|
|
2268
|
+
await ctx.models.Request.deleteMany({ userId });
|
|
2269
|
+
const key = pairwiseKey(userId);
|
|
2270
|
+
if (key) {
|
|
2271
|
+
await ctx.models.Client.updateMany(
|
|
2272
|
+
{ [`pairwiseSubjects.${key}`]: { $exists: true } },
|
|
2273
|
+
{ $unset: { [`pairwiseSubjects.${key}`]: "" } }
|
|
2274
|
+
);
|
|
2275
|
+
}
|
|
2276
|
+
await ctx.models.Audit.deleteMany({ userId });
|
|
2277
|
+
const grants = grantsResult.deletedCount ?? 0;
|
|
2278
|
+
const tokens = tokensResult.deletedCount ?? 0;
|
|
2279
|
+
await ctx.audit({ type: "oauth.user_forgotten", actor: "system", meta: { grants, tokens } });
|
|
2280
|
+
return { grants, tokens };
|
|
2281
|
+
},
|
|
2282
|
+
/**
|
|
2283
|
+
* Password change, deactivation, suspected compromise.
|
|
2284
|
+
*
|
|
2285
|
+
* Kills live access and KEEPS everything else: the grant documents stay so
|
|
2286
|
+
* the audit trail still resolves and so the user's connected apps remain
|
|
2287
|
+
* visible, and the audit rows stay because that is the record the operator
|
|
2288
|
+
* called this to create. See `forget` above for the destructive twin.
|
|
2289
|
+
*/
|
|
2290
|
+
async revokeAll(userId, opts = {}) {
|
|
2291
|
+
const live = await ctx.models.Grant.find({ userId, revokedAt: null }).limit(MAX_LIST).lean();
|
|
2292
|
+
const { grantsRevoked } = await revokeGrantsMatching(ctx, { userId }, "system");
|
|
2293
|
+
const tokensRevoked = await revokeTokensMatching(ctx, { userId });
|
|
2294
|
+
await ctx.models.Code.deleteMany({ userId });
|
|
2295
|
+
await ctx.models.Request.deleteMany({ userId });
|
|
2296
|
+
for (const grant of live) {
|
|
2297
|
+
ctx.track({
|
|
2298
|
+
type: "oauth.grant_revoked",
|
|
2299
|
+
userId,
|
|
2300
|
+
clientId: grant.clientId,
|
|
2301
|
+
grantId: String(grant._id),
|
|
2302
|
+
...grant.contextId ? { contextId: grant.contextId } : {},
|
|
2303
|
+
scopes: grant.scopes
|
|
2304
|
+
});
|
|
2305
|
+
}
|
|
2306
|
+
await ctx.audit({
|
|
2307
|
+
type: "oauth.user_access_revoked",
|
|
2308
|
+
actor: "system",
|
|
2309
|
+
userId,
|
|
2310
|
+
meta: { grantsRevoked, tokensRevoked, ...opts.reason ? { reason: opts.reason } : {} }
|
|
2311
|
+
});
|
|
2312
|
+
return { grantsRevoked, tokensRevoked };
|
|
1995
2313
|
}
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2316
|
+
function createContextsApi(ctx) {
|
|
2317
|
+
return {
|
|
2318
|
+
/**
|
|
2319
|
+
* A grant made as an employee has to die when the employment does.
|
|
2320
|
+
*
|
|
2321
|
+
* `grantContext.verify()` catches this on the next refresh, but an access
|
|
2322
|
+
* token already issued is valid for its full hour and nothing re-checks it.
|
|
2323
|
+
* This is the push half of that pair, and it is why the adapter has an
|
|
2324
|
+
* outbound direction at all.
|
|
2325
|
+
*/
|
|
2326
|
+
async revoked(userId, contextId) {
|
|
2327
|
+
const live = await ctx.models.Grant.find({ userId, contextId, revokedAt: null }).limit(MAX_LIST).lean();
|
|
2328
|
+
const { grantsRevoked } = await revokeGrantsMatching(ctx, { userId, contextId }, "system");
|
|
2329
|
+
const tokensRevoked = await revokeTokensMatching(ctx, { userId, contextId });
|
|
2330
|
+
await ctx.models.Code.deleteMany({ userId, contextId });
|
|
2331
|
+
for (const grant of live) {
|
|
2332
|
+
ctx.track({
|
|
2333
|
+
type: "oauth.grant_revoked",
|
|
2334
|
+
userId,
|
|
2335
|
+
clientId: grant.clientId,
|
|
2336
|
+
grantId: String(grant._id),
|
|
2337
|
+
contextId,
|
|
2338
|
+
scopes: grant.scopes
|
|
2339
|
+
});
|
|
2340
|
+
await ctx.audit({
|
|
2341
|
+
type: "oauth.grant_revoked",
|
|
2342
|
+
actor: "system",
|
|
2343
|
+
clientId: grant.clientId,
|
|
2344
|
+
userId,
|
|
2345
|
+
grantId: grant._id,
|
|
2346
|
+
meta: { contextId, reason: "context_membership_ended" }
|
|
2347
|
+
});
|
|
2008
2348
|
}
|
|
2009
|
-
|
|
2349
|
+
return { grantsRevoked, tokensRevoked };
|
|
2010
2350
|
}
|
|
2011
|
-
|
|
2012
|
-
const client = await persist(ctx, clientId, url, registration, etag);
|
|
2013
|
-
ctx.logger.debug?.({ clientId }, "oauth-host: client_id metadata document resolved");
|
|
2014
|
-
return client;
|
|
2015
|
-
} catch (err) {
|
|
2016
|
-
const message = err instanceof UnredirectableError ? err.description ?? "client_id metadata could not be resolved" : "client_id metadata could not be resolved";
|
|
2017
|
-
rememberFailure(ctx, url.href, message);
|
|
2018
|
-
ctx.logger.warn?.({ clientId, err }, "oauth-host: client_id metadata resolution failed");
|
|
2019
|
-
throw err instanceof UnredirectableError ? err : refuse(message);
|
|
2020
|
-
}
|
|
2351
|
+
};
|
|
2021
2352
|
}
|
|
2022
2353
|
|
|
2023
2354
|
// src/server/services/scopes.ts
|