@camstack/addon-auth 1.2.143 → 1.2.145

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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DscyrBBw.js");
5
+ const require_dist = require("../dist-Be0i-dLp.js");
6
6
  let node_crypto = require("node:crypto");
7
7
  node_crypto = require_dist.__toESM(node_crypto);
8
8
  //#region ../../node_modules/jose/dist/webapi/lib/buffer_utils.js
@@ -886,83 +886,6 @@ function clientSecretPatchOf(value) {
886
886
  return { clientSecret: value };
887
887
  }
888
888
  //#endregion
889
- //#region src/oidc/request-origin.ts
890
- /** host[:port] — a DNS name, IPv4 or bracketed IPv6. No userinfo, path or spaces. */
891
- var AUTHORITY_RE = /^(?:[a-z0-9.-]+|\[[0-9a-f:.]+\])(?::\d{1,5})?$/i;
892
- /** Same grammar as the hub's `parseForwardedPrefix` (spa-mount.ts). */
893
- var PREFIX_RE = /^\/[A-Za-z0-9_\-.]+(?:\/[A-Za-z0-9_\-.]+)*$/;
894
- var MAX_PREFIX_LENGTH = 512;
895
- /** First value of a possibly comma-joined forwarded header (the client-most proxy). */
896
- function firstValue(value) {
897
- if (value === void 0) return null;
898
- const first = value.split(",")[0]?.trim() ?? "";
899
- return first.length === 0 ? null : first;
900
- }
901
- /** `scheme://host[:port]` normalised by WHATWG URL, or `null` if it is not an http(s) origin. */
902
- function normalizeOrigin(value) {
903
- try {
904
- const url = new URL(value);
905
- if (url.protocol !== "https:" && url.protocol !== "http:") return null;
906
- if (url.username !== "" || url.password !== "") return null;
907
- return url.origin;
908
- } catch {
909
- return null;
910
- }
911
- }
912
- /** The mount prefix the relay named (no trailing slash), or `''`. Invalid = absent. */
913
- function requestMountPrefix(headers) {
914
- const raw = headers["x-forwarded-prefix"];
915
- if (raw === void 0 || raw.length === 0 || raw.length > MAX_PREFIX_LENGTH) return "";
916
- const trimmed = raw.endsWith("/") ? raw.slice(0, -1) : raw;
917
- if (trimmed === "" || !PREFIX_RE.test(trimmed)) return "";
918
- if (trimmed.split("/").some((segment) => segment === ".." || segment === ".")) return "";
919
- return trimmed;
920
- }
921
- /**
922
- * The origins the request CLAIMS, most specific first: the proxy's
923
- * `X-Forwarded-Host` over `Host`; the named `X-Forwarded-Proto`, or https
924
- * then http when none is named (the hub terminates TLS itself on 4443).
925
- */
926
- function claimedRequestOrigins(headers) {
927
- const authority = firstValue(headers["x-forwarded-host"]) ?? firstValue(headers["host"]);
928
- if (authority === null || !AUTHORITY_RE.test(authority)) return [];
929
- const proto = firstValue(headers["x-forwarded-proto"])?.toLowerCase() ?? null;
930
- const schemes = proto === "https" || proto === "http" ? [proto] : proto === null ? ["https", "http"] : [];
931
- const out = [];
932
- for (const scheme of schemes) {
933
- const origin = normalizeOrigin(`${scheme}://${authority}`);
934
- if (origin !== null) out.push(origin);
935
- }
936
- return out;
937
- }
938
- /** Every origin the hub advertises itself at, normalised. Never the app link, never loopback. */
939
- function advertisedHubOrigins(book) {
940
- const candidates = [];
941
- for (const row of book.rows) {
942
- if (row.kind === "app-link" || row.kind === "loopback") continue;
943
- candidates.push(row.baseUrl);
944
- }
945
- const { publicEndpoint, internal, appConnections } = book.roles;
946
- if (publicEndpoint.resolved !== null) candidates.push(publicEndpoint.resolved);
947
- if (internal.resolved !== null) candidates.push(internal.resolved);
948
- candidates.push(...appConnections.resolved);
949
- candidates.push(...book.listenerOrigins ?? []);
950
- const out = /* @__PURE__ */ new Set();
951
- for (const candidate of candidates) {
952
- const origin = normalizeOrigin(candidate);
953
- if (origin !== null) out.add(origin);
954
- }
955
- return out;
956
- }
957
- /**
958
- * The origin to send the browser back to: the first claimed origin that is in
959
- * `allowed`, or `null` (the caller then uses its default address).
960
- */
961
- function pickRequestOrigin(headers, allowed) {
962
- for (const origin of claimedRequestOrigins(headers)) if (allowed.has(origin)) return origin;
963
- return null;
964
- }
965
- //#endregion
966
889
  //#region src/oidc/auth-oidc.addon.ts
967
890
  /**
968
891
  * Generic OpenID Connect (OIDC) authentication provider.
@@ -1015,6 +938,28 @@ var STATE_TTL_MS = 5 * 6e4;
1015
938
  var LINK_INTENT_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
1016
939
  /** Bound on in-flight logins: `/start` is public, so a flood must not grow the map. */
1017
940
  var MAX_PENDING_STATES = 1e3;
941
+ /** In-flight logins one client may hold (D858): one browser flooding `/start` cannot fill the map for everyone. */
942
+ var MAX_PENDING_PER_CLIENT = 20;
943
+ /**
944
+ * A step-up must follow a login that happened no earlier than this before the
945
+ * flow began (D858). The authorize request asks for `prompt=login&max_age=0`;
946
+ * this is the check that the IdP actually honoured it.
947
+ */
948
+ var STEP_UP_MAX_AUTH_AGE_MS = 6e4;
949
+ /** The pending map is full, or this client already holds its share. Live states are never evicted for it. */
950
+ var PendingStatesFullError = class extends Error {};
951
+ /** A step-up whose id_token proves no login just before the flow began. */
952
+ var StepUpNotFreshError = class extends Error {};
953
+ /**
954
+ * Who a `/start` is counted against. The hub already limits each real client
955
+ * address per minute before a route runs; the addon sees only headers, so this
956
+ * is a best-effort key (a spoofed header only rotates the bucket, and the
957
+ * hub's limit and the global cap still bound the total).
958
+ */
959
+ function clientKeyOf(headers) {
960
+ const forwarded = headers?.["x-forwarded-for"]?.split(",")[0]?.trim();
961
+ return headers?.["cf-connecting-ip"] ?? headers?.["x-real-ip"] ?? forwarded ?? "unknown";
962
+ }
1018
963
  /**
1019
964
  * Origin + mount prefix of a pinned `redirect_uri`: the part before the
1020
965
  * callback path when it ends with it, else just its origin.
@@ -1185,7 +1130,11 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1185
1130
  icon: require_dist.resolveIconKey(inst.config.icon, DEFAULT_PROVIDER.icon),
1186
1131
  startUrl: `/addon/auth-oidc/${id}/start`,
1187
1132
  stage: "primary",
1188
- linkable: true
1133
+ linkable: true,
1134
+ app: {
1135
+ kind: "browser",
1136
+ startPath: `/addon/auth-oidc/${id}/start`
1137
+ }
1189
1138
  });
1190
1139
  }
1191
1140
  return out;
@@ -1267,23 +1216,23 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1267
1216
  const inst = this.instances.get(providerId);
1268
1217
  if (!inst) throw new Error(`OIDC: no provider configured with id "${providerId}"`);
1269
1218
  const callbackPath = `/addon/auth-oidc/${providerId}/callback`;
1270
- const claimed = headers !== null ? claimedRequestOrigins(headers) : [];
1219
+ const claimed = headers !== null ? require_dist.claimedRequestOrigins(headers) : [];
1271
1220
  const configured = inst.config.redirectUri?.trim();
1272
1221
  if (configured) return this.returnsTo(providerId, "override", claimed, null, {
1273
1222
  redirectUri: configured,
1274
1223
  returnBase: returnBaseOf(configured, callbackPath)
1275
1224
  });
1276
1225
  const book = await this.addressBook();
1277
- const advertised = book !== null ? advertisedHubOrigins(book) : null;
1278
- const fromRequest = headers !== null && advertised !== null ? pickRequestOrigin(headers, advertised) : null;
1226
+ const advertised = book !== null ? require_dist.advertisedHubOrigins(book) : null;
1227
+ const fromRequest = headers !== null && advertised !== null ? require_dist.pickRequestOrigin(headers, advertised) : null;
1279
1228
  if (fromRequest !== null) {
1280
- const base = `${fromRequest}${requestMountPrefix(headers ?? {})}`;
1229
+ const base = `${fromRequest}${require_dist.requestMountPrefix(headers ?? {})}`;
1281
1230
  return this.returnsTo(providerId, "request", claimed, advertised, {
1282
1231
  redirectUri: `${base}${callbackPath}`,
1283
1232
  returnBase: base
1284
1233
  });
1285
1234
  }
1286
- const publicEndpoint = book !== null ? normalizeOrigin(book.roles.publicEndpoint.resolved ?? "") : null;
1235
+ const publicEndpoint = book !== null ? require_dist.normalizeOrigin(book.roles.publicEndpoint.resolved ?? "") : null;
1287
1236
  const fallback = publicEndpoint ?? "https://localhost:4443";
1288
1237
  return this.returnsTo(providerId, publicEndpoint !== null ? "public-endpoint" : "default", claimed, advertised, {
1289
1238
  redirectUri: `${fallback}${callbackPath}`,
@@ -1321,12 +1270,10 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1321
1270
  const nonce = node_crypto.randomBytes(16).toString("base64url");
1322
1271
  const { redirectUri, returnBase } = await this.resolveRedirect(providerId, options.headers ?? null);
1323
1272
  this.pruneExpiredStates();
1324
- while (this.pendingStates.size >= MAX_PENDING_STATES) {
1325
- const oldest = this.pendingStates.keys().next();
1326
- if (oldest.done) break;
1327
- this.pendingStates.delete(oldest.value);
1328
- }
1273
+ const clientKey = clientKeyOf(options.headers);
1274
+ if (this.pendingStates.size >= MAX_PENDING_STATES || [...this.pendingStates.values()].filter((p) => p.clientKey === clientKey).length >= MAX_PENDING_PER_CLIENT) throw new PendingStatesFullError("Too many sign-ins in progress - try again shortly");
1329
1275
  this.pendingStates.set(state, {
1276
+ clientKey,
1330
1277
  codeVerifier,
1331
1278
  nonce,
1332
1279
  providerId,
@@ -1335,7 +1282,8 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1335
1282
  popup: options.popup === true,
1336
1283
  createdAt: Date.now(),
1337
1284
  ...linkIntent !== void 0 ? { linkIntent } : {},
1338
- ...options.browserBinding !== void 0 ? { browserBinding: options.browserBinding } : {}
1285
+ ...options.browserBinding !== void 0 ? { browserBinding: options.browserBinding } : {},
1286
+ ...options.app !== void 0 ? { app: options.app } : {}
1339
1287
  });
1340
1288
  const params = new URLSearchParams({
1341
1289
  response_type: "code",
@@ -1347,17 +1295,27 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1347
1295
  code_challenge: codeChallenge,
1348
1296
  code_challenge_method: "S256"
1349
1297
  });
1298
+ if (options.app?.purpose === "step-up") {
1299
+ params.set("prompt", "login");
1300
+ params.set("max_age", "0");
1301
+ }
1350
1302
  return {
1351
1303
  url: `${doc.authorization_endpoint}?${params.toString()}`,
1352
1304
  returnBase
1353
1305
  };
1354
1306
  }
1307
+ /** The pending state for `state`, unless it is gone or older than its TTL. */
1308
+ livePending(state) {
1309
+ const pending = this.pendingStates.get(state);
1310
+ if (pending === void 0) return void 0;
1311
+ return Date.now() - pending.createdAt > STATE_TTL_MS ? void 0 : pending;
1312
+ }
1355
1313
  pruneExpiredStates() {
1356
1314
  const now = Date.now();
1357
1315
  for (const [k, v] of this.pendingStates.entries()) if (now - v.createdAt > STATE_TTL_MS) this.pendingStates.delete(k);
1358
1316
  }
1359
1317
  async handleCallback(code, state) {
1360
- const pending = this.pendingStates.get(state);
1318
+ const pending = this.livePending(state);
1361
1319
  if (!pending) throw new Error("Unknown or expired OIDC state — login session timed out, please retry");
1362
1320
  this.pendingStates.delete(state);
1363
1321
  const providerId = pending.providerId;
@@ -1398,6 +1356,10 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1398
1356
  throw new Error(`OIDC id_token verification failed: ${require_dist.errMsg(err)}`, { cause: err });
1399
1357
  }
1400
1358
  if (typeof payload["nonce"] !== "string" || payload["nonce"] !== pending.nonce) throw new Error("OIDC id_token nonce mismatch — possible replay attack");
1359
+ if (pending.app?.purpose === "step-up") {
1360
+ const authTime = payload["auth_time"];
1361
+ if (typeof authTime !== "number" || !Number.isFinite(authTime) || authTime * 1e3 < pending.createdAt - STEP_UP_MAX_AUTH_AGE_MS) throw new StepUpNotFreshError("OIDC step-up id_token proves no fresh login");
1362
+ }
1401
1363
  const sub = typeof payload.sub === "string" ? payload.sub : null;
1402
1364
  if (!sub) throw new Error("OIDC callback: id_token missing required `sub` claim");
1403
1365
  const emailClaim = typeof payload["email"] === "string" ? payload["email"] : void 0;
@@ -1416,7 +1378,8 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1416
1378
  providerId,
1417
1379
  returnBase: pending.returnBase,
1418
1380
  popup: pending.popup,
1419
- ...pending.browserBinding !== void 0 ? { browserBinding: pending.browserBinding } : {}
1381
+ ...pending.browserBinding !== void 0 ? { browserBinding: pending.browserBinding } : {},
1382
+ ...pending.app !== void 0 ? { app: pending.app } : {}
1420
1383
  };
1421
1384
  }
1422
1385
  validateLocalSessionToken(_token) {
@@ -1435,21 +1398,52 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1435
1398
  reply.send({ error: "Malformed link request" });
1436
1399
  return;
1437
1400
  }
1438
- const popupParam = req.query["popup"];
1439
- const state = node_crypto.randomBytes(16).toString("base64url");
1401
+ const query = req.query;
1402
+ const popupParam = query["popup"];
1403
+ let app;
1404
+ if (query["app"] === "1") {
1405
+ const parsed = require_dist.parseAppFlowRequest(query);
1406
+ if (linkParam !== void 0 || popupParam === "1" || !parsed.ok) {
1407
+ this.ctx.logger.warn("OIDC app sign-in refused at start", { meta: {
1408
+ provider: providerId,
1409
+ reason: parsed.ok ? "combined-flow" : parsed.reason
1410
+ } });
1411
+ reply.code(400);
1412
+ reply.type("text/html; charset=utf-8");
1413
+ reply.header("Cache-Control", "no-store");
1414
+ reply.send(appRefusalPage(parsed.ok ? "An app sign-in cannot be combined with linking or a popup." : require_dist.APP_FLOW_REFUSAL_MESSAGES[parsed.reason]));
1415
+ return;
1416
+ }
1417
+ app = parsed.request;
1418
+ }
1419
+ const state = `${app !== void 0 ? "a." : ""}${node_crypto.randomBytes(16).toString("base64url")}`;
1440
1420
  const browserState = node_crypto.randomBytes(32).toString("base64url");
1441
1421
  try {
1442
1422
  const { url, returnBase } = await this.prepareLogin(providerId, state, {
1443
1423
  ...linkParam !== void 0 ? { linkIntent: linkParam } : {},
1444
1424
  headers: req.headers,
1445
1425
  popup: popupParam === "1",
1446
- browserBinding: node_crypto.createHash("sha256").update(browserState).digest("hex")
1426
+ browserBinding: node_crypto.createHash("sha256").update(browserState).digest("hex"),
1427
+ ...app !== void 0 ? { app } : {}
1447
1428
  });
1448
1429
  reply.code(302);
1449
1430
  reply.header("Set-Cookie", ssoStateCookie(browserState, returnBase.startsWith("https:")));
1450
1431
  reply.header("Location", url);
1451
1432
  reply.send("");
1452
1433
  } catch (err) {
1434
+ if (err instanceof PendingStatesFullError) {
1435
+ this.ctx.logger.warn("OIDC sign-in refused: too many in progress", { meta: { provider: providerId } });
1436
+ if (app !== void 0) {
1437
+ reply.code(302);
1438
+ reply.header("Location", require_dist.appFlowLocation(app.redirect, { error: "too-many-requests" }, app.state));
1439
+ reply.send("");
1440
+ return;
1441
+ }
1442
+ reply.code(429);
1443
+ reply.header("Retry-After", "30");
1444
+ reply.send({ error: "Too many sign-ins in progress - try again shortly" });
1445
+ return;
1446
+ }
1453
1447
  reply.code(500);
1454
1448
  reply.send({
1455
1449
  error: "OIDC start failed",
@@ -1468,6 +1462,27 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1468
1462
  const code = query["code"];
1469
1463
  const state = query["state"];
1470
1464
  const errorParam = query["error"];
1465
+ const pendingApp = state !== void 0 ? this.livePending(state)?.app : void 0;
1466
+ const appFlowState = state?.startsWith("a.") === true;
1467
+ const answerApp = (error) => {
1468
+ this.ctx.logger.warn("OIDC app sign-in refused at callback", { meta: {
1469
+ provider: providerId,
1470
+ error
1471
+ } });
1472
+ if (state !== void 0) this.pendingStates.delete(state);
1473
+ reply.code(302);
1474
+ reply.header("Cache-Control", "no-store");
1475
+ reply.header("Location", pendingApp !== void 0 ? require_dist.appFlowLocation(pendingApp.redirect, { error }, pendingApp.state) : require_dist.appFlowLocation(require_dist.APP_FLOW_DEFAULT_REDIRECT, { error }));
1476
+ reply.send("");
1477
+ };
1478
+ if (appFlowState && pendingApp === void 0) {
1479
+ answerApp(errorParam ? "idp-error" : "state-expired");
1480
+ return;
1481
+ }
1482
+ if (errorParam && pendingApp !== void 0) {
1483
+ answerApp("idp-error");
1484
+ return;
1485
+ }
1471
1486
  if (errorParam) {
1472
1487
  reply.code(400);
1473
1488
  reply.send({
@@ -1485,6 +1500,10 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1485
1500
  try {
1486
1501
  const result = await this.handleCallback(code, state);
1487
1502
  if (result.providerId !== providerId) {
1503
+ if (result.app !== void 0) {
1504
+ answerApp("provider-mismatch");
1505
+ return;
1506
+ }
1488
1507
  reply.code(400);
1489
1508
  reply.send({ error: "OIDC callback does not match the provider the login started at" });
1490
1509
  return;
@@ -1499,14 +1518,25 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1499
1518
  ...result.linkIntent !== void 0 ? { linkIntent: result.linkIntent } : {},
1500
1519
  issuer: result.issuer,
1501
1520
  jti: node_crypto.randomBytes(16).toString("base64url"),
1502
- ...result.browserBinding !== void 0 ? { browserBinding: result.browserBinding } : {}
1521
+ ...result.browserBinding !== void 0 ? { browserBinding: result.browserBinding } : {},
1522
+ ...result.app !== void 0 ? { app: {
1523
+ codeChallenge: result.app.codeChallenge,
1524
+ state: result.app.state,
1525
+ purpose: result.app.purpose,
1526
+ redirect: result.app.redirect
1527
+ } } : {}
1503
1528
  } });
1504
1529
  const finish = new URLSearchParams({ bridge: bridgeToken });
1505
1530
  if (result.popup) finish.set("popup", "1");
1531
+ const finishPath = result.app !== void 0 ? "finish-app" : "finish";
1506
1532
  reply.code(302);
1507
- reply.header("Location", `${result.returnBase}/api/auth/sso/finish?${finish.toString()}`);
1533
+ reply.header("Location", `${result.returnBase}/api/auth/sso/${finishPath}?${finish.toString()}`);
1508
1534
  reply.send("");
1509
1535
  } catch (err) {
1536
+ if (pendingApp !== void 0) {
1537
+ answerApp(err instanceof StepUpNotFreshError ? "step-up-not-fresh" : "idp-failed");
1538
+ return;
1539
+ }
1510
1540
  reply.code(500);
1511
1541
  reply.send({
1512
1542
  error: "OIDC callback failed",
@@ -1515,6 +1545,10 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1515
1545
  }
1516
1546
  }
1517
1547
  };
1548
+ /** Minimal standalone page for a refused app sign-in request (D858) - no assets, no script. */
1549
+ function appRefusalPage(message) {
1550
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sign-in request refused</title></head><body style="font-family:system-ui,sans-serif;max-width:32rem;margin:3rem auto;padding:0 1rem"><h1>Sign-in request refused</h1><p>${message.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)}</p><p>Close this window and try again from the app.</p></body></html>`;
1551
+ }
1518
1552
  /**
1519
1553
  * The browser-binding cookie (`SSO_STATE_COOKIE`). HttpOnly, Lax (the IdP
1520
1554
  * returns with a top-level GET), path `/` so it reaches the hub's finish under