@camstack/addon-auth 1.2.130 → 1.2.131

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,41 @@ 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-C-u2l9VC.js");
6
+ const require_hub_browser_origin = require("../hub-browser-origin-CCWSbHYb.js");
7
+ //#region src/magic-link/retire-enabled-flag.ts
8
+ /** The retired settings key. */
9
+ var RETIRED_ENABLED_KEY = "enabled";
10
+ /** Store key marking the retirement as done. Not a schema field. */
11
+ var RETIREMENT_MARKER_KEY = "enabledFlagRetired";
12
+ function deferred(reason) {
13
+ return {
14
+ kind: "deferred",
15
+ reason
16
+ };
17
+ }
18
+ function isMarked(read) {
19
+ return read.kind === "present" && read.value === true;
20
+ }
21
+ async function retireEnabledFlag(deps) {
22
+ const marker = await deps.readStoredKey(RETIREMENT_MARKER_KEY);
23
+ if (marker.kind === "unreadable") return deferred("settings store unreadable (marker)");
24
+ if (isMarked(marker)) return { kind: "already-retired" };
25
+ const flag = await deps.readStoredKey(RETIRED_ENABLED_KEY);
26
+ if (flag.kind === "unreadable") return deferred("settings store unreadable (enabled)");
27
+ const wasOn = flag.kind === "present" && flag.value === true;
28
+ if (!wasOn) try {
29
+ await deps.disableAtCapability();
30
+ } catch (err) {
31
+ return deferred(`capability toggle refused: ${err instanceof Error ? err.message : String(err)}`);
32
+ }
33
+ await deps.commit();
34
+ if (!isMarked(await deps.readStoredKey("enabledFlagRetired"))) return deferred("retirement write did not land");
35
+ return {
36
+ kind: "retired",
37
+ capDisabled: !wasOn
38
+ };
39
+ }
40
+ //#endregion
7
41
  //#region src/magic-link/auth-magic-link.addon.ts
8
42
  /**
9
43
  * Magic-link authentication addon.
@@ -18,10 +52,10 @@ const require_hub_browser_origin = require("../hub-browser-origin-C-u2l9VC.js");
18
52
  * 3. Email click hits `/login?bridge=…` → 302 to `/api/auth/sso/finish?bridge=…`
19
53
  * → hub verifies the bridge token and mints the real session.
20
54
  *
21
- * Delivery is pluggable. This addon ships a "log-only" mode by default
22
- * (writes the URL to the addon log so operators can copy it during
23
- * setup before SMTP is configured). Production deployments configure
24
- * the SMTP relay via global settings.
55
+ * Delivery is pluggable. This addon ships a "log-only" mode by default,
56
+ * which records that a link was requested but never writes the link
57
+ * itself (it is a login credential — D783). Production deployments
58
+ * configure the SMTP relay via global settings.
25
59
  *
26
60
  * Open follow-ups:
27
61
  * - Wire `nodemailer` for real SMTP delivery (peer dep; the framework
@@ -29,16 +63,30 @@ const require_hub_browser_origin = require("../hub-browser-origin-C-u2l9VC.js");
29
63
  * - Throttle requests per-email (rate-limit window).
30
64
  * - Auto-provision unknown emails (currently rejected for safety).
31
65
  */
66
+ /** Capability whose per-provider toggle is magic-link's ONE enable (Identity → Authentication). */
67
+ var GOVERNING_CAPABILITY = "auth-provider";
68
+ /** Backoff between retirement attempts — the first ones are refused until the
69
+ * provider registration reaches the hub. After the last, the next boot retries. */
70
+ var RETIREMENT_RETRY_DELAYS_MS = [
71
+ 1e3,
72
+ 2e3,
73
+ 5e3,
74
+ 1e4,
75
+ 3e4,
76
+ 6e4
77
+ ];
32
78
  var DEFAULT_CONFIG = {
33
- enabled: false,
34
79
  displayName: "Email magic link",
35
- icon: "mail",
80
+ icon: "mailbox",
36
81
  publicOrigin: "",
37
82
  deliveryMode: "log-only",
38
83
  defaultRole: "viewer",
39
84
  ttlSec: 600
40
85
  };
41
86
  var AuthMagicLinkAddon = class extends require_hub_browser_origin.BaseAddon {
87
+ enabledFlagRetired = false;
88
+ retirementTimer = null;
89
+ shuttingDown = false;
42
90
  constructor() {
43
91
  super({ ...DEFAULT_CONFIG });
44
92
  }
@@ -78,6 +126,8 @@ var AuthMagicLinkAddon = class extends require_hub_browser_origin.BaseAddon {
78
126
  ]);
79
127
  const loginMethodProvider = { getLoginMethods: async () => this.buildLoginMethods() };
80
128
  this.ctx.logger.info("Magic-link auth provider initialized", { meta: { deliveryMode: this.config.deliveryMode } });
129
+ this.shuttingDown = false;
130
+ this.attemptEnabledFlagRetirement(0);
81
131
  return [
82
132
  {
83
133
  capability: require_hub_browser_origin.authProviderCapability,
@@ -94,16 +144,70 @@ var AuthMagicLinkAddon = class extends require_hub_browser_origin.BaseAddon {
94
144
  ];
95
145
  }
96
146
  buildLoginMethods() {
97
- if (!this.config.enabled) return [];
147
+ if (!this.enabledFlagRetired) return [];
98
148
  return [{
99
149
  kind: "redirect",
100
150
  id: "auth-magic-link",
101
151
  label: this.config.displayName || DEFAULT_CONFIG.displayName,
102
- icon: this.config.icon || DEFAULT_CONFIG.icon,
152
+ icon: require_hub_browser_origin.isDisplayIconKey(this.config.icon) ? this.config.icon : DEFAULT_CONFIG.icon,
103
153
  startUrl: "/addon/auth-magic-link/start",
104
154
  stage: "primary"
105
155
  }];
106
156
  }
157
+ async onShutdown() {
158
+ this.shuttingDown = true;
159
+ if (this.retirementTimer !== null) clearTimeout(this.retirementTimer);
160
+ this.retirementTimer = null;
161
+ }
162
+ /** One attempt of the D777 carry-over; reschedules itself while deferred. */
163
+ async attemptEnabledFlagRetirement(attempt) {
164
+ if (this.shuttingDown) return;
165
+ let outcome;
166
+ try {
167
+ outcome = await retireEnabledFlag({
168
+ readStoredKey: (key) => this.readStoredGlobalKey(key),
169
+ disableAtCapability: async () => {
170
+ await this.ctx.api.addons.setCapabilityProviderEnabled.mutate({
171
+ capName: GOVERNING_CAPABILITY,
172
+ addonId: this.ctx.id,
173
+ enabled: false
174
+ });
175
+ },
176
+ commit: async () => {
177
+ const patch = {
178
+ [RETIRED_ENABLED_KEY]: void 0,
179
+ [RETIREMENT_MARKER_KEY]: true
180
+ };
181
+ await this.updateGlobalSettings(patch);
182
+ }
183
+ });
184
+ } catch (err) {
185
+ outcome = {
186
+ kind: "deferred",
187
+ reason: require_hub_browser_origin.errMsg(err)
188
+ };
189
+ }
190
+ if (outcome.kind !== "deferred") {
191
+ this.enabledFlagRetired = true;
192
+ if (outcome.kind === "retired") this.ctx.logger.info(outcome.capDisabled ? "Magic-link: the retired `enabled` setting was off — the provider is now DISABLED on Identity → Authentication; enable it there to offer magic-link login." : "Magic-link: the retired `enabled` setting was on — the provider stays enabled on Identity → Authentication.");
193
+ return;
194
+ }
195
+ const delay = RETIREMENT_RETRY_DELAYS_MS[attempt];
196
+ if (delay === void 0) {
197
+ this.ctx.logger.warn("Magic-link: carrying the retired `enabled` setting over to the capability toggle failed — login button withheld until the next boot", { meta: { reason: outcome.reason } });
198
+ return;
199
+ }
200
+ this.ctx.logger.debug("Magic-link: enabled-flag retirement deferred", { meta: {
201
+ reason: outcome.reason,
202
+ attempt,
203
+ retryInMs: delay
204
+ } });
205
+ this.retirementTimer = setTimeout(() => {
206
+ this.retirementTimer = null;
207
+ this.attemptEnabledFlagRetirement(attempt + 1);
208
+ }, delay);
209
+ this.retirementTimer.unref?.();
210
+ }
107
211
  globalSettingsSchema() {
108
212
  return this.schema({ sections: [{
109
213
  id: "magic-link",
@@ -111,13 +215,6 @@ var AuthMagicLinkAddon = class extends require_hub_browser_origin.BaseAddon {
111
215
  description: "Passwordless email login. The user types an email; the hub mails them a one-time link valid for the configured TTL.",
112
216
  columns: 1,
113
217
  fields: [
114
- this.field({
115
- type: "boolean",
116
- key: "enabled",
117
- label: "Enabled",
118
- description: "Offer the magic-link button on the login page. Leave off until email delivery is configured.",
119
- default: DEFAULT_CONFIG.enabled
120
- }),
121
218
  this.field({
122
219
  type: "text",
123
220
  key: "displayName",
@@ -125,9 +222,10 @@ var AuthMagicLinkAddon = class extends require_hub_browser_origin.BaseAddon {
125
222
  default: DEFAULT_CONFIG.displayName
126
223
  }),
127
224
  this.field({
128
- type: "text",
225
+ type: "icon",
129
226
  key: "icon",
130
227
  label: "Icon",
228
+ description: "Shown on the login button.",
131
229
  default: DEFAULT_CONFIG.icon
132
230
  }),
133
231
  this.field({
@@ -141,7 +239,7 @@ var AuthMagicLinkAddon = class extends require_hub_browser_origin.BaseAddon {
141
239
  type: "select",
142
240
  key: "deliveryMode",
143
241
  label: "Delivery mode",
144
- description: "log-only writes the URL to the addon log (setup only). smtp uses the configured SMTP relay.",
242
+ description: "log-only records the request in the addon log but never the link (it is a login credential). smtp uses the configured SMTP relay.",
145
243
  default: DEFAULT_CONFIG.deliveryMode,
146
244
  options: [{
147
245
  value: "log-only",
@@ -278,15 +376,15 @@ var AuthMagicLinkAddon = class extends require_hub_browser_origin.BaseAddon {
278
376
  reply.send({ error: "sso-bridge capability unavailable" });
279
377
  return;
280
378
  }
281
- const link = `${await this.buildPublicOrigin()}/addon/auth-magic-link/login?bridge=${encodeURIComponent(token)}`;
282
- if (this.config.deliveryMode === "log-only") this.ctx.logger.warn("Magic link generated (log-only mode — NOT delivered via email)", { meta: {
379
+ `${await this.buildPublicOrigin()}${encodeURIComponent(token)}`;
380
+ if (this.config.deliveryMode === "log-only") this.ctx.logger.warn("Magic link generated — not delivered (log-only mode, link withheld)", { meta: {
283
381
  email,
284
- link,
285
- ttlSec: this.config.ttlSec
382
+ ttlSec: this.config.ttlSec,
383
+ delivered: false
286
384
  } });
287
- else this.ctx.logger.warn("Magic link delivery: SMTP mode requested but no smtp-provider cap registered. Falling back to log.", { meta: {
385
+ else this.ctx.logger.warn("Magic link not delivered: SMTP mode requested but no smtp-provider cap registered", { meta: {
288
386
  email,
289
- link
387
+ delivered: false
290
388
  } });
291
389
  reply.code(200);
292
390
  reply.send(ok);
@@ -1,4 +1,38 @@
1
- import { a as buildAddonRouteProvider, c as errMsg, i as authProviderCapability, l as BaseAddon, n as addonRoutesCapability, o as loginMethodCapability, t as hubBrowserOrigin } from "../hub-browser-origin-B1o5vmQX.mjs";
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";
2
+ //#region src/magic-link/retire-enabled-flag.ts
3
+ /** The retired settings key. */
4
+ var RETIRED_ENABLED_KEY = "enabled";
5
+ /** Store key marking the retirement as done. Not a schema field. */
6
+ var RETIREMENT_MARKER_KEY = "enabledFlagRetired";
7
+ function deferred(reason) {
8
+ return {
9
+ kind: "deferred",
10
+ reason
11
+ };
12
+ }
13
+ function isMarked(read) {
14
+ return read.kind === "present" && read.value === true;
15
+ }
16
+ async function retireEnabledFlag(deps) {
17
+ const marker = await deps.readStoredKey(RETIREMENT_MARKER_KEY);
18
+ if (marker.kind === "unreadable") return deferred("settings store unreadable (marker)");
19
+ if (isMarked(marker)) return { kind: "already-retired" };
20
+ const flag = await deps.readStoredKey(RETIRED_ENABLED_KEY);
21
+ if (flag.kind === "unreadable") return deferred("settings store unreadable (enabled)");
22
+ const wasOn = flag.kind === "present" && flag.value === true;
23
+ if (!wasOn) try {
24
+ await deps.disableAtCapability();
25
+ } catch (err) {
26
+ return deferred(`capability toggle refused: ${err instanceof Error ? err.message : String(err)}`);
27
+ }
28
+ await deps.commit();
29
+ if (!isMarked(await deps.readStoredKey("enabledFlagRetired"))) return deferred("retirement write did not land");
30
+ return {
31
+ kind: "retired",
32
+ capDisabled: !wasOn
33
+ };
34
+ }
35
+ //#endregion
2
36
  //#region src/magic-link/auth-magic-link.addon.ts
3
37
  /**
4
38
  * Magic-link authentication addon.
@@ -13,10 +47,10 @@ import { a as buildAddonRouteProvider, c as errMsg, i as authProviderCapability,
13
47
  * 3. Email click hits `/login?bridge=…` → 302 to `/api/auth/sso/finish?bridge=…`
14
48
  * → hub verifies the bridge token and mints the real session.
15
49
  *
16
- * Delivery is pluggable. This addon ships a "log-only" mode by default
17
- * (writes the URL to the addon log so operators can copy it during
18
- * setup before SMTP is configured). Production deployments configure
19
- * the SMTP relay via global settings.
50
+ * Delivery is pluggable. This addon ships a "log-only" mode by default,
51
+ * which records that a link was requested but never writes the link
52
+ * itself (it is a login credential — D783). Production deployments
53
+ * configure the SMTP relay via global settings.
20
54
  *
21
55
  * Open follow-ups:
22
56
  * - Wire `nodemailer` for real SMTP delivery (peer dep; the framework
@@ -24,16 +58,30 @@ import { a as buildAddonRouteProvider, c as errMsg, i as authProviderCapability,
24
58
  * - Throttle requests per-email (rate-limit window).
25
59
  * - Auto-provision unknown emails (currently rejected for safety).
26
60
  */
61
+ /** Capability whose per-provider toggle is magic-link's ONE enable (Identity → Authentication). */
62
+ var GOVERNING_CAPABILITY = "auth-provider";
63
+ /** Backoff between retirement attempts — the first ones are refused until the
64
+ * provider registration reaches the hub. After the last, the next boot retries. */
65
+ var RETIREMENT_RETRY_DELAYS_MS = [
66
+ 1e3,
67
+ 2e3,
68
+ 5e3,
69
+ 1e4,
70
+ 3e4,
71
+ 6e4
72
+ ];
27
73
  var DEFAULT_CONFIG = {
28
- enabled: false,
29
74
  displayName: "Email magic link",
30
- icon: "mail",
75
+ icon: "mailbox",
31
76
  publicOrigin: "",
32
77
  deliveryMode: "log-only",
33
78
  defaultRole: "viewer",
34
79
  ttlSec: 600
35
80
  };
36
81
  var AuthMagicLinkAddon = class extends BaseAddon {
82
+ enabledFlagRetired = false;
83
+ retirementTimer = null;
84
+ shuttingDown = false;
37
85
  constructor() {
38
86
  super({ ...DEFAULT_CONFIG });
39
87
  }
@@ -73,6 +121,8 @@ var AuthMagicLinkAddon = class extends BaseAddon {
73
121
  ]);
74
122
  const loginMethodProvider = { getLoginMethods: async () => this.buildLoginMethods() };
75
123
  this.ctx.logger.info("Magic-link auth provider initialized", { meta: { deliveryMode: this.config.deliveryMode } });
124
+ this.shuttingDown = false;
125
+ this.attemptEnabledFlagRetirement(0);
76
126
  return [
77
127
  {
78
128
  capability: authProviderCapability,
@@ -89,16 +139,70 @@ var AuthMagicLinkAddon = class extends BaseAddon {
89
139
  ];
90
140
  }
91
141
  buildLoginMethods() {
92
- if (!this.config.enabled) return [];
142
+ if (!this.enabledFlagRetired) return [];
93
143
  return [{
94
144
  kind: "redirect",
95
145
  id: "auth-magic-link",
96
146
  label: this.config.displayName || DEFAULT_CONFIG.displayName,
97
- icon: this.config.icon || DEFAULT_CONFIG.icon,
147
+ icon: isDisplayIconKey(this.config.icon) ? this.config.icon : DEFAULT_CONFIG.icon,
98
148
  startUrl: "/addon/auth-magic-link/start",
99
149
  stage: "primary"
100
150
  }];
101
151
  }
152
+ async onShutdown() {
153
+ this.shuttingDown = true;
154
+ if (this.retirementTimer !== null) clearTimeout(this.retirementTimer);
155
+ this.retirementTimer = null;
156
+ }
157
+ /** One attempt of the D777 carry-over; reschedules itself while deferred. */
158
+ async attemptEnabledFlagRetirement(attempt) {
159
+ if (this.shuttingDown) return;
160
+ let outcome;
161
+ try {
162
+ outcome = await retireEnabledFlag({
163
+ readStoredKey: (key) => this.readStoredGlobalKey(key),
164
+ disableAtCapability: async () => {
165
+ await this.ctx.api.addons.setCapabilityProviderEnabled.mutate({
166
+ capName: GOVERNING_CAPABILITY,
167
+ addonId: this.ctx.id,
168
+ enabled: false
169
+ });
170
+ },
171
+ commit: async () => {
172
+ const patch = {
173
+ [RETIRED_ENABLED_KEY]: void 0,
174
+ [RETIREMENT_MARKER_KEY]: true
175
+ };
176
+ await this.updateGlobalSettings(patch);
177
+ }
178
+ });
179
+ } catch (err) {
180
+ outcome = {
181
+ kind: "deferred",
182
+ reason: errMsg(err)
183
+ };
184
+ }
185
+ if (outcome.kind !== "deferred") {
186
+ this.enabledFlagRetired = true;
187
+ if (outcome.kind === "retired") this.ctx.logger.info(outcome.capDisabled ? "Magic-link: the retired `enabled` setting was off — the provider is now DISABLED on Identity → Authentication; enable it there to offer magic-link login." : "Magic-link: the retired `enabled` setting was on — the provider stays enabled on Identity → Authentication.");
188
+ return;
189
+ }
190
+ const delay = RETIREMENT_RETRY_DELAYS_MS[attempt];
191
+ if (delay === void 0) {
192
+ this.ctx.logger.warn("Magic-link: carrying the retired `enabled` setting over to the capability toggle failed — login button withheld until the next boot", { meta: { reason: outcome.reason } });
193
+ return;
194
+ }
195
+ this.ctx.logger.debug("Magic-link: enabled-flag retirement deferred", { meta: {
196
+ reason: outcome.reason,
197
+ attempt,
198
+ retryInMs: delay
199
+ } });
200
+ this.retirementTimer = setTimeout(() => {
201
+ this.retirementTimer = null;
202
+ this.attemptEnabledFlagRetirement(attempt + 1);
203
+ }, delay);
204
+ this.retirementTimer.unref?.();
205
+ }
102
206
  globalSettingsSchema() {
103
207
  return this.schema({ sections: [{
104
208
  id: "magic-link",
@@ -106,13 +210,6 @@ var AuthMagicLinkAddon = class extends BaseAddon {
106
210
  description: "Passwordless email login. The user types an email; the hub mails them a one-time link valid for the configured TTL.",
107
211
  columns: 1,
108
212
  fields: [
109
- this.field({
110
- type: "boolean",
111
- key: "enabled",
112
- label: "Enabled",
113
- description: "Offer the magic-link button on the login page. Leave off until email delivery is configured.",
114
- default: DEFAULT_CONFIG.enabled
115
- }),
116
213
  this.field({
117
214
  type: "text",
118
215
  key: "displayName",
@@ -120,9 +217,10 @@ var AuthMagicLinkAddon = class extends BaseAddon {
120
217
  default: DEFAULT_CONFIG.displayName
121
218
  }),
122
219
  this.field({
123
- type: "text",
220
+ type: "icon",
124
221
  key: "icon",
125
222
  label: "Icon",
223
+ description: "Shown on the login button.",
126
224
  default: DEFAULT_CONFIG.icon
127
225
  }),
128
226
  this.field({
@@ -136,7 +234,7 @@ var AuthMagicLinkAddon = class extends BaseAddon {
136
234
  type: "select",
137
235
  key: "deliveryMode",
138
236
  label: "Delivery mode",
139
- description: "log-only writes the URL to the addon log (setup only). smtp uses the configured SMTP relay.",
237
+ description: "log-only records the request in the addon log but never the link (it is a login credential). smtp uses the configured SMTP relay.",
140
238
  default: DEFAULT_CONFIG.deliveryMode,
141
239
  options: [{
142
240
  value: "log-only",
@@ -273,15 +371,15 @@ var AuthMagicLinkAddon = class extends BaseAddon {
273
371
  reply.send({ error: "sso-bridge capability unavailable" });
274
372
  return;
275
373
  }
276
- const link = `${await this.buildPublicOrigin()}/addon/auth-magic-link/login?bridge=${encodeURIComponent(token)}`;
277
- if (this.config.deliveryMode === "log-only") this.ctx.logger.warn("Magic link generated (log-only mode — NOT delivered via email)", { meta: {
374
+ `${await this.buildPublicOrigin()}${encodeURIComponent(token)}`;
375
+ if (this.config.deliveryMode === "log-only") this.ctx.logger.warn("Magic link generated — not delivered (log-only mode, link withheld)", { meta: {
278
376
  email,
279
- link,
280
- ttlSec: this.config.ttlSec
377
+ ttlSec: this.config.ttlSec,
378
+ delivered: false
281
379
  } });
282
- else this.ctx.logger.warn("Magic link delivery: SMTP mode requested but no smtp-provider cap registered. Falling back to log.", { meta: {
380
+ else this.ctx.logger.warn("Magic link not delivered: SMTP mode requested but no smtp-provider cap registered", { meta: {
283
381
  email,
284
- link
382
+ delivered: false
285
383
  } });
286
384
  reply.code(200);
287
385
  reply.send(ok);
@@ -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_hub_browser_origin = require("../hub-browser-origin-CCWSbHYb.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
@@ -733,9 +733,14 @@ function createRemoteJWKSet(url, options) {
733
733
  * — the hub verifies it with the same `jwtSecret` and only then mints
734
734
  * the user session JWT.
735
735
  *
736
+ * Identity mapping is EXPLICIT LINKING only (D782): the bridge names the
737
+ * identity `oidc:<instance>:<sub>`; the hub signs in the user that identity
738
+ * was linked to, and refuses an unlinked one. A flow started by a signed-in
739
+ * user's "Link" button carries the hub's opaque `?link=` handle through the
740
+ * pending state into the bridge (`linkIntent`); the hub resolves it.
741
+ *
736
742
  * TODO (next session):
737
743
  * - RP-initiated logout (`end_session_endpoint` 302 with id_token_hint).
738
- * - Groups → scope mapping via id_token `groups` claim.
739
744
  * - Refresh token rotation (offline_access).
740
745
  * - Persist state + nonce + PKCE across restart (multi-replica).
741
746
  */
@@ -748,11 +753,24 @@ var DEFAULT_PROVIDER = {
748
753
  clientSecret: "",
749
754
  redirectUri: "",
750
755
  scopes: "openid profile email",
751
- usernameClaim: "preferred_username",
752
- defaultRole: "viewer"
756
+ usernameClaim: "preferred_username"
753
757
  };
754
758
  var DEFAULT_CONFIG = { providers: [] };
755
759
  var STATE_TTL_MS = 5 * 6e4;
760
+ /**
761
+ * Shape of a hub link-intent handle (`?link=` on /start). Opaque to this
762
+ * addon — the hub resolves it, single-use, at the SSO finish. Validated only
763
+ * so a junk value is refused here rather than carried into a signed bridge.
764
+ */
765
+ var LINK_INTENT_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
766
+ /**
767
+ * An instance id appears in URLs and in the identity `oidc:<id>:<sub>`; a `:`
768
+ * would make two identities read the same, so ids are slugs. A configured row
769
+ * whose id is not one is skipped.
770
+ */
771
+ var INSTANCE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
772
+ /** Bound on in-flight logins: `/start` is public, so a flood must not grow the map. */
773
+ var MAX_PENDING_STATES = 1e3;
756
774
  var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
757
775
  /** Per-provider runtime state, keyed by `provider.id`. */
758
776
  instances = /* @__PURE__ */ new Map();
@@ -822,9 +840,10 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
822
840
  kind: "redirect",
823
841
  id: `auth-oidc/${id}`,
824
842
  label: inst.config.displayName || DEFAULT_PROVIDER.displayName,
825
- icon: inst.config.icon || DEFAULT_PROVIDER.icon,
843
+ icon: require_hub_browser_origin.isDisplayIconKey(inst.config.icon) ? inst.config.icon : DEFAULT_PROVIDER.icon,
826
844
  startUrl: `/addon/auth-oidc/${id}/start`,
827
- stage: "primary"
845
+ stage: "primary",
846
+ linkable: true
828
847
  });
829
848
  }
830
849
  return out;
@@ -857,11 +876,11 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
857
876
  default: "OpenID Connect"
858
877
  },
859
878
  {
860
- type: "text",
879
+ type: "icon",
861
880
  key: "icon",
862
881
  label: "Icon",
863
- description: "lucide-react icon name (default: shield-check).",
864
- default: "shield-check"
882
+ description: "Shown on the login button.",
883
+ default: DEFAULT_PROVIDER.icon
865
884
  },
866
885
  {
867
886
  type: "text",
@@ -877,7 +896,7 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
877
896
  default: ""
878
897
  },
879
898
  {
880
- type: "text",
899
+ type: "password",
881
900
  key: "clientSecret",
882
901
  label: "Client Secret",
883
902
  default: ""
@@ -915,20 +934,6 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
915
934
  label: "sub (user id)"
916
935
  }
917
936
  ]
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
937
  }
933
938
  ]
934
939
  })]
@@ -942,7 +947,10 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
942
947
  ...DEFAULT_PROVIDER,
943
948
  ...raw
944
949
  };
945
- if (!merged.id || !merged.id.trim()) continue;
950
+ if (!merged.id || !INSTANCE_ID_PATTERN.test(merged.id)) {
951
+ if (merged.id) this.ctx.logger.warn("OIDC: provider instance skipped — id must be a lowercase slug", { meta: { id: merged.id } });
952
+ continue;
953
+ }
946
954
  this.instances.set(merged.id, {
947
955
  config: merged,
948
956
  discovery: null,
@@ -982,7 +990,7 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
982
990
  if (configured) return configured;
983
991
  return `${await require_hub_browser_origin.hubBrowserOrigin(this.ctx.api.localNetwork, this.ctx.logger) ?? "https://localhost:4443"}/addon/auth-oidc/${providerId}/callback`;
984
992
  }
985
- async buildLoginUrl(providerId, state) {
993
+ async buildLoginUrl(providerId, state, linkIntent) {
986
994
  const inst = this.instances.get(providerId);
987
995
  if (!inst) throw new Error(`OIDC: no provider configured with id "${providerId}"`);
988
996
  const doc = await this.getDiscovery(providerId);
@@ -991,12 +999,18 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
991
999
  const nonce = node_crypto.randomBytes(16).toString("base64url");
992
1000
  const redirectUri = await this.buildRedirectUri(providerId);
993
1001
  this.pruneExpiredStates();
1002
+ while (this.pendingStates.size >= MAX_PENDING_STATES) {
1003
+ const oldest = this.pendingStates.keys().next();
1004
+ if (oldest.done) break;
1005
+ this.pendingStates.delete(oldest.value);
1006
+ }
994
1007
  this.pendingStates.set(state, {
995
1008
  codeVerifier,
996
1009
  nonce,
997
1010
  providerId,
998
1011
  redirectUri,
999
- createdAt: Date.now()
1012
+ createdAt: Date.now(),
1013
+ ...linkIntent !== void 0 ? { linkIntent } : {}
1000
1014
  });
1001
1015
  const params = new URLSearchParams({
1002
1016
  response_type: "code",
@@ -1066,7 +1080,10 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
1066
1080
  username: String(username),
1067
1081
  ...emailClaim ? { email: emailClaim } : {},
1068
1082
  ...nameClaim ? { displayName: nameClaim } : {},
1069
- isAdmin: inst.config.defaultRole === "admin"
1083
+ isAdmin: false,
1084
+ ...pending.linkIntent !== void 0 ? { linkIntent: pending.linkIntent } : {},
1085
+ issuer: doc.issuer,
1086
+ providerId
1070
1087
  };
1071
1088
  }
1072
1089
  validateLocalSessionToken(_token) {
@@ -1079,9 +1096,15 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
1079
1096
  reply.send({ error: `Unknown OIDC provider: ${providerId}` });
1080
1097
  return;
1081
1098
  }
1099
+ const linkParam = req.query["link"];
1100
+ if (linkParam !== void 0 && !LINK_INTENT_PATTERN.test(linkParam)) {
1101
+ reply.code(400);
1102
+ reply.send({ error: "Malformed link request" });
1103
+ return;
1104
+ }
1082
1105
  const state = node_crypto.randomBytes(16).toString("base64url");
1083
1106
  try {
1084
- const url = await this.buildLoginUrl(providerId, state);
1107
+ const url = await this.buildLoginUrl(providerId, state, linkParam);
1085
1108
  reply.code(302);
1086
1109
  reply.header("Location", url);
1087
1110
  reply.send("");
@@ -1120,13 +1143,20 @@ var AuthOidcAddon = class extends require_hub_browser_origin.BaseAddon {
1120
1143
  }
1121
1144
  try {
1122
1145
  const result = await this.handleCallback(code, state);
1146
+ if (result.providerId !== providerId) {
1147
+ reply.code(400);
1148
+ reply.send({ error: "OIDC callback does not match the provider the login started at" });
1149
+ return;
1150
+ }
1123
1151
  const { token: bridgeToken } = await this.ctx.api.ssoBridge.signBridgeToken.query({ claims: {
1124
1152
  userId: result.userId,
1125
1153
  username: result.username,
1126
1154
  isAdmin: result.isAdmin,
1127
1155
  provider: `auth-oidc/${providerId}`,
1128
1156
  ...result.email ? { email: result.email } : {},
1129
- ...result.displayName ? { displayName: result.displayName } : {}
1157
+ ...result.displayName ? { displayName: result.displayName } : {},
1158
+ ...result.linkIntent !== void 0 ? { linkIntent: result.linkIntent } : {},
1159
+ issuer: result.issuer
1130
1160
  } });
1131
1161
  reply.code(302);
1132
1162
  reply.header("Location", `/api/auth/sso/finish?bridge=${encodeURIComponent(bridgeToken)}`);