@camstack/addon-auth 1.2.131 → 1.2.132

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.
@@ -0,0 +1,16 @@
1
+ //#region src/hub-browser-origin.ts
2
+ async function hubBrowserOrigin(localNetwork, logger) {
3
+ try {
4
+ return (await localNetwork.getAddresses.query()).roles.publicEndpoint.resolved;
5
+ } catch (err) {
6
+ logger.warn("hub address unavailable from local-network — using the localhost default", { meta: { error: err instanceof Error ? err.message : String(err) } });
7
+ return null;
8
+ }
9
+ }
10
+ //#endregion
11
+ Object.defineProperty(exports, "hubBrowserOrigin", {
12
+ enumerable: true,
13
+ get: function() {
14
+ return hubBrowserOrigin;
15
+ }
16
+ });
@@ -0,0 +1,11 @@
1
+ //#region src/hub-browser-origin.ts
2
+ async function hubBrowserOrigin(localNetwork, logger) {
3
+ try {
4
+ return (await localNetwork.getAddresses.query()).roles.publicEndpoint.resolved;
5
+ } catch (err) {
6
+ logger.warn("hub address unavailable from local-network — using the localhost default", { meta: { error: err instanceof Error ? err.message : String(err) } });
7
+ return null;
8
+ }
9
+ }
10
+ //#endregion
11
+ export { hubBrowserOrigin as t };
@@ -3,7 +3,8 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../chunk-Cek0wNdY.js");
6
- const require_hub_browser_origin = require("../hub-browser-origin-CCWSbHYb.js");
6
+ const require_dist = require("../dist-DjCeMP9o.js");
7
+ const require_hub_browser_origin = require("../hub-browser-origin-DohQUFPz.js");
7
8
  //#region src/magic-link/retire-enabled-flag.ts
8
9
  /** The retired settings key. */
9
10
  var RETIRED_ENABLED_KEY = "enabled";
@@ -83,7 +84,7 @@ var DEFAULT_CONFIG = {
83
84
  defaultRole: "viewer",
84
85
  ttlSec: 600
85
86
  };
86
- var AuthMagicLinkAddon = class extends require_hub_browser_origin.BaseAddon {
87
+ var AuthMagicLinkAddon = class extends require_dist.BaseAddon {
87
88
  enabledFlagRetired = false;
88
89
  retirementTimer = null;
89
90
  shuttingDown = false;
@@ -101,7 +102,7 @@ var AuthMagicLinkAddon = class extends require_hub_browser_origin.BaseAddon {
101
102
  throw new Error("Magic link does not use callback — the email link redirects to /api/auth/sso/finish directly");
102
103
  }
103
104
  };
104
- const routeProvider = require_hub_browser_origin.buildAddonRouteProvider("auth-magic-link", [
105
+ const routeProvider = require_dist.buildAddonRouteProvider("auth-magic-link", [
105
106
  {
106
107
  method: "GET",
107
108
  path: "/start",
@@ -130,15 +131,15 @@ var AuthMagicLinkAddon = class extends require_hub_browser_origin.BaseAddon {
130
131
  this.attemptEnabledFlagRetirement(0);
131
132
  return [
132
133
  {
133
- capability: require_hub_browser_origin.authProviderCapability,
134
+ capability: require_dist.authProviderCapability,
134
135
  provider: authProvider
135
136
  },
136
137
  {
137
- capability: require_hub_browser_origin.addonRoutesCapability,
138
+ capability: require_dist.addonRoutesCapability,
138
139
  provider: routeProvider
139
140
  },
140
141
  {
141
- capability: require_hub_browser_origin.loginMethodCapability,
142
+ capability: require_dist.loginMethodCapability,
142
143
  provider: loginMethodProvider
143
144
  }
144
145
  ];
@@ -149,7 +150,7 @@ var AuthMagicLinkAddon = class extends require_hub_browser_origin.BaseAddon {
149
150
  kind: "redirect",
150
151
  id: "auth-magic-link",
151
152
  label: this.config.displayName || DEFAULT_CONFIG.displayName,
152
- icon: require_hub_browser_origin.isDisplayIconKey(this.config.icon) ? this.config.icon : DEFAULT_CONFIG.icon,
153
+ icon: require_dist.isDisplayIconKey(this.config.icon) ? this.config.icon : DEFAULT_CONFIG.icon,
153
154
  startUrl: "/addon/auth-magic-link/start",
154
155
  stage: "primary"
155
156
  }];
@@ -184,7 +185,7 @@ var AuthMagicLinkAddon = class extends require_hub_browser_origin.BaseAddon {
184
185
  } catch (err) {
185
186
  outcome = {
186
187
  kind: "deferred",
187
- reason: require_hub_browser_origin.errMsg(err)
188
+ reason: require_dist.errMsg(err)
188
189
  };
189
190
  }
190
191
  if (outcome.kind !== "deferred") {
@@ -282,7 +283,7 @@ var AuthMagicLinkAddon = class extends require_hub_browser_origin.BaseAddon {
282
283
  try {
283
284
  users = await this.ctx.api.userManagement.listUsers.query();
284
285
  } catch (err) {
285
- this.ctx.logger.warn("Magic-link user lookup failed (user-management cap unavailable)", { meta: { message: require_hub_browser_origin.errMsg(err) } });
286
+ this.ctx.logger.warn("Magic-link user lookup failed (user-management cap unavailable)", { meta: { message: require_dist.errMsg(err) } });
286
287
  return null;
287
288
  }
288
289
  const needle = email.trim().toLowerCase();
@@ -326,7 +327,7 @@ var AuthMagicLinkAddon = class extends require_hub_browser_origin.BaseAddon {
326
327
  <input id="email" name="email" type="email" autocomplete="email" required autofocus placeholder="you@example.com">
327
328
  <button id="b" type="submit">Send login link</button>
328
329
  <div class="msg" id="m" hidden></div>
329
- <p style="margin-top:1rem"><a href="/admin/login">Back to sign in</a></p>
330
+ <p style="margin-top:1rem"><a href="/login">Back to sign in</a></p>
330
331
  </form>
331
332
  <script>
332
333
  var f=document.getElementById('f'),b=document.getElementById('b'),m=document.getElementById('m');
@@ -371,7 +372,7 @@ var AuthMagicLinkAddon = class extends require_hub_browser_origin.BaseAddon {
371
372
  ttlSec: this.config.ttlSec || DEFAULT_CONFIG.ttlSec
372
373
  }));
373
374
  } catch (err) {
374
- this.ctx.logger.error("Magic-link bridge-token minting failed (sso-bridge unavailable)", { meta: { message: require_hub_browser_origin.errMsg(err) } });
375
+ this.ctx.logger.error("Magic-link bridge-token minting failed (sso-bridge unavailable)", { meta: { message: require_dist.errMsg(err) } });
375
376
  reply.code(503);
376
377
  reply.send({ error: "sso-bridge capability unavailable" });
377
378
  return;
@@ -392,7 +393,7 @@ var AuthMagicLinkAddon = class extends require_hub_browser_origin.BaseAddon {
392
393
  reply.code(500);
393
394
  reply.send({
394
395
  error: "Magic-link request failed",
395
- message: require_hub_browser_origin.errMsg(err)
396
+ message: require_dist.errMsg(err)
396
397
  });
397
398
  }
398
399
  }
@@ -1,4 +1,5 @@
1
- import { a as buildAddonRouteProvider, i as authProviderCapability, l as errMsg, n as addonRoutesCapability, o as isDisplayIconKey, s as loginMethodCapability, t as hubBrowserOrigin, u as BaseAddon } from "../hub-browser-origin-BRU-HVqq.mjs";
1
+ import { a as isDisplayIconKey, c as errMsg, i as buildAddonRouteProvider, l as BaseAddon, o as loginMethodCapability, r as authProviderCapability, t as addonRoutesCapability } from "../dist-DbaYwR-N.mjs";
2
+ import { t as hubBrowserOrigin } from "../hub-browser-origin-LBBO5r05.mjs";
2
3
  //#region src/magic-link/retire-enabled-flag.ts
3
4
  /** The retired settings key. */
4
5
  var RETIRED_ENABLED_KEY = "enabled";
@@ -321,7 +322,7 @@ var AuthMagicLinkAddon = class extends BaseAddon {
321
322
  <input id="email" name="email" type="email" autocomplete="email" required autofocus placeholder="you@example.com">
322
323
  <button id="b" type="submit">Send login link</button>
323
324
  <div class="msg" id="m" hidden></div>
324
- <p style="margin-top:1rem"><a href="/admin/login">Back to sign in</a></p>
325
+ <p style="margin-top:1rem"><a href="/login">Back to sign in</a></p>
325
326
  </form>
326
327
  <script>
327
328
  var f=document.getElementById('f'),b=document.getElementById('b'),m=document.getElementById('m');
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-Cek0wNdY.js");
6
- const require_hub_browser_origin = require("../hub-browser-origin-CCWSbHYb.js");
6
+ const require_dist = require("../dist-DjCeMP9o.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  node_crypto = require_chunk.__toESM(node_crypto);
9
9
  //#region ../../node_modules/jose/dist/webapi/lib/buffer_utils.js
@@ -705,6 +705,82 @@ function createRemoteJWKSet(url, options) {
705
705
  });
706
706
  }
707
707
  //#endregion
708
+ //#region src/oidc/request-origin.ts
709
+ /** host[:port] — a DNS name, IPv4 or bracketed IPv6. No userinfo, path or spaces. */
710
+ var AUTHORITY_RE = /^(?:[a-z0-9.-]+|\[[0-9a-f:.]+\])(?::\d{1,5})?$/i;
711
+ /** Same grammar as the hub's `parseForwardedPrefix` (spa-mount.ts). */
712
+ var PREFIX_RE = /^\/[A-Za-z0-9_\-.]+(?:\/[A-Za-z0-9_\-.]+)*$/;
713
+ var MAX_PREFIX_LENGTH = 512;
714
+ /** First value of a possibly comma-joined forwarded header (the client-most proxy). */
715
+ function firstValue(value) {
716
+ if (value === void 0) return null;
717
+ const first = value.split(",")[0]?.trim() ?? "";
718
+ return first.length === 0 ? null : first;
719
+ }
720
+ /** `scheme://host[:port]` normalised by WHATWG URL, or `null` if it is not an http(s) origin. */
721
+ function normalizeOrigin(value) {
722
+ try {
723
+ const url = new URL(value);
724
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
725
+ if (url.username !== "" || url.password !== "") return null;
726
+ return url.origin;
727
+ } catch {
728
+ return null;
729
+ }
730
+ }
731
+ /** The mount prefix the relay named (no trailing slash), or `''`. Invalid = absent. */
732
+ function requestMountPrefix(headers) {
733
+ const raw = headers["x-forwarded-prefix"];
734
+ if (raw === void 0 || raw.length === 0 || raw.length > MAX_PREFIX_LENGTH) return "";
735
+ const trimmed = raw.endsWith("/") ? raw.slice(0, -1) : raw;
736
+ if (trimmed === "" || !PREFIX_RE.test(trimmed)) return "";
737
+ if (trimmed.split("/").some((segment) => segment === ".." || segment === ".")) return "";
738
+ return trimmed;
739
+ }
740
+ /**
741
+ * The origins the request CLAIMS, most specific first: the proxy's
742
+ * `X-Forwarded-Host` over `Host`; the named `X-Forwarded-Proto`, or https
743
+ * then http when none is named (the hub terminates TLS itself on 4443).
744
+ */
745
+ function claimedRequestOrigins(headers) {
746
+ const authority = firstValue(headers["x-forwarded-host"]) ?? firstValue(headers["host"]);
747
+ if (authority === null || !AUTHORITY_RE.test(authority)) return [];
748
+ const proto = firstValue(headers["x-forwarded-proto"])?.toLowerCase() ?? null;
749
+ const schemes = proto === "https" || proto === "http" ? [proto] : proto === null ? ["https", "http"] : [];
750
+ const out = [];
751
+ for (const scheme of schemes) {
752
+ const origin = normalizeOrigin(`${scheme}://${authority}`);
753
+ if (origin !== null) out.push(origin);
754
+ }
755
+ return out;
756
+ }
757
+ /** Every origin the hub advertises itself at, normalised. Never the app link, never loopback. */
758
+ function advertisedHubOrigins(book) {
759
+ const candidates = [];
760
+ for (const row of book.rows) {
761
+ if (row.kind === "app-link" || row.kind === "loopback") continue;
762
+ candidates.push(row.baseUrl);
763
+ }
764
+ const { publicEndpoint, internal, appConnections } = book.roles;
765
+ if (publicEndpoint.resolved !== null) candidates.push(publicEndpoint.resolved);
766
+ if (internal.resolved !== null) candidates.push(internal.resolved);
767
+ candidates.push(...appConnections.resolved);
768
+ const out = /* @__PURE__ */ new Set();
769
+ for (const candidate of candidates) {
770
+ const origin = normalizeOrigin(candidate);
771
+ if (origin !== null) out.add(origin);
772
+ }
773
+ return out;
774
+ }
775
+ /**
776
+ * The origin to send the browser back to: the first claimed origin that is in
777
+ * `allowed`, or `null` (the caller then uses its default address).
778
+ */
779
+ function pickRequestOrigin(headers, allowed) {
780
+ for (const origin of claimedRequestOrigins(headers)) if (allowed.has(origin)) return origin;
781
+ return null;
782
+ }
783
+ //#endregion
708
784
  //#region src/oidc/auth-oidc.addon.ts
709
785
  /**
710
786
  * Generic OpenID Connect (OIDC) authentication provider.
@@ -771,7 +847,20 @@ var LINK_INTENT_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
771
847
  var INSTANCE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
772
848
  /** Bound on in-flight logins: `/start` is public, so a flood must not grow the map. */
773
849
  var MAX_PENDING_STATES = 1e3;
774
- var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
850
+ /**
851
+ * Origin + mount prefix of a pinned `redirect_uri`: the part before the
852
+ * callback path when it ends with it, else just its origin.
853
+ */
854
+ function returnBaseOf(redirectUri, callbackPath) {
855
+ try {
856
+ const url = new URL(redirectUri);
857
+ const prefix = url.pathname.endsWith(callbackPath) ? url.pathname.slice(0, -callbackPath.length) : "";
858
+ return `${url.origin}${prefix.replace(/\/+$/, "")}`;
859
+ } catch {
860
+ return "";
861
+ }
862
+ }
863
+ var AuthOidcAddon = class extends require_dist.BaseAddon {
775
864
  /** Per-provider runtime state, keyed by `provider.id`. */
776
865
  instances = /* @__PURE__ */ new Map();
777
866
  /** Pending OAuth states across ALL providers. The pending entry
@@ -797,7 +886,7 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
797
886
  return this.handleCallback(code, state);
798
887
  }
799
888
  };
800
- const routeProvider = require_hub_browser_origin.buildAddonRouteProvider("auth-oidc", [{
889
+ const routeProvider = require_dist.buildAddonRouteProvider("auth-oidc", [{
801
890
  method: "GET",
802
891
  path: "/:providerId/start",
803
892
  access: "public",
@@ -814,15 +903,15 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
814
903
  this.ctx.logger.info("OIDC auth provider initialized", { meta: { providers: [...this.instances.keys()] } });
815
904
  return [
816
905
  {
817
- capability: require_hub_browser_origin.authProviderCapability,
906
+ capability: require_dist.authProviderCapability,
818
907
  provider: authProvider
819
908
  },
820
909
  {
821
- capability: require_hub_browser_origin.addonRoutesCapability,
910
+ capability: require_dist.addonRoutesCapability,
822
911
  provider: routeProvider
823
912
  },
824
913
  {
825
- capability: require_hub_browser_origin.loginMethodCapability,
914
+ capability: require_dist.loginMethodCapability,
826
915
  provider: loginMethodProvider
827
916
  }
828
917
  ];
@@ -840,7 +929,7 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
840
929
  kind: "redirect",
841
930
  id: `auth-oidc/${id}`,
842
931
  label: inst.config.displayName || DEFAULT_PROVIDER.displayName,
843
- icon: require_hub_browser_origin.isDisplayIconKey(inst.config.icon) ? inst.config.icon : DEFAULT_PROVIDER.icon,
932
+ icon: require_dist.isDisplayIconKey(inst.config.icon) ? inst.config.icon : DEFAULT_PROVIDER.icon,
844
933
  startUrl: `/addon/auth-oidc/${id}/start`,
845
934
  stage: "primary",
846
935
  linkable: true
@@ -905,7 +994,7 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
905
994
  type: "text",
906
995
  key: "redirectUri",
907
996
  label: "Redirect URI override",
908
- description: "Optional. Default uses the hub address from Network (public tunnel, then mesh, then LAN) + the instance id.",
997
+ description: "Optional — leave empty. Empty: a sign-in returns to the hub address it was started from (LAN, mesh or public, as listed on the Network page), so register one callback per address you sign in from at the IdP. Set: it PINS that one callback — every sign-in returns to its address, wherever it started.",
909
998
  default: ""
910
999
  },
911
1000
  {
@@ -983,21 +1072,60 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
983
1072
  }
984
1073
  return doc;
985
1074
  }
986
- async buildRedirectUri(providerId) {
1075
+ /** The hub's Network-page address book, or `null` when `local-network` is unreachable. */
1076
+ async addressBook() {
1077
+ try {
1078
+ return await this.ctx.api.localNetwork.getAddresses.query({ scheme: "https" });
1079
+ } catch (err) {
1080
+ this.ctx.logger.warn("OIDC: hub addresses unavailable from local-network", { meta: { error: require_dist.errMsg(err) } });
1081
+ return null;
1082
+ }
1083
+ }
1084
+ /**
1085
+ * The `redirect_uri` of one sign-in, and the base the browser returns to
1086
+ * after the callback (D791). Precedence:
1087
+ *
1088
+ * 1. The instance's "Redirect URI override", when set: it PINS one origin —
1089
+ * every sign-in returns there, wherever it started.
1090
+ * 2. The origin the `/start` request was made at (Host / X-Forwarded-Host /
1091
+ * -Proto + mount prefix), ONLY when the hub advertises that origin
1092
+ * (`request-origin.ts`) — a forged Host never reaches the IdP.
1093
+ * 3. The Network page's public endpoint (`roles.publicEndpoint`), else the
1094
+ * development default.
1095
+ */
1096
+ async resolveRedirect(providerId, headers) {
987
1097
  const inst = this.instances.get(providerId);
988
1098
  if (!inst) throw new Error(`OIDC: no provider configured with id "${providerId}"`);
1099
+ const callbackPath = `/addon/auth-oidc/${providerId}/callback`;
989
1100
  const configured = inst.config.redirectUri?.trim();
990
- if (configured) return configured;
991
- return `${await require_hub_browser_origin.hubBrowserOrigin(this.ctx.api.localNetwork, this.ctx.logger) ?? "https://localhost:4443"}/addon/auth-oidc/${providerId}/callback`;
1101
+ if (configured) return {
1102
+ redirectUri: configured,
1103
+ returnBase: returnBaseOf(configured, callbackPath)
1104
+ };
1105
+ const book = await this.addressBook();
1106
+ const fromRequest = headers !== null && book !== null ? pickRequestOrigin(headers, advertisedHubOrigins(book)) : null;
1107
+ if (fromRequest !== null) {
1108
+ const base = `${fromRequest}${requestMountPrefix(headers ?? {})}`;
1109
+ return {
1110
+ redirectUri: `${base}${callbackPath}`,
1111
+ returnBase: base
1112
+ };
1113
+ }
1114
+ const fallback = (book !== null ? normalizeOrigin(book.roles.publicEndpoint.resolved ?? "") : null) ?? "https://localhost:4443";
1115
+ return {
1116
+ redirectUri: `${fallback}${callbackPath}`,
1117
+ returnBase: fallback
1118
+ };
992
1119
  }
993
- async buildLoginUrl(providerId, state, linkIntent) {
1120
+ async buildLoginUrl(providerId, state, options = {}) {
1121
+ const { linkIntent } = options;
994
1122
  const inst = this.instances.get(providerId);
995
1123
  if (!inst) throw new Error(`OIDC: no provider configured with id "${providerId}"`);
996
1124
  const doc = await this.getDiscovery(providerId);
997
1125
  const codeVerifier = node_crypto.randomBytes(32).toString("base64url");
998
1126
  const codeChallenge = node_crypto.createHash("sha256").update(codeVerifier).digest("base64url");
999
1127
  const nonce = node_crypto.randomBytes(16).toString("base64url");
1000
- const redirectUri = await this.buildRedirectUri(providerId);
1128
+ const { redirectUri, returnBase } = await this.resolveRedirect(providerId, options.headers ?? null);
1001
1129
  this.pruneExpiredStates();
1002
1130
  while (this.pendingStates.size >= MAX_PENDING_STATES) {
1003
1131
  const oldest = this.pendingStates.keys().next();
@@ -1009,6 +1137,8 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
1009
1137
  nonce,
1010
1138
  providerId,
1011
1139
  redirectUri,
1140
+ returnBase,
1141
+ popup: options.popup === true,
1012
1142
  createdAt: Date.now(),
1013
1143
  ...linkIntent !== void 0 ? { linkIntent } : {}
1014
1144
  });
@@ -1065,7 +1195,7 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
1065
1195
  audience: inst.config.clientId
1066
1196
  })).payload;
1067
1197
  } catch (err) {
1068
- throw new Error(`OIDC id_token verification failed: ${require_hub_browser_origin.errMsg(err)}`, { cause: err });
1198
+ throw new Error(`OIDC id_token verification failed: ${require_dist.errMsg(err)}`, { cause: err });
1069
1199
  }
1070
1200
  if (typeof payload["nonce"] !== "string" || payload["nonce"] !== pending.nonce) throw new Error("OIDC id_token nonce mismatch — possible replay attack");
1071
1201
  const sub = typeof payload.sub === "string" ? payload.sub : null;
@@ -1083,7 +1213,9 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
1083
1213
  isAdmin: false,
1084
1214
  ...pending.linkIntent !== void 0 ? { linkIntent: pending.linkIntent } : {},
1085
1215
  issuer: doc.issuer,
1086
- providerId
1216
+ providerId,
1217
+ returnBase: pending.returnBase,
1218
+ popup: pending.popup
1087
1219
  };
1088
1220
  }
1089
1221
  validateLocalSessionToken(_token) {
@@ -1102,9 +1234,14 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
1102
1234
  reply.send({ error: "Malformed link request" });
1103
1235
  return;
1104
1236
  }
1237
+ const popupParam = req.query["popup"];
1105
1238
  const state = node_crypto.randomBytes(16).toString("base64url");
1106
1239
  try {
1107
- const url = await this.buildLoginUrl(providerId, state, linkParam);
1240
+ const url = await this.buildLoginUrl(providerId, state, {
1241
+ ...linkParam !== void 0 ? { linkIntent: linkParam } : {},
1242
+ headers: req.headers,
1243
+ popup: popupParam === "1"
1244
+ });
1108
1245
  reply.code(302);
1109
1246
  reply.header("Location", url);
1110
1247
  reply.send("");
@@ -1112,7 +1249,7 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
1112
1249
  reply.code(500);
1113
1250
  reply.send({
1114
1251
  error: "OIDC start failed",
1115
- message: require_hub_browser_origin.errMsg(err)
1252
+ message: require_dist.errMsg(err)
1116
1253
  });
1117
1254
  }
1118
1255
  }
@@ -1158,14 +1295,16 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
1158
1295
  ...result.linkIntent !== void 0 ? { linkIntent: result.linkIntent } : {},
1159
1296
  issuer: result.issuer
1160
1297
  } });
1298
+ const finish = new URLSearchParams({ bridge: bridgeToken });
1299
+ if (result.popup) finish.set("popup", "1");
1161
1300
  reply.code(302);
1162
- reply.header("Location", `/api/auth/sso/finish?bridge=${encodeURIComponent(bridgeToken)}`);
1301
+ reply.header("Location", `${result.returnBase}/api/auth/sso/finish?${finish.toString()}`);
1163
1302
  reply.send("");
1164
1303
  } catch (err) {
1165
1304
  reply.code(500);
1166
1305
  reply.send({
1167
1306
  error: "OIDC callback failed",
1168
- message: require_hub_browser_origin.errMsg(err)
1307
+ message: require_dist.errMsg(err)
1169
1308
  });
1170
1309
  }
1171
1310
  }
@@ -1,4 +1,4 @@
1
- import { a as buildAddonRouteProvider, i as authProviderCapability, l as errMsg, n as addonRoutesCapability, o as isDisplayIconKey, s as loginMethodCapability, t as hubBrowserOrigin, u as BaseAddon } from "../hub-browser-origin-BRU-HVqq.mjs";
1
+ import { a as isDisplayIconKey, c as errMsg, i as buildAddonRouteProvider, l as BaseAddon, o as loginMethodCapability, r as authProviderCapability, t as addonRoutesCapability } from "../dist-DbaYwR-N.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 });
@@ -699,6 +699,82 @@ function createRemoteJWKSet(url, options) {
699
699
  });
700
700
  }
701
701
  //#endregion
702
+ //#region src/oidc/request-origin.ts
703
+ /** host[:port] — a DNS name, IPv4 or bracketed IPv6. No userinfo, path or spaces. */
704
+ var AUTHORITY_RE = /^(?:[a-z0-9.-]+|\[[0-9a-f:.]+\])(?::\d{1,5})?$/i;
705
+ /** Same grammar as the hub's `parseForwardedPrefix` (spa-mount.ts). */
706
+ var PREFIX_RE = /^\/[A-Za-z0-9_\-.]+(?:\/[A-Za-z0-9_\-.]+)*$/;
707
+ var MAX_PREFIX_LENGTH = 512;
708
+ /** First value of a possibly comma-joined forwarded header (the client-most proxy). */
709
+ function firstValue(value) {
710
+ if (value === void 0) return null;
711
+ const first = value.split(",")[0]?.trim() ?? "";
712
+ return first.length === 0 ? null : first;
713
+ }
714
+ /** `scheme://host[:port]` normalised by WHATWG URL, or `null` if it is not an http(s) origin. */
715
+ function normalizeOrigin(value) {
716
+ try {
717
+ const url = new URL(value);
718
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
719
+ if (url.username !== "" || url.password !== "") return null;
720
+ return url.origin;
721
+ } catch {
722
+ return null;
723
+ }
724
+ }
725
+ /** The mount prefix the relay named (no trailing slash), or `''`. Invalid = absent. */
726
+ function requestMountPrefix(headers) {
727
+ const raw = headers["x-forwarded-prefix"];
728
+ if (raw === void 0 || raw.length === 0 || raw.length > MAX_PREFIX_LENGTH) return "";
729
+ const trimmed = raw.endsWith("/") ? raw.slice(0, -1) : raw;
730
+ if (trimmed === "" || !PREFIX_RE.test(trimmed)) return "";
731
+ if (trimmed.split("/").some((segment) => segment === ".." || segment === ".")) return "";
732
+ return trimmed;
733
+ }
734
+ /**
735
+ * The origins the request CLAIMS, most specific first: the proxy's
736
+ * `X-Forwarded-Host` over `Host`; the named `X-Forwarded-Proto`, or https
737
+ * then http when none is named (the hub terminates TLS itself on 4443).
738
+ */
739
+ function claimedRequestOrigins(headers) {
740
+ const authority = firstValue(headers["x-forwarded-host"]) ?? firstValue(headers["host"]);
741
+ if (authority === null || !AUTHORITY_RE.test(authority)) return [];
742
+ const proto = firstValue(headers["x-forwarded-proto"])?.toLowerCase() ?? null;
743
+ const schemes = proto === "https" || proto === "http" ? [proto] : proto === null ? ["https", "http"] : [];
744
+ const out = [];
745
+ for (const scheme of schemes) {
746
+ const origin = normalizeOrigin(`${scheme}://${authority}`);
747
+ if (origin !== null) out.push(origin);
748
+ }
749
+ return out;
750
+ }
751
+ /** Every origin the hub advertises itself at, normalised. Never the app link, never loopback. */
752
+ function advertisedHubOrigins(book) {
753
+ const candidates = [];
754
+ for (const row of book.rows) {
755
+ if (row.kind === "app-link" || row.kind === "loopback") continue;
756
+ candidates.push(row.baseUrl);
757
+ }
758
+ const { publicEndpoint, internal, appConnections } = book.roles;
759
+ if (publicEndpoint.resolved !== null) candidates.push(publicEndpoint.resolved);
760
+ if (internal.resolved !== null) candidates.push(internal.resolved);
761
+ candidates.push(...appConnections.resolved);
762
+ const out = /* @__PURE__ */ new Set();
763
+ for (const candidate of candidates) {
764
+ const origin = normalizeOrigin(candidate);
765
+ if (origin !== null) out.add(origin);
766
+ }
767
+ return out;
768
+ }
769
+ /**
770
+ * The origin to send the browser back to: the first claimed origin that is in
771
+ * `allowed`, or `null` (the caller then uses its default address).
772
+ */
773
+ function pickRequestOrigin(headers, allowed) {
774
+ for (const origin of claimedRequestOrigins(headers)) if (allowed.has(origin)) return origin;
775
+ return null;
776
+ }
777
+ //#endregion
702
778
  //#region src/oidc/auth-oidc.addon.ts
703
779
  /**
704
780
  * Generic OpenID Connect (OIDC) authentication provider.
@@ -765,6 +841,19 @@ var LINK_INTENT_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
765
841
  var INSTANCE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
766
842
  /** Bound on in-flight logins: `/start` is public, so a flood must not grow the map. */
767
843
  var MAX_PENDING_STATES = 1e3;
844
+ /**
845
+ * Origin + mount prefix of a pinned `redirect_uri`: the part before the
846
+ * callback path when it ends with it, else just its origin.
847
+ */
848
+ function returnBaseOf(redirectUri, callbackPath) {
849
+ try {
850
+ const url = new URL(redirectUri);
851
+ const prefix = url.pathname.endsWith(callbackPath) ? url.pathname.slice(0, -callbackPath.length) : "";
852
+ return `${url.origin}${prefix.replace(/\/+$/, "")}`;
853
+ } catch {
854
+ return "";
855
+ }
856
+ }
768
857
  var AuthOidcAddon = class extends BaseAddon {
769
858
  /** Per-provider runtime state, keyed by `provider.id`. */
770
859
  instances = /* @__PURE__ */ new Map();
@@ -899,7 +988,7 @@ var AuthOidcAddon = class extends BaseAddon {
899
988
  type: "text",
900
989
  key: "redirectUri",
901
990
  label: "Redirect URI override",
902
- description: "Optional. Default uses the hub address from Network (public tunnel, then mesh, then LAN) + the instance id.",
991
+ description: "Optional — leave empty. Empty: a sign-in returns to the hub address it was started from (LAN, mesh or public, as listed on the Network page), so register one callback per address you sign in from at the IdP. Set: it PINS that one callback — every sign-in returns to its address, wherever it started.",
903
992
  default: ""
904
993
  },
905
994
  {
@@ -977,21 +1066,60 @@ var AuthOidcAddon = class extends BaseAddon {
977
1066
  }
978
1067
  return doc;
979
1068
  }
980
- async buildRedirectUri(providerId) {
1069
+ /** The hub's Network-page address book, or `null` when `local-network` is unreachable. */
1070
+ async addressBook() {
1071
+ try {
1072
+ return await this.ctx.api.localNetwork.getAddresses.query({ scheme: "https" });
1073
+ } catch (err) {
1074
+ this.ctx.logger.warn("OIDC: hub addresses unavailable from local-network", { meta: { error: errMsg(err) } });
1075
+ return null;
1076
+ }
1077
+ }
1078
+ /**
1079
+ * The `redirect_uri` of one sign-in, and the base the browser returns to
1080
+ * after the callback (D791). Precedence:
1081
+ *
1082
+ * 1. The instance's "Redirect URI override", when set: it PINS one origin —
1083
+ * every sign-in returns there, wherever it started.
1084
+ * 2. The origin the `/start` request was made at (Host / X-Forwarded-Host /
1085
+ * -Proto + mount prefix), ONLY when the hub advertises that origin
1086
+ * (`request-origin.ts`) — a forged Host never reaches the IdP.
1087
+ * 3. The Network page's public endpoint (`roles.publicEndpoint`), else the
1088
+ * development default.
1089
+ */
1090
+ async resolveRedirect(providerId, headers) {
981
1091
  const inst = this.instances.get(providerId);
982
1092
  if (!inst) throw new Error(`OIDC: no provider configured with id "${providerId}"`);
1093
+ const callbackPath = `/addon/auth-oidc/${providerId}/callback`;
983
1094
  const configured = inst.config.redirectUri?.trim();
984
- if (configured) return configured;
985
- return `${await hubBrowserOrigin(this.ctx.api.localNetwork, this.ctx.logger) ?? "https://localhost:4443"}/addon/auth-oidc/${providerId}/callback`;
1095
+ if (configured) return {
1096
+ redirectUri: configured,
1097
+ returnBase: returnBaseOf(configured, callbackPath)
1098
+ };
1099
+ const book = await this.addressBook();
1100
+ const fromRequest = headers !== null && book !== null ? pickRequestOrigin(headers, advertisedHubOrigins(book)) : null;
1101
+ if (fromRequest !== null) {
1102
+ const base = `${fromRequest}${requestMountPrefix(headers ?? {})}`;
1103
+ return {
1104
+ redirectUri: `${base}${callbackPath}`,
1105
+ returnBase: base
1106
+ };
1107
+ }
1108
+ const fallback = (book !== null ? normalizeOrigin(book.roles.publicEndpoint.resolved ?? "") : null) ?? "https://localhost:4443";
1109
+ return {
1110
+ redirectUri: `${fallback}${callbackPath}`,
1111
+ returnBase: fallback
1112
+ };
986
1113
  }
987
- async buildLoginUrl(providerId, state, linkIntent) {
1114
+ async buildLoginUrl(providerId, state, options = {}) {
1115
+ const { linkIntent } = options;
988
1116
  const inst = this.instances.get(providerId);
989
1117
  if (!inst) throw new Error(`OIDC: no provider configured with id "${providerId}"`);
990
1118
  const doc = await this.getDiscovery(providerId);
991
1119
  const codeVerifier = crypto$1.randomBytes(32).toString("base64url");
992
1120
  const codeChallenge = crypto$1.createHash("sha256").update(codeVerifier).digest("base64url");
993
1121
  const nonce = crypto$1.randomBytes(16).toString("base64url");
994
- const redirectUri = await this.buildRedirectUri(providerId);
1122
+ const { redirectUri, returnBase } = await this.resolveRedirect(providerId, options.headers ?? null);
995
1123
  this.pruneExpiredStates();
996
1124
  while (this.pendingStates.size >= MAX_PENDING_STATES) {
997
1125
  const oldest = this.pendingStates.keys().next();
@@ -1003,6 +1131,8 @@ var AuthOidcAddon = class extends BaseAddon {
1003
1131
  nonce,
1004
1132
  providerId,
1005
1133
  redirectUri,
1134
+ returnBase,
1135
+ popup: options.popup === true,
1006
1136
  createdAt: Date.now(),
1007
1137
  ...linkIntent !== void 0 ? { linkIntent } : {}
1008
1138
  });
@@ -1077,7 +1207,9 @@ var AuthOidcAddon = class extends BaseAddon {
1077
1207
  isAdmin: false,
1078
1208
  ...pending.linkIntent !== void 0 ? { linkIntent: pending.linkIntent } : {},
1079
1209
  issuer: doc.issuer,
1080
- providerId
1210
+ providerId,
1211
+ returnBase: pending.returnBase,
1212
+ popup: pending.popup
1081
1213
  };
1082
1214
  }
1083
1215
  validateLocalSessionToken(_token) {
@@ -1096,9 +1228,14 @@ var AuthOidcAddon = class extends BaseAddon {
1096
1228
  reply.send({ error: "Malformed link request" });
1097
1229
  return;
1098
1230
  }
1231
+ const popupParam = req.query["popup"];
1099
1232
  const state = crypto$1.randomBytes(16).toString("base64url");
1100
1233
  try {
1101
- const url = await this.buildLoginUrl(providerId, state, linkParam);
1234
+ const url = await this.buildLoginUrl(providerId, state, {
1235
+ ...linkParam !== void 0 ? { linkIntent: linkParam } : {},
1236
+ headers: req.headers,
1237
+ popup: popupParam === "1"
1238
+ });
1102
1239
  reply.code(302);
1103
1240
  reply.header("Location", url);
1104
1241
  reply.send("");
@@ -1152,8 +1289,10 @@ var AuthOidcAddon = class extends BaseAddon {
1152
1289
  ...result.linkIntent !== void 0 ? { linkIntent: result.linkIntent } : {},
1153
1290
  issuer: result.issuer
1154
1291
  } });
1292
+ const finish = new URLSearchParams({ bridge: bridgeToken });
1293
+ if (result.popup) finish.set("popup", "1");
1155
1294
  reply.code(302);
1156
- reply.header("Location", `/api/auth/sso/finish?bridge=${encodeURIComponent(bridgeToken)}`);
1295
+ reply.header("Location", `${result.returnBase}/api/auth/sso/finish?${finish.toString()}`);
1157
1296
  reply.send("");
1158
1297
  } catch (err) {
1159
1298
  reply.code(500);