@camstack/addon-auth 1.2.130 → 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.
@@ -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-C-u2l9VC.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.
@@ -733,9 +809,14 @@ function createRemoteJWKSet(url, options) {
733
809
  * — the hub verifies it with the same `jwtSecret` and only then mints
734
810
  * the user session JWT.
735
811
  *
812
+ * Identity mapping is EXPLICIT LINKING only (D782): the bridge names the
813
+ * identity `oidc:<instance>:<sub>`; the hub signs in the user that identity
814
+ * was linked to, and refuses an unlinked one. A flow started by a signed-in
815
+ * user's "Link" button carries the hub's opaque `?link=` handle through the
816
+ * pending state into the bridge (`linkIntent`); the hub resolves it.
817
+ *
736
818
  * TODO (next session):
737
819
  * - RP-initiated logout (`end_session_endpoint` 302 with id_token_hint).
738
- * - Groups → scope mapping via id_token `groups` claim.
739
820
  * - Refresh token rotation (offline_access).
740
821
  * - Persist state + nonce + PKCE across restart (multi-replica).
741
822
  */
@@ -748,12 +829,38 @@ var DEFAULT_PROVIDER = {
748
829
  clientSecret: "",
749
830
  redirectUri: "",
750
831
  scopes: "openid profile email",
751
- usernameClaim: "preferred_username",
752
- defaultRole: "viewer"
832
+ usernameClaim: "preferred_username"
753
833
  };
754
834
  var DEFAULT_CONFIG = { providers: [] };
755
835
  var STATE_TTL_MS = 5 * 6e4;
756
- var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
836
+ /**
837
+ * Shape of a hub link-intent handle (`?link=` on /start). Opaque to this
838
+ * addon — the hub resolves it, single-use, at the SSO finish. Validated only
839
+ * so a junk value is refused here rather than carried into a signed bridge.
840
+ */
841
+ var LINK_INTENT_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
842
+ /**
843
+ * An instance id appears in URLs and in the identity `oidc:<id>:<sub>`; a `:`
844
+ * would make two identities read the same, so ids are slugs. A configured row
845
+ * whose id is not one is skipped.
846
+ */
847
+ var INSTANCE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
848
+ /** Bound on in-flight logins: `/start` is public, so a flood must not grow the map. */
849
+ var MAX_PENDING_STATES = 1e3;
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 {
757
864
  /** Per-provider runtime state, keyed by `provider.id`. */
758
865
  instances = /* @__PURE__ */ new Map();
759
866
  /** Pending OAuth states across ALL providers. The pending entry
@@ -779,7 +886,7 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
779
886
  return this.handleCallback(code, state);
780
887
  }
781
888
  };
782
- const routeProvider = require_hub_browser_origin.buildAddonRouteProvider("auth-oidc", [{
889
+ const routeProvider = require_dist.buildAddonRouteProvider("auth-oidc", [{
783
890
  method: "GET",
784
891
  path: "/:providerId/start",
785
892
  access: "public",
@@ -796,15 +903,15 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
796
903
  this.ctx.logger.info("OIDC auth provider initialized", { meta: { providers: [...this.instances.keys()] } });
797
904
  return [
798
905
  {
799
- capability: require_hub_browser_origin.authProviderCapability,
906
+ capability: require_dist.authProviderCapability,
800
907
  provider: authProvider
801
908
  },
802
909
  {
803
- capability: require_hub_browser_origin.addonRoutesCapability,
910
+ capability: require_dist.addonRoutesCapability,
804
911
  provider: routeProvider
805
912
  },
806
913
  {
807
- capability: require_hub_browser_origin.loginMethodCapability,
914
+ capability: require_dist.loginMethodCapability,
808
915
  provider: loginMethodProvider
809
916
  }
810
917
  ];
@@ -822,9 +929,10 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
822
929
  kind: "redirect",
823
930
  id: `auth-oidc/${id}`,
824
931
  label: inst.config.displayName || DEFAULT_PROVIDER.displayName,
825
- icon: inst.config.icon || DEFAULT_PROVIDER.icon,
932
+ icon: require_dist.isDisplayIconKey(inst.config.icon) ? inst.config.icon : DEFAULT_PROVIDER.icon,
826
933
  startUrl: `/addon/auth-oidc/${id}/start`,
827
- stage: "primary"
934
+ stage: "primary",
935
+ linkable: true
828
936
  });
829
937
  }
830
938
  return out;
@@ -857,11 +965,11 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
857
965
  default: "OpenID Connect"
858
966
  },
859
967
  {
860
- type: "text",
968
+ type: "icon",
861
969
  key: "icon",
862
970
  label: "Icon",
863
- description: "lucide-react icon name (default: shield-check).",
864
- default: "shield-check"
971
+ description: "Shown on the login button.",
972
+ default: DEFAULT_PROVIDER.icon
865
973
  },
866
974
  {
867
975
  type: "text",
@@ -877,7 +985,7 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
877
985
  default: ""
878
986
  },
879
987
  {
880
- type: "text",
988
+ type: "password",
881
989
  key: "clientSecret",
882
990
  label: "Client Secret",
883
991
  default: ""
@@ -886,7 +994,7 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
886
994
  type: "text",
887
995
  key: "redirectUri",
888
996
  label: "Redirect URI override",
889
- 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.",
890
998
  default: ""
891
999
  },
892
1000
  {
@@ -915,20 +1023,6 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
915
1023
  label: "sub (user id)"
916
1024
  }
917
1025
  ]
918
- },
919
- {
920
- type: "select",
921
- key: "defaultRole",
922
- label: "Default role",
923
- description: "Role assigned to first-time SSO users.",
924
- default: "viewer",
925
- options: [{
926
- value: "viewer",
927
- label: "Viewer"
928
- }, {
929
- value: "admin",
930
- label: "Admin"
931
- }]
932
1026
  }
933
1027
  ]
934
1028
  })]
@@ -942,7 +1036,10 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
942
1036
  ...DEFAULT_PROVIDER,
943
1037
  ...raw
944
1038
  };
945
- if (!merged.id || !merged.id.trim()) continue;
1039
+ if (!merged.id || !INSTANCE_ID_PATTERN.test(merged.id)) {
1040
+ if (merged.id) this.ctx.logger.warn("OIDC: provider instance skipped — id must be a lowercase slug", { meta: { id: merged.id } });
1041
+ continue;
1042
+ }
946
1043
  this.instances.set(merged.id, {
947
1044
  config: merged,
948
1045
  discovery: null,
@@ -975,28 +1072,75 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
975
1072
  }
976
1073
  return doc;
977
1074
  }
978
- 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) {
979
1097
  const inst = this.instances.get(providerId);
980
1098
  if (!inst) throw new Error(`OIDC: no provider configured with id "${providerId}"`);
1099
+ const callbackPath = `/addon/auth-oidc/${providerId}/callback`;
981
1100
  const configured = inst.config.redirectUri?.trim();
982
- if (configured) return configured;
983
- 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
+ };
984
1119
  }
985
- async buildLoginUrl(providerId, state) {
1120
+ async buildLoginUrl(providerId, state, options = {}) {
1121
+ const { linkIntent } = options;
986
1122
  const inst = this.instances.get(providerId);
987
1123
  if (!inst) throw new Error(`OIDC: no provider configured with id "${providerId}"`);
988
1124
  const doc = await this.getDiscovery(providerId);
989
1125
  const codeVerifier = node_crypto.randomBytes(32).toString("base64url");
990
1126
  const codeChallenge = node_crypto.createHash("sha256").update(codeVerifier).digest("base64url");
991
1127
  const nonce = node_crypto.randomBytes(16).toString("base64url");
992
- const redirectUri = await this.buildRedirectUri(providerId);
1128
+ const { redirectUri, returnBase } = await this.resolveRedirect(providerId, options.headers ?? null);
993
1129
  this.pruneExpiredStates();
1130
+ while (this.pendingStates.size >= MAX_PENDING_STATES) {
1131
+ const oldest = this.pendingStates.keys().next();
1132
+ if (oldest.done) break;
1133
+ this.pendingStates.delete(oldest.value);
1134
+ }
994
1135
  this.pendingStates.set(state, {
995
1136
  codeVerifier,
996
1137
  nonce,
997
1138
  providerId,
998
1139
  redirectUri,
999
- createdAt: Date.now()
1140
+ returnBase,
1141
+ popup: options.popup === true,
1142
+ createdAt: Date.now(),
1143
+ ...linkIntent !== void 0 ? { linkIntent } : {}
1000
1144
  });
1001
1145
  const params = new URLSearchParams({
1002
1146
  response_type: "code",
@@ -1051,7 +1195,7 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
1051
1195
  audience: inst.config.clientId
1052
1196
  })).payload;
1053
1197
  } catch (err) {
1054
- 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 });
1055
1199
  }
1056
1200
  if (typeof payload["nonce"] !== "string" || payload["nonce"] !== pending.nonce) throw new Error("OIDC id_token nonce mismatch — possible replay attack");
1057
1201
  const sub = typeof payload.sub === "string" ? payload.sub : null;
@@ -1066,7 +1210,12 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
1066
1210
  username: String(username),
1067
1211
  ...emailClaim ? { email: emailClaim } : {},
1068
1212
  ...nameClaim ? { displayName: nameClaim } : {},
1069
- isAdmin: inst.config.defaultRole === "admin"
1213
+ isAdmin: false,
1214
+ ...pending.linkIntent !== void 0 ? { linkIntent: pending.linkIntent } : {},
1215
+ issuer: doc.issuer,
1216
+ providerId,
1217
+ returnBase: pending.returnBase,
1218
+ popup: pending.popup
1070
1219
  };
1071
1220
  }
1072
1221
  validateLocalSessionToken(_token) {
@@ -1079,9 +1228,20 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
1079
1228
  reply.send({ error: `Unknown OIDC provider: ${providerId}` });
1080
1229
  return;
1081
1230
  }
1231
+ const linkParam = req.query["link"];
1232
+ if (linkParam !== void 0 && !LINK_INTENT_PATTERN.test(linkParam)) {
1233
+ reply.code(400);
1234
+ reply.send({ error: "Malformed link request" });
1235
+ return;
1236
+ }
1237
+ const popupParam = req.query["popup"];
1082
1238
  const state = node_crypto.randomBytes(16).toString("base64url");
1083
1239
  try {
1084
- const url = await this.buildLoginUrl(providerId, state);
1240
+ const url = await this.buildLoginUrl(providerId, state, {
1241
+ ...linkParam !== void 0 ? { linkIntent: linkParam } : {},
1242
+ headers: req.headers,
1243
+ popup: popupParam === "1"
1244
+ });
1085
1245
  reply.code(302);
1086
1246
  reply.header("Location", url);
1087
1247
  reply.send("");
@@ -1089,7 +1249,7 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
1089
1249
  reply.code(500);
1090
1250
  reply.send({
1091
1251
  error: "OIDC start failed",
1092
- message: require_hub_browser_origin.errMsg(err)
1252
+ message: require_dist.errMsg(err)
1093
1253
  });
1094
1254
  }
1095
1255
  }
@@ -1120,22 +1280,31 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
1120
1280
  }
1121
1281
  try {
1122
1282
  const result = await this.handleCallback(code, state);
1283
+ if (result.providerId !== providerId) {
1284
+ reply.code(400);
1285
+ reply.send({ error: "OIDC callback does not match the provider the login started at" });
1286
+ return;
1287
+ }
1123
1288
  const { token: bridgeToken } = await this.ctx.api.ssoBridge.signBridgeToken.query({ claims: {
1124
1289
  userId: result.userId,
1125
1290
  username: result.username,
1126
1291
  isAdmin: result.isAdmin,
1127
1292
  provider: `auth-oidc/${providerId}`,
1128
1293
  ...result.email ? { email: result.email } : {},
1129
- ...result.displayName ? { displayName: result.displayName } : {}
1294
+ ...result.displayName ? { displayName: result.displayName } : {},
1295
+ ...result.linkIntent !== void 0 ? { linkIntent: result.linkIntent } : {},
1296
+ issuer: result.issuer
1130
1297
  } });
1298
+ const finish = new URLSearchParams({ bridge: bridgeToken });
1299
+ if (result.popup) finish.set("popup", "1");
1131
1300
  reply.code(302);
1132
- reply.header("Location", `/api/auth/sso/finish?bridge=${encodeURIComponent(bridgeToken)}`);
1301
+ reply.header("Location", `${result.returnBase}/api/auth/sso/finish?${finish.toString()}`);
1133
1302
  reply.send("");
1134
1303
  } catch (err) {
1135
1304
  reply.code(500);
1136
1305
  reply.send({
1137
1306
  error: "OIDC callback failed",
1138
- message: require_hub_browser_origin.errMsg(err)
1307
+ message: require_dist.errMsg(err)
1139
1308
  });
1140
1309
  }
1141
1310
  }