@camstack/addon-auth 1.2.144 → 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-0YTEFDYk.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
@@ -938,6 +938,28 @@ var STATE_TTL_MS = 5 * 6e4;
938
938
  var LINK_INTENT_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
939
939
  /** Bound on in-flight logins: `/start` is public, so a flood must not grow the map. */
940
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
+ }
941
963
  /**
942
964
  * Origin + mount prefix of a pinned `redirect_uri`: the part before the
943
965
  * callback path when it ends with it, else just its origin.
@@ -1108,7 +1130,11 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1108
1130
  icon: require_dist.resolveIconKey(inst.config.icon, DEFAULT_PROVIDER.icon),
1109
1131
  startUrl: `/addon/auth-oidc/${id}/start`,
1110
1132
  stage: "primary",
1111
- linkable: true
1133
+ linkable: true,
1134
+ app: {
1135
+ kind: "browser",
1136
+ startPath: `/addon/auth-oidc/${id}/start`
1137
+ }
1112
1138
  });
1113
1139
  }
1114
1140
  return out;
@@ -1244,12 +1270,10 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1244
1270
  const nonce = node_crypto.randomBytes(16).toString("base64url");
1245
1271
  const { redirectUri, returnBase } = await this.resolveRedirect(providerId, options.headers ?? null);
1246
1272
  this.pruneExpiredStates();
1247
- while (this.pendingStates.size >= MAX_PENDING_STATES) {
1248
- const oldest = this.pendingStates.keys().next();
1249
- if (oldest.done) break;
1250
- this.pendingStates.delete(oldest.value);
1251
- }
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");
1252
1275
  this.pendingStates.set(state, {
1276
+ clientKey,
1253
1277
  codeVerifier,
1254
1278
  nonce,
1255
1279
  providerId,
@@ -1258,7 +1282,8 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1258
1282
  popup: options.popup === true,
1259
1283
  createdAt: Date.now(),
1260
1284
  ...linkIntent !== void 0 ? { linkIntent } : {},
1261
- ...options.browserBinding !== void 0 ? { browserBinding: options.browserBinding } : {}
1285
+ ...options.browserBinding !== void 0 ? { browserBinding: options.browserBinding } : {},
1286
+ ...options.app !== void 0 ? { app: options.app } : {}
1262
1287
  });
1263
1288
  const params = new URLSearchParams({
1264
1289
  response_type: "code",
@@ -1270,17 +1295,27 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1270
1295
  code_challenge: codeChallenge,
1271
1296
  code_challenge_method: "S256"
1272
1297
  });
1298
+ if (options.app?.purpose === "step-up") {
1299
+ params.set("prompt", "login");
1300
+ params.set("max_age", "0");
1301
+ }
1273
1302
  return {
1274
1303
  url: `${doc.authorization_endpoint}?${params.toString()}`,
1275
1304
  returnBase
1276
1305
  };
1277
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
+ }
1278
1313
  pruneExpiredStates() {
1279
1314
  const now = Date.now();
1280
1315
  for (const [k, v] of this.pendingStates.entries()) if (now - v.createdAt > STATE_TTL_MS) this.pendingStates.delete(k);
1281
1316
  }
1282
1317
  async handleCallback(code, state) {
1283
- const pending = this.pendingStates.get(state);
1318
+ const pending = this.livePending(state);
1284
1319
  if (!pending) throw new Error("Unknown or expired OIDC state — login session timed out, please retry");
1285
1320
  this.pendingStates.delete(state);
1286
1321
  const providerId = pending.providerId;
@@ -1321,6 +1356,10 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1321
1356
  throw new Error(`OIDC id_token verification failed: ${require_dist.errMsg(err)}`, { cause: err });
1322
1357
  }
1323
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
+ }
1324
1363
  const sub = typeof payload.sub === "string" ? payload.sub : null;
1325
1364
  if (!sub) throw new Error("OIDC callback: id_token missing required `sub` claim");
1326
1365
  const emailClaim = typeof payload["email"] === "string" ? payload["email"] : void 0;
@@ -1339,7 +1378,8 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1339
1378
  providerId,
1340
1379
  returnBase: pending.returnBase,
1341
1380
  popup: pending.popup,
1342
- ...pending.browserBinding !== void 0 ? { browserBinding: pending.browserBinding } : {}
1381
+ ...pending.browserBinding !== void 0 ? { browserBinding: pending.browserBinding } : {},
1382
+ ...pending.app !== void 0 ? { app: pending.app } : {}
1343
1383
  };
1344
1384
  }
1345
1385
  validateLocalSessionToken(_token) {
@@ -1358,21 +1398,52 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1358
1398
  reply.send({ error: "Malformed link request" });
1359
1399
  return;
1360
1400
  }
1361
- const popupParam = req.query["popup"];
1362
- 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")}`;
1363
1420
  const browserState = node_crypto.randomBytes(32).toString("base64url");
1364
1421
  try {
1365
1422
  const { url, returnBase } = await this.prepareLogin(providerId, state, {
1366
1423
  ...linkParam !== void 0 ? { linkIntent: linkParam } : {},
1367
1424
  headers: req.headers,
1368
1425
  popup: popupParam === "1",
1369
- browserBinding: node_crypto.createHash("sha256").update(browserState).digest("hex")
1426
+ browserBinding: node_crypto.createHash("sha256").update(browserState).digest("hex"),
1427
+ ...app !== void 0 ? { app } : {}
1370
1428
  });
1371
1429
  reply.code(302);
1372
1430
  reply.header("Set-Cookie", ssoStateCookie(browserState, returnBase.startsWith("https:")));
1373
1431
  reply.header("Location", url);
1374
1432
  reply.send("");
1375
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
+ }
1376
1447
  reply.code(500);
1377
1448
  reply.send({
1378
1449
  error: "OIDC start failed",
@@ -1391,6 +1462,27 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1391
1462
  const code = query["code"];
1392
1463
  const state = query["state"];
1393
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
+ }
1394
1486
  if (errorParam) {
1395
1487
  reply.code(400);
1396
1488
  reply.send({
@@ -1408,6 +1500,10 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1408
1500
  try {
1409
1501
  const result = await this.handleCallback(code, state);
1410
1502
  if (result.providerId !== providerId) {
1503
+ if (result.app !== void 0) {
1504
+ answerApp("provider-mismatch");
1505
+ return;
1506
+ }
1411
1507
  reply.code(400);
1412
1508
  reply.send({ error: "OIDC callback does not match the provider the login started at" });
1413
1509
  return;
@@ -1422,14 +1518,25 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1422
1518
  ...result.linkIntent !== void 0 ? { linkIntent: result.linkIntent } : {},
1423
1519
  issuer: result.issuer,
1424
1520
  jti: node_crypto.randomBytes(16).toString("base64url"),
1425
- ...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
+ } } : {}
1426
1528
  } });
1427
1529
  const finish = new URLSearchParams({ bridge: bridgeToken });
1428
1530
  if (result.popup) finish.set("popup", "1");
1531
+ const finishPath = result.app !== void 0 ? "finish-app" : "finish";
1429
1532
  reply.code(302);
1430
- reply.header("Location", `${result.returnBase}/api/auth/sso/finish?${finish.toString()}`);
1533
+ reply.header("Location", `${result.returnBase}/api/auth/sso/${finishPath}?${finish.toString()}`);
1431
1534
  reply.send("");
1432
1535
  } catch (err) {
1536
+ if (pendingApp !== void 0) {
1537
+ answerApp(err instanceof StepUpNotFreshError ? "step-up-not-fresh" : "idp-failed");
1538
+ return;
1539
+ }
1433
1540
  reply.code(500);
1434
1541
  reply.send({
1435
1542
  error: "OIDC callback failed",
@@ -1438,6 +1545,10 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1438
1545
  }
1439
1546
  }
1440
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
+ }
1441
1552
  /**
1442
1553
  * The browser-binding cookie (`SSO_STATE_COOKIE`). HttpOnly, Lax (the IdP
1443
1554
  * returns with a top-level GET), path `/` so it reaches the hub's finish under
@@ -1,4 +1,4 @@
1
- import { S as toSecretFieldView, _ as SECRET_CLEAR, a as authProviderCapability, b as isSecretClear, c as loginMethodCapability, d as pickRequestOrigin, f as requestMountPrefix, g as BaseAddon, h as errMsg, i as advertisedHubOrigins, l as normalizeOrigin, n as addonRoutesCapability, o as buildAddonRouteProvider, p as resolveIconKey, s as claimedRequestOrigins, t as SSO_STATE_COOKIE, u as oidcConnectionsCapability, v as arrayItemFieldPath, x as isSecretMarker, y as globalSecretScope } from "../dist-ChwITXNa.mjs";
1
+ import { C as globalSecretScope, E as toSecretFieldView, S as arrayItemFieldPath, T as isSecretMarker, _ as resolveIconKey, b as BaseAddon, c as authProviderCapability, d as loginMethodCapability, f as normalizeOrigin, g as requestMountPrefix, h as pickRequestOrigin, i as addonRoutesCapability, l as buildAddonRouteProvider, m as parseAppFlowRequest, n as APP_FLOW_REFUSAL_MESSAGES, o as advertisedHubOrigins, p as oidcConnectionsCapability, r as SSO_STATE_COOKIE, s as appFlowLocation, t as APP_FLOW_DEFAULT_REDIRECT, u as claimedRequestOrigins, w as isSecretClear, x as SECRET_CLEAR, y as errMsg } from "../dist-B2h4Ziv7.mjs";
2
2
  import * as crypto$1 from "node:crypto";
3
3
  //#region ../../node_modules/jose/dist/webapi/lib/buffer_utils.js
4
4
  var encoder = new TextEncoder(), decoder = new TextDecoder(), strictDecoder = new TextDecoder("utf-8", { fatal: !0 });
@@ -933,6 +933,28 @@ var STATE_TTL_MS = 5 * 6e4;
933
933
  var LINK_INTENT_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
934
934
  /** Bound on in-flight logins: `/start` is public, so a flood must not grow the map. */
935
935
  var MAX_PENDING_STATES = 1e3;
936
+ /** In-flight logins one client may hold (D858): one browser flooding `/start` cannot fill the map for everyone. */
937
+ var MAX_PENDING_PER_CLIENT = 20;
938
+ /**
939
+ * A step-up must follow a login that happened no earlier than this before the
940
+ * flow began (D858). The authorize request asks for `prompt=login&max_age=0`;
941
+ * this is the check that the IdP actually honoured it.
942
+ */
943
+ var STEP_UP_MAX_AUTH_AGE_MS = 6e4;
944
+ /** The pending map is full, or this client already holds its share. Live states are never evicted for it. */
945
+ var PendingStatesFullError = class extends Error {};
946
+ /** A step-up whose id_token proves no login just before the flow began. */
947
+ var StepUpNotFreshError = class extends Error {};
948
+ /**
949
+ * Who a `/start` is counted against. The hub already limits each real client
950
+ * address per minute before a route runs; the addon sees only headers, so this
951
+ * is a best-effort key (a spoofed header only rotates the bucket, and the
952
+ * hub's limit and the global cap still bound the total).
953
+ */
954
+ function clientKeyOf(headers) {
955
+ const forwarded = headers?.["x-forwarded-for"]?.split(",")[0]?.trim();
956
+ return headers?.["cf-connecting-ip"] ?? headers?.["x-real-ip"] ?? forwarded ?? "unknown";
957
+ }
936
958
  /**
937
959
  * Origin + mount prefix of a pinned `redirect_uri`: the part before the
938
960
  * callback path when it ends with it, else just its origin.
@@ -1103,7 +1125,11 @@ var AuthOidcAddon = class extends BaseAddon {
1103
1125
  icon: resolveIconKey(inst.config.icon, DEFAULT_PROVIDER.icon),
1104
1126
  startUrl: `/addon/auth-oidc/${id}/start`,
1105
1127
  stage: "primary",
1106
- linkable: true
1128
+ linkable: true,
1129
+ app: {
1130
+ kind: "browser",
1131
+ startPath: `/addon/auth-oidc/${id}/start`
1132
+ }
1107
1133
  });
1108
1134
  }
1109
1135
  return out;
@@ -1239,12 +1265,10 @@ var AuthOidcAddon = class extends BaseAddon {
1239
1265
  const nonce = crypto$1.randomBytes(16).toString("base64url");
1240
1266
  const { redirectUri, returnBase } = await this.resolveRedirect(providerId, options.headers ?? null);
1241
1267
  this.pruneExpiredStates();
1242
- while (this.pendingStates.size >= MAX_PENDING_STATES) {
1243
- const oldest = this.pendingStates.keys().next();
1244
- if (oldest.done) break;
1245
- this.pendingStates.delete(oldest.value);
1246
- }
1268
+ const clientKey = clientKeyOf(options.headers);
1269
+ 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");
1247
1270
  this.pendingStates.set(state, {
1271
+ clientKey,
1248
1272
  codeVerifier,
1249
1273
  nonce,
1250
1274
  providerId,
@@ -1253,7 +1277,8 @@ var AuthOidcAddon = class extends BaseAddon {
1253
1277
  popup: options.popup === true,
1254
1278
  createdAt: Date.now(),
1255
1279
  ...linkIntent !== void 0 ? { linkIntent } : {},
1256
- ...options.browserBinding !== void 0 ? { browserBinding: options.browserBinding } : {}
1280
+ ...options.browserBinding !== void 0 ? { browserBinding: options.browserBinding } : {},
1281
+ ...options.app !== void 0 ? { app: options.app } : {}
1257
1282
  });
1258
1283
  const params = new URLSearchParams({
1259
1284
  response_type: "code",
@@ -1265,17 +1290,27 @@ var AuthOidcAddon = class extends BaseAddon {
1265
1290
  code_challenge: codeChallenge,
1266
1291
  code_challenge_method: "S256"
1267
1292
  });
1293
+ if (options.app?.purpose === "step-up") {
1294
+ params.set("prompt", "login");
1295
+ params.set("max_age", "0");
1296
+ }
1268
1297
  return {
1269
1298
  url: `${doc.authorization_endpoint}?${params.toString()}`,
1270
1299
  returnBase
1271
1300
  };
1272
1301
  }
1302
+ /** The pending state for `state`, unless it is gone or older than its TTL. */
1303
+ livePending(state) {
1304
+ const pending = this.pendingStates.get(state);
1305
+ if (pending === void 0) return void 0;
1306
+ return Date.now() - pending.createdAt > STATE_TTL_MS ? void 0 : pending;
1307
+ }
1273
1308
  pruneExpiredStates() {
1274
1309
  const now = Date.now();
1275
1310
  for (const [k, v] of this.pendingStates.entries()) if (now - v.createdAt > STATE_TTL_MS) this.pendingStates.delete(k);
1276
1311
  }
1277
1312
  async handleCallback(code, state) {
1278
- const pending = this.pendingStates.get(state);
1313
+ const pending = this.livePending(state);
1279
1314
  if (!pending) throw new Error("Unknown or expired OIDC state — login session timed out, please retry");
1280
1315
  this.pendingStates.delete(state);
1281
1316
  const providerId = pending.providerId;
@@ -1316,6 +1351,10 @@ var AuthOidcAddon = class extends BaseAddon {
1316
1351
  throw new Error(`OIDC id_token verification failed: ${errMsg(err)}`, { cause: err });
1317
1352
  }
1318
1353
  if (typeof payload["nonce"] !== "string" || payload["nonce"] !== pending.nonce) throw new Error("OIDC id_token nonce mismatch — possible replay attack");
1354
+ if (pending.app?.purpose === "step-up") {
1355
+ const authTime = payload["auth_time"];
1356
+ 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");
1357
+ }
1319
1358
  const sub = typeof payload.sub === "string" ? payload.sub : null;
1320
1359
  if (!sub) throw new Error("OIDC callback: id_token missing required `sub` claim");
1321
1360
  const emailClaim = typeof payload["email"] === "string" ? payload["email"] : void 0;
@@ -1334,7 +1373,8 @@ var AuthOidcAddon = class extends BaseAddon {
1334
1373
  providerId,
1335
1374
  returnBase: pending.returnBase,
1336
1375
  popup: pending.popup,
1337
- ...pending.browserBinding !== void 0 ? { browserBinding: pending.browserBinding } : {}
1376
+ ...pending.browserBinding !== void 0 ? { browserBinding: pending.browserBinding } : {},
1377
+ ...pending.app !== void 0 ? { app: pending.app } : {}
1338
1378
  };
1339
1379
  }
1340
1380
  validateLocalSessionToken(_token) {
@@ -1353,21 +1393,52 @@ var AuthOidcAddon = class extends BaseAddon {
1353
1393
  reply.send({ error: "Malformed link request" });
1354
1394
  return;
1355
1395
  }
1356
- const popupParam = req.query["popup"];
1357
- const state = crypto$1.randomBytes(16).toString("base64url");
1396
+ const query = req.query;
1397
+ const popupParam = query["popup"];
1398
+ let app;
1399
+ if (query["app"] === "1") {
1400
+ const parsed = parseAppFlowRequest(query);
1401
+ if (linkParam !== void 0 || popupParam === "1" || !parsed.ok) {
1402
+ this.ctx.logger.warn("OIDC app sign-in refused at start", { meta: {
1403
+ provider: providerId,
1404
+ reason: parsed.ok ? "combined-flow" : parsed.reason
1405
+ } });
1406
+ reply.code(400);
1407
+ reply.type("text/html; charset=utf-8");
1408
+ reply.header("Cache-Control", "no-store");
1409
+ reply.send(appRefusalPage(parsed.ok ? "An app sign-in cannot be combined with linking or a popup." : APP_FLOW_REFUSAL_MESSAGES[parsed.reason]));
1410
+ return;
1411
+ }
1412
+ app = parsed.request;
1413
+ }
1414
+ const state = `${app !== void 0 ? "a." : ""}${crypto$1.randomBytes(16).toString("base64url")}`;
1358
1415
  const browserState = crypto$1.randomBytes(32).toString("base64url");
1359
1416
  try {
1360
1417
  const { url, returnBase } = await this.prepareLogin(providerId, state, {
1361
1418
  ...linkParam !== void 0 ? { linkIntent: linkParam } : {},
1362
1419
  headers: req.headers,
1363
1420
  popup: popupParam === "1",
1364
- browserBinding: crypto$1.createHash("sha256").update(browserState).digest("hex")
1421
+ browserBinding: crypto$1.createHash("sha256").update(browserState).digest("hex"),
1422
+ ...app !== void 0 ? { app } : {}
1365
1423
  });
1366
1424
  reply.code(302);
1367
1425
  reply.header("Set-Cookie", ssoStateCookie(browserState, returnBase.startsWith("https:")));
1368
1426
  reply.header("Location", url);
1369
1427
  reply.send("");
1370
1428
  } catch (err) {
1429
+ if (err instanceof PendingStatesFullError) {
1430
+ this.ctx.logger.warn("OIDC sign-in refused: too many in progress", { meta: { provider: providerId } });
1431
+ if (app !== void 0) {
1432
+ reply.code(302);
1433
+ reply.header("Location", appFlowLocation(app.redirect, { error: "too-many-requests" }, app.state));
1434
+ reply.send("");
1435
+ return;
1436
+ }
1437
+ reply.code(429);
1438
+ reply.header("Retry-After", "30");
1439
+ reply.send({ error: "Too many sign-ins in progress - try again shortly" });
1440
+ return;
1441
+ }
1371
1442
  reply.code(500);
1372
1443
  reply.send({
1373
1444
  error: "OIDC start failed",
@@ -1386,6 +1457,27 @@ var AuthOidcAddon = class extends BaseAddon {
1386
1457
  const code = query["code"];
1387
1458
  const state = query["state"];
1388
1459
  const errorParam = query["error"];
1460
+ const pendingApp = state !== void 0 ? this.livePending(state)?.app : void 0;
1461
+ const appFlowState = state?.startsWith("a.") === true;
1462
+ const answerApp = (error) => {
1463
+ this.ctx.logger.warn("OIDC app sign-in refused at callback", { meta: {
1464
+ provider: providerId,
1465
+ error
1466
+ } });
1467
+ if (state !== void 0) this.pendingStates.delete(state);
1468
+ reply.code(302);
1469
+ reply.header("Cache-Control", "no-store");
1470
+ reply.header("Location", pendingApp !== void 0 ? appFlowLocation(pendingApp.redirect, { error }, pendingApp.state) : appFlowLocation(APP_FLOW_DEFAULT_REDIRECT, { error }));
1471
+ reply.send("");
1472
+ };
1473
+ if (appFlowState && pendingApp === void 0) {
1474
+ answerApp(errorParam ? "idp-error" : "state-expired");
1475
+ return;
1476
+ }
1477
+ if (errorParam && pendingApp !== void 0) {
1478
+ answerApp("idp-error");
1479
+ return;
1480
+ }
1389
1481
  if (errorParam) {
1390
1482
  reply.code(400);
1391
1483
  reply.send({
@@ -1403,6 +1495,10 @@ var AuthOidcAddon = class extends BaseAddon {
1403
1495
  try {
1404
1496
  const result = await this.handleCallback(code, state);
1405
1497
  if (result.providerId !== providerId) {
1498
+ if (result.app !== void 0) {
1499
+ answerApp("provider-mismatch");
1500
+ return;
1501
+ }
1406
1502
  reply.code(400);
1407
1503
  reply.send({ error: "OIDC callback does not match the provider the login started at" });
1408
1504
  return;
@@ -1417,14 +1513,25 @@ var AuthOidcAddon = class extends BaseAddon {
1417
1513
  ...result.linkIntent !== void 0 ? { linkIntent: result.linkIntent } : {},
1418
1514
  issuer: result.issuer,
1419
1515
  jti: crypto$1.randomBytes(16).toString("base64url"),
1420
- ...result.browserBinding !== void 0 ? { browserBinding: result.browserBinding } : {}
1516
+ ...result.browserBinding !== void 0 ? { browserBinding: result.browserBinding } : {},
1517
+ ...result.app !== void 0 ? { app: {
1518
+ codeChallenge: result.app.codeChallenge,
1519
+ state: result.app.state,
1520
+ purpose: result.app.purpose,
1521
+ redirect: result.app.redirect
1522
+ } } : {}
1421
1523
  } });
1422
1524
  const finish = new URLSearchParams({ bridge: bridgeToken });
1423
1525
  if (result.popup) finish.set("popup", "1");
1526
+ const finishPath = result.app !== void 0 ? "finish-app" : "finish";
1424
1527
  reply.code(302);
1425
- reply.header("Location", `${result.returnBase}/api/auth/sso/finish?${finish.toString()}`);
1528
+ reply.header("Location", `${result.returnBase}/api/auth/sso/${finishPath}?${finish.toString()}`);
1426
1529
  reply.send("");
1427
1530
  } catch (err) {
1531
+ if (pendingApp !== void 0) {
1532
+ answerApp(err instanceof StepUpNotFreshError ? "step-up-not-fresh" : "idp-failed");
1533
+ return;
1534
+ }
1428
1535
  reply.code(500);
1429
1536
  reply.send({
1430
1537
  error: "OIDC callback failed",
@@ -1433,6 +1540,10 @@ var AuthOidcAddon = class extends BaseAddon {
1433
1540
  }
1434
1541
  }
1435
1542
  };
1543
+ /** Minimal standalone page for a refused app sign-in request (D858) - no assets, no script. */
1544
+ function appRefusalPage(message) {
1545
+ 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>`;
1546
+ }
1436
1547
  /**
1437
1548
  * The browser-binding cookie (`SSO_STATE_COOKIE`). HttpOnly, Lax (the IdP
1438
1549
  * returns with a top-level GET), path `/` so it reaches the hub's finish under
@@ -1,6 +1,6 @@
1
1
  import { n as e, r as t, t as n } from "./_virtual_mf___mfe_internal__addon_auth_webauthn_widgets__loadShare__react__loadShare__.js-BIIa6vDX.mjs";
2
2
  import { n as r, t as i } from "./_virtual_mf___mfe_internal__addon_auth_webauthn_widgets__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.js-CQ-aEQ9b.mjs";
3
- import { t as a } from "./_virtual_mf___mfe_internal__addon_auth_webauthn_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-xhY74yma.mjs";
3
+ import { t as a } from "./_virtual_mf___mfe_internal__addon_auth_webauthn_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DSXWgur5.mjs";
4
4
  import { n as o, r as s, t as c } from "./_virtual_mf___mfe_internal__addon_auth_webauthn_widgets__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-BL2etuqg.mjs";
5
5
  //#region ../../node_modules/lucide-react/dist/esm/shared/src/utils/mergeClasses.js
6
6
  var l = (...e) => e.filter((e, t, n) => !!e && e.trim() !== "" && n.indexOf(e) === t).join(" ").trim(), u = (e) => e.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(), d = (e) => e.replace(/^([A-Z])|[\s-_]+(\w)/g, (e, t, n) => n ? n.toUpperCase() : t.toLowerCase()), f = (e) => {
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.365",
21
+ version: "1.2.370",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_auth_webauthn_widgets",
@@ -33,7 +33,7 @@ var e = {
33
33
  },
34
34
  "@camstack/ui-library": {
35
35
  name: "@camstack/ui-library",
36
- version: "1.2.288",
36
+ version: "1.2.289",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_auth_webauthn_widgets",