@camstack/addon-auth 1.1.3 → 1.1.5

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.
@@ -7052,6 +7052,123 @@ var ConvertResultSchema = object({
7052
7052
  })).readonly()
7053
7053
  });
7054
7054
  /**
7055
+ * Build an `IAddonRouteProvider` from a list of routes. Implements
7056
+ * both the operator-facing `getRoutes` (returning route descriptors
7057
+ * minus the handlers, which can't cross JSON) and the framework-
7058
+ * private `invoke` method that the hub calls when this provider lives
7059
+ * in a forked worker.
7060
+ *
7061
+ * Co-located addons use the returned `getRoutes` directly because
7062
+ * their handlers don't need to cross any wire. The `invoke` method
7063
+ * is present anyway so the bridge code on the hub is uniform — it
7064
+ * doesn't need to switch on "local vs remote provider" at the call
7065
+ * site.
7066
+ *
7067
+ * Example:
7068
+ * const routes: IAddonHttpRoute[] = [
7069
+ * { method: 'GET', path: '/start', access: 'public', handler: this.handleStart },
7070
+ * ]
7071
+ * return [
7072
+ * {
7073
+ * capability: addonRoutesCapability,
7074
+ * provider: buildAddonRouteProvider('auth-oidc', routes),
7075
+ * },
7076
+ * ]
7077
+ */
7078
+ function buildAddonRouteProvider(id, routes) {
7079
+ return {
7080
+ id,
7081
+ getRoutes: () => routes,
7082
+ invoke: async (input) => {
7083
+ const match = matchRoute(routes, input.method, input.path);
7084
+ if (!match) return {
7085
+ status: 404,
7086
+ headers: {},
7087
+ redirectUrl: null,
7088
+ body: { error: `No route matches ${input.method} ${input.path}` }
7089
+ };
7090
+ const envelope = {
7091
+ status: 200,
7092
+ headers: {},
7093
+ redirectUrl: null
7094
+ };
7095
+ const reply = buildCapturingReply(envelope);
7096
+ const request = {
7097
+ params: {
7098
+ ...input.params,
7099
+ ...match.params
7100
+ },
7101
+ query: input.query,
7102
+ body: input.body,
7103
+ headers: input.headers,
7104
+ ...input.user ? { user: input.user } : {},
7105
+ ...input.scopedToken !== void 0 ? { scopedToken: input.scopedToken } : {}
7106
+ };
7107
+ await match.route.handler(request, reply);
7108
+ return envelope;
7109
+ }
7110
+ };
7111
+ }
7112
+ /**
7113
+ * Pattern matcher: same semantics as `AddonRouteRegistry.matchRoute`
7114
+ * but operating on a flat list and bypassing the `/addon/<id>/` prefix
7115
+ * — the bridge sends the post-prefix path directly so we don't need
7116
+ * to round-trip it through normalization.
7117
+ */
7118
+ function matchRoute(routes, method, path) {
7119
+ const normalizedMethod = method.toUpperCase();
7120
+ for (const route of routes) {
7121
+ if (route.method !== normalizedMethod) continue;
7122
+ const params = matchPath(route.path, path);
7123
+ if (params !== null) return {
7124
+ route,
7125
+ params
7126
+ };
7127
+ }
7128
+ return null;
7129
+ }
7130
+ function matchPath(pattern, p) {
7131
+ const patternParts = pattern.split("/").filter(Boolean);
7132
+ const pathParts = p.split("/").filter(Boolean);
7133
+ if (patternParts.length !== pathParts.length) return null;
7134
+ const params = {};
7135
+ for (let i = 0; i < patternParts.length; i++) {
7136
+ const a = patternParts[i];
7137
+ const b = pathParts[i];
7138
+ if (a.startsWith(":")) params[a.slice(1)] = b;
7139
+ else if (a !== b) return null;
7140
+ }
7141
+ return params;
7142
+ }
7143
+ function buildCapturingReply(envelope) {
7144
+ const wrapper = {
7145
+ status(code) {
7146
+ envelope.status = code;
7147
+ return wrapper;
7148
+ },
7149
+ code(code) {
7150
+ envelope.status = code;
7151
+ return wrapper;
7152
+ },
7153
+ send(data) {
7154
+ envelope.body = data;
7155
+ },
7156
+ redirect(url) {
7157
+ envelope.redirectUrl = url;
7158
+ if (envelope.status === 200) envelope.status = 302;
7159
+ },
7160
+ header(name, value) {
7161
+ envelope.headers[name.toLowerCase()] = value;
7162
+ return wrapper;
7163
+ },
7164
+ type(mime) {
7165
+ envelope.contentType = mime;
7166
+ return wrapper;
7167
+ }
7168
+ };
7169
+ return wrapper;
7170
+ }
7171
+ /**
7055
7172
  * Numeric day-of-week: 0 = Sunday … 6 = Saturday (matches `Date.getDay`).
7056
7173
  * Named `RecordingWeekday` to avoid collision with the string-union
7057
7174
  * `Weekday` exported from `interfaces/timezones.ts`.
@@ -16284,6 +16401,19 @@ method(_void(), array(TurnServerSchema).readonly());
16284
16401
  * b. `finishAuthentication({userId, response})` → server verifies
16285
16402
  * the assertion, bumps the credential counter, returns ok.
16286
16403
  *
16404
+ * 2b. Usernameless (discoverable-credential) authentication — the
16405
+ * passkey IS the primary factor, no password leg:
16406
+ * a. `beginDiscoverableAuthentication({})` → assertion options with
16407
+ * EMPTY `allowCredentials` (the browser offers every resident
16408
+ * passkey it holds for this RP) + `userVerification: 'required'`
16409
+ * (the passkey replaces both factors, so UV is mandatory).
16410
+ * The challenge is stored server-side, NOT bound to any user.
16411
+ * b. `finishDiscoverableAuthentication({response})` → the provider
16412
+ * resolves the credential by the response's credential id,
16413
+ * verifies the assertion against the stored challenge + that
16414
+ * credential's public key/counter, and returns the OWNING
16415
+ * `userId` — the caller (core auth router) mints the session.
16416
+ *
16287
16417
  * 3. Management:
16288
16418
  * - `listPasskeys({userId})` — enumerate user's enrolled credentials.
16289
16419
  * - `removePasskey({userId, credentialId})` — revoke one credential.
@@ -16340,6 +16470,19 @@ var userPasskeysCapability = {
16340
16470
  kind: "mutation",
16341
16471
  access: "view"
16342
16472
  }),
16473
+ beginDiscoverableAuthentication: method(object({}), object({ optionsJSON: record(string(), unknown()) }), {
16474
+ kind: "mutation",
16475
+ access: "view"
16476
+ }),
16477
+ finishDiscoverableAuthentication: method(object({
16478
+ /** AuthenticationResponseJSON from the browser. */
16479
+ response: record(string(), unknown()) }), object({
16480
+ verified: boolean(),
16481
+ userId: string().nullable()
16482
+ }), {
16483
+ kind: "mutation",
16484
+ access: "view"
16485
+ }),
16343
16486
  listPasskeys: method(object({ userId: string() }), array(PasskeySummarySchema), { auth: "admin" }),
16344
16487
  removePasskey: method(object({
16345
16488
  userId: string(),
@@ -22842,6 +22985,12 @@ Object.freeze({
22842
22985
  addonId: null,
22843
22986
  access: "view"
22844
22987
  },
22988
+ "userPasskeys.beginDiscoverableAuthentication": {
22989
+ capName: "user-passkeys",
22990
+ capScope: "system",
22991
+ addonId: null,
22992
+ access: "view"
22993
+ },
22845
22994
  "userPasskeys.beginRegistration": {
22846
22995
  capName: "user-passkeys",
22847
22996
  capScope: "system",
@@ -22854,6 +23003,12 @@ Object.freeze({
22854
23003
  addonId: null,
22855
23004
  access: "view"
22856
23005
  },
23006
+ "userPasskeys.finishDiscoverableAuthentication": {
23007
+ capName: "user-passkeys",
23008
+ capScope: "system",
23009
+ addonId: null,
23010
+ access: "view"
23011
+ },
22857
23012
  "userPasskeys.finishRegistration": {
22858
23013
  capName: "user-passkeys",
22859
23014
  capScope: "system",
@@ -23124,4 +23279,4 @@ object({
23124
23279
  schemaVersion: literal(1)
23125
23280
  });
23126
23281
  //#endregion
23127
- export { userPasskeysCapability as a, array as c, loginMethodCapability as i, object as l, addonWidgetsSourceCapability as n, errMsg as o, authProviderCapability as r, BaseAddon as s, addonRoutesCapability as t, string as u };
23282
+ export { loginMethodCapability as a, BaseAddon as c, string as d, buildAddonRouteProvider as i, array as l, addonWidgetsSourceCapability as n, userPasskeysCapability as o, authProviderCapability as r, errMsg as s, addonRoutesCapability as t, object as u };
@@ -7052,6 +7052,123 @@ var ConvertResultSchema = object({
7052
7052
  })).readonly()
7053
7053
  });
7054
7054
  /**
7055
+ * Build an `IAddonRouteProvider` from a list of routes. Implements
7056
+ * both the operator-facing `getRoutes` (returning route descriptors
7057
+ * minus the handlers, which can't cross JSON) and the framework-
7058
+ * private `invoke` method that the hub calls when this provider lives
7059
+ * in a forked worker.
7060
+ *
7061
+ * Co-located addons use the returned `getRoutes` directly because
7062
+ * their handlers don't need to cross any wire. The `invoke` method
7063
+ * is present anyway so the bridge code on the hub is uniform — it
7064
+ * doesn't need to switch on "local vs remote provider" at the call
7065
+ * site.
7066
+ *
7067
+ * Example:
7068
+ * const routes: IAddonHttpRoute[] = [
7069
+ * { method: 'GET', path: '/start', access: 'public', handler: this.handleStart },
7070
+ * ]
7071
+ * return [
7072
+ * {
7073
+ * capability: addonRoutesCapability,
7074
+ * provider: buildAddonRouteProvider('auth-oidc', routes),
7075
+ * },
7076
+ * ]
7077
+ */
7078
+ function buildAddonRouteProvider(id, routes) {
7079
+ return {
7080
+ id,
7081
+ getRoutes: () => routes,
7082
+ invoke: async (input) => {
7083
+ const match = matchRoute(routes, input.method, input.path);
7084
+ if (!match) return {
7085
+ status: 404,
7086
+ headers: {},
7087
+ redirectUrl: null,
7088
+ body: { error: `No route matches ${input.method} ${input.path}` }
7089
+ };
7090
+ const envelope = {
7091
+ status: 200,
7092
+ headers: {},
7093
+ redirectUrl: null
7094
+ };
7095
+ const reply = buildCapturingReply(envelope);
7096
+ const request = {
7097
+ params: {
7098
+ ...input.params,
7099
+ ...match.params
7100
+ },
7101
+ query: input.query,
7102
+ body: input.body,
7103
+ headers: input.headers,
7104
+ ...input.user ? { user: input.user } : {},
7105
+ ...input.scopedToken !== void 0 ? { scopedToken: input.scopedToken } : {}
7106
+ };
7107
+ await match.route.handler(request, reply);
7108
+ return envelope;
7109
+ }
7110
+ };
7111
+ }
7112
+ /**
7113
+ * Pattern matcher: same semantics as `AddonRouteRegistry.matchRoute`
7114
+ * but operating on a flat list and bypassing the `/addon/<id>/` prefix
7115
+ * — the bridge sends the post-prefix path directly so we don't need
7116
+ * to round-trip it through normalization.
7117
+ */
7118
+ function matchRoute(routes, method, path) {
7119
+ const normalizedMethod = method.toUpperCase();
7120
+ for (const route of routes) {
7121
+ if (route.method !== normalizedMethod) continue;
7122
+ const params = matchPath(route.path, path);
7123
+ if (params !== null) return {
7124
+ route,
7125
+ params
7126
+ };
7127
+ }
7128
+ return null;
7129
+ }
7130
+ function matchPath(pattern, p) {
7131
+ const patternParts = pattern.split("/").filter(Boolean);
7132
+ const pathParts = p.split("/").filter(Boolean);
7133
+ if (patternParts.length !== pathParts.length) return null;
7134
+ const params = {};
7135
+ for (let i = 0; i < patternParts.length; i++) {
7136
+ const a = patternParts[i];
7137
+ const b = pathParts[i];
7138
+ if (a.startsWith(":")) params[a.slice(1)] = b;
7139
+ else if (a !== b) return null;
7140
+ }
7141
+ return params;
7142
+ }
7143
+ function buildCapturingReply(envelope) {
7144
+ const wrapper = {
7145
+ status(code) {
7146
+ envelope.status = code;
7147
+ return wrapper;
7148
+ },
7149
+ code(code) {
7150
+ envelope.status = code;
7151
+ return wrapper;
7152
+ },
7153
+ send(data) {
7154
+ envelope.body = data;
7155
+ },
7156
+ redirect(url) {
7157
+ envelope.redirectUrl = url;
7158
+ if (envelope.status === 200) envelope.status = 302;
7159
+ },
7160
+ header(name, value) {
7161
+ envelope.headers[name.toLowerCase()] = value;
7162
+ return wrapper;
7163
+ },
7164
+ type(mime) {
7165
+ envelope.contentType = mime;
7166
+ return wrapper;
7167
+ }
7168
+ };
7169
+ return wrapper;
7170
+ }
7171
+ /**
7055
7172
  * Numeric day-of-week: 0 = Sunday … 6 = Saturday (matches `Date.getDay`).
7056
7173
  * Named `RecordingWeekday` to avoid collision with the string-union
7057
7174
  * `Weekday` exported from `interfaces/timezones.ts`.
@@ -16284,6 +16401,19 @@ method(_void(), array(TurnServerSchema).readonly());
16284
16401
  * b. `finishAuthentication({userId, response})` → server verifies
16285
16402
  * the assertion, bumps the credential counter, returns ok.
16286
16403
  *
16404
+ * 2b. Usernameless (discoverable-credential) authentication — the
16405
+ * passkey IS the primary factor, no password leg:
16406
+ * a. `beginDiscoverableAuthentication({})` → assertion options with
16407
+ * EMPTY `allowCredentials` (the browser offers every resident
16408
+ * passkey it holds for this RP) + `userVerification: 'required'`
16409
+ * (the passkey replaces both factors, so UV is mandatory).
16410
+ * The challenge is stored server-side, NOT bound to any user.
16411
+ * b. `finishDiscoverableAuthentication({response})` → the provider
16412
+ * resolves the credential by the response's credential id,
16413
+ * verifies the assertion against the stored challenge + that
16414
+ * credential's public key/counter, and returns the OWNING
16415
+ * `userId` — the caller (core auth router) mints the session.
16416
+ *
16287
16417
  * 3. Management:
16288
16418
  * - `listPasskeys({userId})` — enumerate user's enrolled credentials.
16289
16419
  * - `removePasskey({userId, credentialId})` — revoke one credential.
@@ -16340,6 +16470,19 @@ var userPasskeysCapability = {
16340
16470
  kind: "mutation",
16341
16471
  access: "view"
16342
16472
  }),
16473
+ beginDiscoverableAuthentication: method(object({}), object({ optionsJSON: record(string(), unknown()) }), {
16474
+ kind: "mutation",
16475
+ access: "view"
16476
+ }),
16477
+ finishDiscoverableAuthentication: method(object({
16478
+ /** AuthenticationResponseJSON from the browser. */
16479
+ response: record(string(), unknown()) }), object({
16480
+ verified: boolean(),
16481
+ userId: string().nullable()
16482
+ }), {
16483
+ kind: "mutation",
16484
+ access: "view"
16485
+ }),
16343
16486
  listPasskeys: method(object({ userId: string() }), array(PasskeySummarySchema), { auth: "admin" }),
16344
16487
  removePasskey: method(object({
16345
16488
  userId: string(),
@@ -22842,6 +22985,12 @@ Object.freeze({
22842
22985
  addonId: null,
22843
22986
  access: "view"
22844
22987
  },
22988
+ "userPasskeys.beginDiscoverableAuthentication": {
22989
+ capName: "user-passkeys",
22990
+ capScope: "system",
22991
+ addonId: null,
22992
+ access: "view"
22993
+ },
22845
22994
  "userPasskeys.beginRegistration": {
22846
22995
  capName: "user-passkeys",
22847
22996
  capScope: "system",
@@ -22854,6 +23003,12 @@ Object.freeze({
22854
23003
  addonId: null,
22855
23004
  access: "view"
22856
23005
  },
23006
+ "userPasskeys.finishDiscoverableAuthentication": {
23007
+ capName: "user-passkeys",
23008
+ capScope: "system",
23009
+ addonId: null,
23010
+ access: "view"
23011
+ },
22857
23012
  "userPasskeys.finishRegistration": {
22858
23013
  capName: "user-passkeys",
22859
23014
  capScope: "system",
@@ -23154,6 +23309,12 @@ Object.defineProperty(exports, "authProviderCapability", {
23154
23309
  return authProviderCapability;
23155
23310
  }
23156
23311
  });
23312
+ Object.defineProperty(exports, "buildAddonRouteProvider", {
23313
+ enumerable: true,
23314
+ get: function() {
23315
+ return buildAddonRouteProvider;
23316
+ }
23317
+ });
23157
23318
  Object.defineProperty(exports, "errMsg", {
23158
23319
  enumerable: true,
23159
23320
  get: function() {
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../chunk-Cek0wNdY.js");
6
- const require_dist = require("../dist-mmJDfwXE.js");
6
+ const require_dist = require("../dist-biwV9TkV.js");
7
7
  //#region src/magic-link/auth-magic-link.addon.ts
8
8
  /**
9
9
  * Magic-link authentication addon.
@@ -30,6 +30,7 @@ const require_dist = require("../dist-mmJDfwXE.js");
30
30
  * - Auto-provision unknown emails (currently rejected for safety).
31
31
  */
32
32
  var DEFAULT_CONFIG = {
33
+ enabled: false,
33
34
  displayName: "Email magic link",
34
35
  icon: "mail",
35
36
  publicOrigin: "",
@@ -52,7 +53,7 @@ var AuthMagicLinkAddon = class extends require_dist.BaseAddon {
52
53
  throw new Error("Magic link does not use callback — the email link redirects to /api/auth/sso/finish directly");
53
54
  }
54
55
  };
55
- const routes = [
56
+ const routeProvider = require_dist.buildAddonRouteProvider("auth-magic-link", [
56
57
  {
57
58
  method: "GET",
58
59
  path: "/start",
@@ -74,11 +75,7 @@ var AuthMagicLinkAddon = class extends require_dist.BaseAddon {
74
75
  description: "Click target embedded in the magic-link email",
75
76
  handler: async (req, reply) => this.handleLogin(req, reply)
76
77
  }
77
- ];
78
- const routeProvider = {
79
- id: "auth-magic-link",
80
- getRoutes: () => routes
81
- };
78
+ ]);
82
79
  const loginMethodProvider = { getLoginMethods: async () => this.buildLoginMethods() };
83
80
  this.ctx.logger.info("Magic-link auth provider initialized", { meta: { deliveryMode: this.config.deliveryMode } });
84
81
  return [
@@ -97,6 +94,7 @@ var AuthMagicLinkAddon = class extends require_dist.BaseAddon {
97
94
  ];
98
95
  }
99
96
  buildLoginMethods() {
97
+ if (!this.config.enabled) return [];
100
98
  return [{
101
99
  kind: "redirect",
102
100
  id: "auth-magic-link",
@@ -113,6 +111,13 @@ var AuthMagicLinkAddon = class extends require_dist.BaseAddon {
113
111
  description: "Passwordless email login. The user types an email; the hub mails them a one-time link valid for the configured TTL.",
114
112
  columns: 1,
115
113
  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
+ }),
116
121
  this.field({
117
122
  type: "text",
118
123
  key: "displayName",
@@ -1,4 +1,4 @@
1
- import { i as loginMethodCapability, o as errMsg, r as authProviderCapability, s as BaseAddon, t as addonRoutesCapability } from "../dist-suALViLT.mjs";
1
+ import { a as loginMethodCapability, c as BaseAddon, i as buildAddonRouteProvider, r as authProviderCapability, s as errMsg, t as addonRoutesCapability } from "../dist-BopfLZ9P.mjs";
2
2
  //#region src/magic-link/auth-magic-link.addon.ts
3
3
  /**
4
4
  * Magic-link authentication addon.
@@ -25,6 +25,7 @@ import { i as loginMethodCapability, o as errMsg, r as authProviderCapability, s
25
25
  * - Auto-provision unknown emails (currently rejected for safety).
26
26
  */
27
27
  var DEFAULT_CONFIG = {
28
+ enabled: false,
28
29
  displayName: "Email magic link",
29
30
  icon: "mail",
30
31
  publicOrigin: "",
@@ -47,7 +48,7 @@ var AuthMagicLinkAddon = class extends BaseAddon {
47
48
  throw new Error("Magic link does not use callback — the email link redirects to /api/auth/sso/finish directly");
48
49
  }
49
50
  };
50
- const routes = [
51
+ const routeProvider = buildAddonRouteProvider("auth-magic-link", [
51
52
  {
52
53
  method: "GET",
53
54
  path: "/start",
@@ -69,11 +70,7 @@ var AuthMagicLinkAddon = class extends BaseAddon {
69
70
  description: "Click target embedded in the magic-link email",
70
71
  handler: async (req, reply) => this.handleLogin(req, reply)
71
72
  }
72
- ];
73
- const routeProvider = {
74
- id: "auth-magic-link",
75
- getRoutes: () => routes
76
- };
73
+ ]);
77
74
  const loginMethodProvider = { getLoginMethods: async () => this.buildLoginMethods() };
78
75
  this.ctx.logger.info("Magic-link auth provider initialized", { meta: { deliveryMode: this.config.deliveryMode } });
79
76
  return [
@@ -92,6 +89,7 @@ var AuthMagicLinkAddon = class extends BaseAddon {
92
89
  ];
93
90
  }
94
91
  buildLoginMethods() {
92
+ if (!this.config.enabled) return [];
95
93
  return [{
96
94
  kind: "redirect",
97
95
  id: "auth-magic-link",
@@ -108,6 +106,13 @@ var AuthMagicLinkAddon = class extends BaseAddon {
108
106
  description: "Passwordless email login. The user types an email; the hub mails them a one-time link valid for the configured TTL.",
109
107
  columns: 1,
110
108
  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
+ }),
111
116
  this.field({
112
117
  type: "text",
113
118
  key: "displayName",
@@ -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_dist = require("../dist-mmJDfwXE.js");
6
+ const require_dist = require("../dist-biwV9TkV.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
@@ -1237,7 +1237,7 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1237
1237
  return this.handleCallback(code, state);
1238
1238
  }
1239
1239
  };
1240
- const routes = [{
1240
+ const routeProvider = require_dist.buildAddonRouteProvider("auth-oidc", [{
1241
1241
  method: "GET",
1242
1242
  path: "/:providerId/start",
1243
1243
  access: "public",
@@ -1249,11 +1249,7 @@ var AuthOidcAddon = class extends require_dist.BaseAddon {
1249
1249
  access: "public",
1250
1250
  description: "OIDC redirect callback for a configured provider",
1251
1251
  handler: async (req, reply) => this.handleCallbackRoute(req, reply)
1252
- }];
1253
- const routeProvider = {
1254
- id: "auth-oidc",
1255
- getRoutes: () => routes
1256
- };
1252
+ }]);
1257
1253
  const loginMethodProvider = { getLoginMethods: async () => this.buildLoginMethods() };
1258
1254
  this.ctx.logger.info("OIDC auth provider initialized", { meta: { providers: [...this.instances.keys()] } });
1259
1255
  return [
@@ -1,4 +1,4 @@
1
- import { i as loginMethodCapability, o as errMsg, r as authProviderCapability, s as BaseAddon, t as addonRoutesCapability } from "../dist-suALViLT.mjs";
1
+ import { a as loginMethodCapability, c as BaseAddon, i as buildAddonRouteProvider, r as authProviderCapability, s as errMsg, t as addonRoutesCapability } from "../dist-BopfLZ9P.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();
@@ -1231,7 +1231,7 @@ var AuthOidcAddon = class extends BaseAddon {
1231
1231
  return this.handleCallback(code, state);
1232
1232
  }
1233
1233
  };
1234
- const routes = [{
1234
+ const routeProvider = buildAddonRouteProvider("auth-oidc", [{
1235
1235
  method: "GET",
1236
1236
  path: "/:providerId/start",
1237
1237
  access: "public",
@@ -1243,11 +1243,7 @@ var AuthOidcAddon = class extends BaseAddon {
1243
1243
  access: "public",
1244
1244
  description: "OIDC redirect callback for a configured provider",
1245
1245
  handler: async (req, reply) => this.handleCallbackRoute(req, reply)
1246
- }];
1247
- const routeProvider = {
1248
- id: "auth-oidc",
1249
- getRoutes: () => routes
1250
- };
1246
+ }]);
1251
1247
  const loginMethodProvider = { getLoginMethods: async () => this.buildLoginMethods() };
1252
1248
  this.ctx.logger.info("OIDC auth provider initialized", { meta: { providers: [...this.instances.keys()] } });
1253
1249
  return [
@@ -559,10 +559,76 @@ function H(e) {
559
559
  });
560
560
  }
561
561
  //#endregion
562
- //#region src/webauthn/widgets/index.tsx
562
+ //#region src/webauthn/widgets/direct-login-i18n.ts
563
563
  var U = {
564
+ en: {
565
+ signInWithPasskey: "Sign in with a passkey",
566
+ unsupported: "This browser does not support passkeys.",
567
+ failed: "Passkey sign-in failed"
568
+ },
569
+ it: {
570
+ signInWithPasskey: "Accedi con una passkey",
571
+ unsupported: "Questo browser non supporta le passkey.",
572
+ failed: "Accesso con passkey non riuscito"
573
+ }
574
+ };
575
+ function W(e) {
576
+ return (e ?? globalThis.navigator?.language ?? "en").toLowerCase().split("-")[0] === "it" ? "it" : "en";
577
+ }
578
+ function G(e, t) {
579
+ return U[e][t];
580
+ }
581
+ //#endregion
582
+ //#region src/webauthn/widgets/PasskeyDirectLogin.tsx
583
+ function K(e) {
584
+ if (!e) return null;
585
+ let t = e.onComplete, n = e.onError, r = e.locale;
586
+ return typeof t == "function" ? {
587
+ onComplete: t,
588
+ onError: typeof n == "function" ? n : void 0,
589
+ locale: typeof r == "string" ? r : void 0
590
+ } : null;
591
+ }
592
+ function q(e) {
593
+ let n = a(), r = K(e.config), [i, c] = t(!1), [l, u] = t(null), d = W(r?.locale);
594
+ return r ? L() ? /* @__PURE__ */ s("div", {
595
+ className: "space-y-2",
596
+ children: [/* @__PURE__ */ s("button", {
597
+ type: "button",
598
+ "data-testid": "login-passkey-direct",
599
+ onClick: async () => {
600
+ u(null), c(!0);
601
+ try {
602
+ let e = await R((await n.passkeyLoginBegin()).optionsJSON), t = await n.passkeyLoginFinish(e);
603
+ r.onComplete(t.token);
604
+ } catch (e) {
605
+ let t = e instanceof Error ? e.message : G(d, "failed");
606
+ u(t), r.onError?.(t);
607
+ } finally {
608
+ c(!1);
609
+ }
610
+ },
611
+ disabled: i,
612
+ className: "w-full flex items-center justify-center gap-2 rounded-lg border border-border bg-surface px-4 py-2.5 text-sm font-medium text-foreground hover:bg-surface-hover disabled:opacity-50 transition-colors",
613
+ children: [i ? /* @__PURE__ */ o(b, { className: "h-4 w-4 animate-spin text-primary" }) : /* @__PURE__ */ o(v, { className: "h-4 w-4 text-primary" }), G(d, "signInWithPasskey")]
614
+ }), l && /* @__PURE__ */ o("div", {
615
+ className: "rounded-md bg-danger/10 border border-danger/20 px-3 py-2 text-xs text-danger",
616
+ children: l
617
+ })]
618
+ }) : /* @__PURE__ */ o("div", {
619
+ className: "rounded-lg border border-border bg-surface px-3 py-2 text-xs text-foreground-subtle",
620
+ children: G(d, "unsupported")
621
+ }) : /* @__PURE__ */ o("div", {
622
+ className: "rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning",
623
+ children: "Passkey direct-login widget requires an onComplete callback."
624
+ });
625
+ }
626
+ //#endregion
627
+ //#region src/webauthn/widgets/index.tsx
628
+ var J = {
564
629
  "passkey-enrollment": B,
565
- "passkey-login": H
630
+ "passkey-login": H,
631
+ "passkey-direct-login": q
566
632
  };
567
633
  //#endregion
568
- export { U as default };
634
+ export { J as default };
@@ -3,7 +3,7 @@ import "./dist-CYZr2fwk.mjs";
3
3
  var e = {
4
4
  "@camstack/sdk": {
5
5
  name: "@camstack/sdk",
6
- version: "1.1.20",
6
+ version: "1.1.21",
7
7
  scope: ["default"],
8
8
  loaded: !1,
9
9
  from: "addon_auth_webauthn_widgets",
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.1.36",
21
+ version: "1.1.37",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_auth_webauthn_widgets",
@@ -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_dist = require("../dist-mmJDfwXE.js");
6
+ const require_dist = require("../dist-biwV9TkV.js");
7
7
  let stream = require("stream");
8
8
  stream = require_chunk.__toESM(stream, 1);
9
9
  let http = require("http");
@@ -12257,61 +12257,97 @@ var AUTH_WEBAUTHN_REMOTE_NAME = "addon_auth_webauthn_widgets";
12257
12257
  var AUTH_WEBAUTHN_BUNDLE = "remoteEntry.js";
12258
12258
  /** Manifest addon id — the public bundle namespace + login-method addonId. */
12259
12259
  var AUTH_WEBAUTHN_ADDON_ID = "auth-webauthn";
12260
- var authWebauthnWidgets = [{
12261
- tab: "dashboard",
12262
- label: "Passkeys",
12263
- kind: "remote",
12264
- remote: {
12265
- remoteName: AUTH_WEBAUTHN_REMOTE_NAME,
12266
- exposedModule: "./widgets",
12267
- componentKey: "passkey-enrollment"
12268
- },
12269
- stableId: "passkey-enrollment",
12270
- description: "Enroll and manage passkeys (WebAuthn) for your account.",
12271
- icon: "fingerprint",
12272
- bundle: AUTH_WEBAUTHN_BUNDLE,
12273
- hosts: ["dashboard"],
12274
- requires: {
12275
- deviceContext: false,
12276
- integrationContext: false
12277
- },
12278
- defaultSize: "lg",
12279
- allowedSizes: [
12280
- "md",
12281
- "lg",
12282
- "xl"
12283
- ],
12284
- defaultColumns: 12,
12285
- defaultRows: 3,
12286
- preAuth: false
12287
- }, {
12288
- tab: "dashboard",
12289
- label: "Passkey login",
12290
- kind: "remote",
12291
- remote: {
12292
- remoteName: AUTH_WEBAUTHN_REMOTE_NAME,
12293
- exposedModule: "./widgets",
12294
- componentKey: "passkey-login"
12260
+ var authWebauthnWidgets = [
12261
+ {
12262
+ tab: "dashboard",
12263
+ label: "Passkeys",
12264
+ kind: "remote",
12265
+ remote: {
12266
+ remoteName: AUTH_WEBAUTHN_REMOTE_NAME,
12267
+ exposedModule: "./widgets",
12268
+ componentKey: "passkey-enrollment"
12269
+ },
12270
+ stableId: "passkey-enrollment",
12271
+ description: "Enroll and manage passkeys (WebAuthn) for your account.",
12272
+ icon: "fingerprint",
12273
+ bundle: AUTH_WEBAUTHN_BUNDLE,
12274
+ hosts: ["dashboard"],
12275
+ requires: {
12276
+ deviceContext: false,
12277
+ integrationContext: false
12278
+ },
12279
+ defaultSize: "lg",
12280
+ allowedSizes: [
12281
+ "md",
12282
+ "lg",
12283
+ "xl"
12284
+ ],
12285
+ defaultColumns: 12,
12286
+ defaultRows: 3,
12287
+ preAuth: false
12295
12288
  },
12296
- stableId: "passkey-login",
12297
- description: "Login-page passkey second-factor ceremony.",
12298
- icon: "fingerprint",
12299
- bundle: AUTH_WEBAUTHN_BUNDLE,
12300
- hosts: ["dashboard"],
12301
- requires: {
12302
- deviceContext: false,
12303
- integrationContext: false
12289
+ {
12290
+ tab: "dashboard",
12291
+ label: "Passkey login",
12292
+ kind: "remote",
12293
+ remote: {
12294
+ remoteName: AUTH_WEBAUTHN_REMOTE_NAME,
12295
+ exposedModule: "./widgets",
12296
+ componentKey: "passkey-login"
12297
+ },
12298
+ stableId: "passkey-login",
12299
+ description: "Login-page passkey second-factor ceremony.",
12300
+ icon: "fingerprint",
12301
+ bundle: AUTH_WEBAUTHN_BUNDLE,
12302
+ hosts: ["dashboard"],
12303
+ requires: {
12304
+ deviceContext: false,
12305
+ integrationContext: false
12306
+ },
12307
+ defaultSize: "sm",
12308
+ allowedSizes: ["sm"],
12309
+ defaultColumns: 4,
12310
+ defaultRows: 1,
12311
+ preAuth: true
12304
12312
  },
12305
- defaultSize: "sm",
12306
- allowedSizes: ["sm"],
12307
- defaultColumns: 4,
12308
- defaultRows: 1,
12309
- preAuth: true
12310
- }];
12313
+ {
12314
+ tab: "dashboard",
12315
+ label: "Passkey sign-in",
12316
+ kind: "remote",
12317
+ remote: {
12318
+ remoteName: AUTH_WEBAUTHN_REMOTE_NAME,
12319
+ exposedModule: "./widgets",
12320
+ componentKey: "passkey-direct-login"
12321
+ },
12322
+ stableId: "passkey-direct-login",
12323
+ description: "Login-page usernameless (discoverable-credential) passkey sign-in.",
12324
+ icon: "fingerprint",
12325
+ bundle: AUTH_WEBAUTHN_BUNDLE,
12326
+ hosts: ["dashboard"],
12327
+ requires: {
12328
+ deviceContext: false,
12329
+ integrationContext: false
12330
+ },
12331
+ defaultSize: "sm",
12332
+ allowedSizes: ["sm"],
12333
+ defaultColumns: 4,
12334
+ defaultRows: 1,
12335
+ preAuth: true
12336
+ }
12337
+ ];
12311
12338
  /**
12312
- * Login-method contributionthe `passkey-login` widget, second-factor
12313
- * stage. Aggregated by the public `auth.listLoginMethods` procedure,
12314
- * which stamps a public `bundleUrl` from `addonId` + `bundle`.
12339
+ * Login-method contributionsaggregated by the public
12340
+ * `auth.listLoginMethods` procedure, which stamps a public `bundleUrl`
12341
+ * from `addonId` + `bundle`.
12342
+ *
12343
+ * - `passkey-login` (second-factor) — the post-password 2FA ceremony,
12344
+ * mounted only when the challenged user has the `passkey` factor.
12345
+ * - `passkey-direct-login` (primary) — usernameless sign-in via the
12346
+ * standard WebAuthn discoverable-credentials flow. Contributed
12347
+ * unconditionally while the addon is registered: the browser's own
12348
+ * passkey picker is the honest gate (it simply offers nothing when
12349
+ * no resident credential matches the RP) — probing the server for
12350
+ * "does anyone have a passkey" pre-auth would leak enrollment state.
12315
12351
  */
12316
12352
  var authWebauthnLoginMethods = [{
12317
12353
  kind: "widget",
@@ -12324,6 +12360,17 @@ var authWebauthnLoginMethods = [{
12324
12360
  componentKey: "passkey-login"
12325
12361
  },
12326
12362
  stage: "second-factor"
12363
+ }, {
12364
+ kind: "widget",
12365
+ id: `${AUTH_WEBAUTHN_ADDON_ID}/passkey-direct-login`,
12366
+ addonId: AUTH_WEBAUTHN_ADDON_ID,
12367
+ bundle: AUTH_WEBAUTHN_BUNDLE,
12368
+ remote: {
12369
+ remoteName: AUTH_WEBAUTHN_REMOTE_NAME,
12370
+ exposedModule: "./widgets",
12371
+ componentKey: "passkey-direct-login"
12372
+ },
12373
+ stage: "primary"
12327
12374
  }];
12328
12375
  //#endregion
12329
12376
  //#region src/webauthn/auth-webauthn.addon.ts
@@ -12388,6 +12435,8 @@ var AuthWebauthnAddon = class extends require_dist.BaseAddon {
12388
12435
  finishRegistration: async ({ userId, response, label }) => this.finishRegistration(userId, response, label),
12389
12436
  beginAuthentication: async ({ userId }) => this.beginAuthentication(userId),
12390
12437
  finishAuthentication: async ({ userId, response }) => this.finishAuthentication(userId, response),
12438
+ beginDiscoverableAuthentication: async () => this.beginDiscoverableAuthentication(),
12439
+ finishDiscoverableAuthentication: async ({ response }) => this.finishDiscoverableAuthentication(response),
12391
12440
  listPasskeys: async ({ userId }) => this.listPasskeys(userId),
12392
12441
  removePasskey: async ({ userId, credentialId }) => this.removePasskey(userId, credentialId)
12393
12442
  };
@@ -12505,9 +12554,12 @@ var AuthWebauthnAddon = class extends require_dist.BaseAddon {
12505
12554
  if (this.config.origin && this.config.origin.trim()) return this.config.origin.trim().replace(/\/+$/, "");
12506
12555
  return (process.env["CAMSTACK_PUBLIC_ORIGIN"] || "http://localhost:4443").replace(/\/+$/, "");
12507
12556
  }
12557
+ challengeTtlMs() {
12558
+ return (this.config.challengeTtlSec || DEFAULT_CONFIG.challengeTtlSec) * 1e3;
12559
+ }
12508
12560
  pruneExpiredChallenges() {
12509
12561
  const now = Date.now();
12510
- const ttlMs = (this.config.challengeTtlSec || DEFAULT_CONFIG.challengeTtlSec) * 1e3;
12562
+ const ttlMs = this.challengeTtlMs();
12511
12563
  for (const [k, v] of this.pending.entries()) if (now - v.createdAt > ttlMs) this.pending.delete(k);
12512
12564
  }
12513
12565
  base64urlEncode(bytes) {
@@ -12633,6 +12685,105 @@ var AuthWebauthnAddon = class extends require_dist.BaseAddon {
12633
12685
  await this.store.updateCounter(credentialId, verification.authenticationInfo.newCounter);
12634
12686
  return { verified: true };
12635
12687
  }
12688
+ /**
12689
+ * Usernameless (discoverable-credential) login — leg 1. Issues
12690
+ * assertion options with an EMPTY `allowCredentials` list so the
12691
+ * browser offers every resident passkey it holds for this RP, and
12692
+ * `userVerification: 'required'` because the passkey is the ONLY
12693
+ * factor (it replaces both username/password and the 2FA leg).
12694
+ * The challenge is stored server-side keyed by its own value and is
12695
+ * NOT bound to any user — `finishDiscoverableAuthentication` resolves
12696
+ * the owner from the asserted credential id.
12697
+ */
12698
+ async beginDiscoverableAuthentication() {
12699
+ if (!this.store) throw new Error("passkey store unavailable");
12700
+ this.pruneExpiredChallenges();
12701
+ const options = await generateAuthenticationOptions({
12702
+ rpID: this.resolveRpID(),
12703
+ allowCredentials: [],
12704
+ userVerification: "required"
12705
+ });
12706
+ this.pending.set(options.challenge, {
12707
+ challenge: options.challenge,
12708
+ userId: "",
12709
+ kind: "discoverable-authentication",
12710
+ createdAt: Date.now()
12711
+ });
12712
+ return { optionsJSON: options };
12713
+ }
12714
+ /**
12715
+ * Usernameless login — leg 2. Resolves the credential by the
12716
+ * response's credential id, verifies the assertion against the stored
12717
+ * challenge + that credential's public key/counter, and returns the
12718
+ * OWNING userId. Fail-closed: every anomaly returns
12719
+ * `{ verified: false, userId: null }`.
12720
+ *
12721
+ * Replay protection: the pending challenge is consumed (deleted)
12722
+ * BEFORE verification, so an assertion can be spent exactly once.
12723
+ * The TTL is enforced here too — pruning alone only runs on begin
12724
+ * legs, which an attacker controls the timing of.
12725
+ */
12726
+ async finishDiscoverableAuthentication(response) {
12727
+ if (!this.store) return {
12728
+ verified: false,
12729
+ userId: null
12730
+ };
12731
+ const challenge = this.decodeClientData(response)?.challenge;
12732
+ if (!challenge) return {
12733
+ verified: false,
12734
+ userId: null
12735
+ };
12736
+ const pending = this.pending.get(challenge);
12737
+ if (!pending || pending.kind !== "discoverable-authentication") return {
12738
+ verified: false,
12739
+ userId: null
12740
+ };
12741
+ this.pending.delete(challenge);
12742
+ if (Date.now() - pending.createdAt > this.challengeTtlMs()) return {
12743
+ verified: false,
12744
+ userId: null
12745
+ };
12746
+ const credentialId = typeof response["id"] === "string" ? response["id"] : "";
12747
+ const stored = await this.store.findByCredentialId(credentialId);
12748
+ if (!stored) return {
12749
+ verified: false,
12750
+ userId: null
12751
+ };
12752
+ let verification;
12753
+ try {
12754
+ verification = await verifyAuthenticationResponse({
12755
+ response,
12756
+ expectedChallenge: challenge,
12757
+ expectedRPID: this.resolveRpID(),
12758
+ expectedOrigin: this.resolveOrigin(),
12759
+ requireUserVerification: true,
12760
+ credential: {
12761
+ id: stored.credentialId,
12762
+ publicKey: this.base64urlDecode(stored.publicKey),
12763
+ counter: stored.counter,
12764
+ transports: stored.transports
12765
+ }
12766
+ });
12767
+ } catch (err) {
12768
+ this.ctx.logger.warn("Discoverable passkey verification threw", { meta: {
12769
+ credentialId,
12770
+ err: err instanceof Error ? err.message : String(err)
12771
+ } });
12772
+ return {
12773
+ verified: false,
12774
+ userId: null
12775
+ };
12776
+ }
12777
+ if (!verification.verified || !verification.authenticationInfo) return {
12778
+ verified: false,
12779
+ userId: null
12780
+ };
12781
+ await this.store.updateCounter(credentialId, verification.authenticationInfo.newCounter);
12782
+ return {
12783
+ verified: true,
12784
+ userId: stored.userId
12785
+ };
12786
+ }
12636
12787
  async listPasskeys(userId) {
12637
12788
  if (!this.store) return [];
12638
12789
  return (await this.store.findByUserId(userId)).map((r) => ({
@@ -1,4 +1,4 @@
1
- import { a as userPasskeysCapability, c as array, i as loginMethodCapability, l as object, n as addonWidgetsSourceCapability, s as BaseAddon, u as string } from "../dist-suALViLT.mjs";
1
+ import { a as loginMethodCapability, c as BaseAddon, d as string, l as array, n as addonWidgetsSourceCapability, o as userPasskeysCapability, u as object } from "../dist-BopfLZ9P.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import Stream from "stream";
4
4
  import http from "http";
@@ -12283,61 +12283,97 @@ var AUTH_WEBAUTHN_REMOTE_NAME = "addon_auth_webauthn_widgets";
12283
12283
  var AUTH_WEBAUTHN_BUNDLE = "remoteEntry.js";
12284
12284
  /** Manifest addon id — the public bundle namespace + login-method addonId. */
12285
12285
  var AUTH_WEBAUTHN_ADDON_ID = "auth-webauthn";
12286
- var authWebauthnWidgets = [{
12287
- tab: "dashboard",
12288
- label: "Passkeys",
12289
- kind: "remote",
12290
- remote: {
12291
- remoteName: AUTH_WEBAUTHN_REMOTE_NAME,
12292
- exposedModule: "./widgets",
12293
- componentKey: "passkey-enrollment"
12294
- },
12295
- stableId: "passkey-enrollment",
12296
- description: "Enroll and manage passkeys (WebAuthn) for your account.",
12297
- icon: "fingerprint",
12298
- bundle: AUTH_WEBAUTHN_BUNDLE,
12299
- hosts: ["dashboard"],
12300
- requires: {
12301
- deviceContext: false,
12302
- integrationContext: false
12303
- },
12304
- defaultSize: "lg",
12305
- allowedSizes: [
12306
- "md",
12307
- "lg",
12308
- "xl"
12309
- ],
12310
- defaultColumns: 12,
12311
- defaultRows: 3,
12312
- preAuth: false
12313
- }, {
12314
- tab: "dashboard",
12315
- label: "Passkey login",
12316
- kind: "remote",
12317
- remote: {
12318
- remoteName: AUTH_WEBAUTHN_REMOTE_NAME,
12319
- exposedModule: "./widgets",
12320
- componentKey: "passkey-login"
12286
+ var authWebauthnWidgets = [
12287
+ {
12288
+ tab: "dashboard",
12289
+ label: "Passkeys",
12290
+ kind: "remote",
12291
+ remote: {
12292
+ remoteName: AUTH_WEBAUTHN_REMOTE_NAME,
12293
+ exposedModule: "./widgets",
12294
+ componentKey: "passkey-enrollment"
12295
+ },
12296
+ stableId: "passkey-enrollment",
12297
+ description: "Enroll and manage passkeys (WebAuthn) for your account.",
12298
+ icon: "fingerprint",
12299
+ bundle: AUTH_WEBAUTHN_BUNDLE,
12300
+ hosts: ["dashboard"],
12301
+ requires: {
12302
+ deviceContext: false,
12303
+ integrationContext: false
12304
+ },
12305
+ defaultSize: "lg",
12306
+ allowedSizes: [
12307
+ "md",
12308
+ "lg",
12309
+ "xl"
12310
+ ],
12311
+ defaultColumns: 12,
12312
+ defaultRows: 3,
12313
+ preAuth: false
12321
12314
  },
12322
- stableId: "passkey-login",
12323
- description: "Login-page passkey second-factor ceremony.",
12324
- icon: "fingerprint",
12325
- bundle: AUTH_WEBAUTHN_BUNDLE,
12326
- hosts: ["dashboard"],
12327
- requires: {
12328
- deviceContext: false,
12329
- integrationContext: false
12315
+ {
12316
+ tab: "dashboard",
12317
+ label: "Passkey login",
12318
+ kind: "remote",
12319
+ remote: {
12320
+ remoteName: AUTH_WEBAUTHN_REMOTE_NAME,
12321
+ exposedModule: "./widgets",
12322
+ componentKey: "passkey-login"
12323
+ },
12324
+ stableId: "passkey-login",
12325
+ description: "Login-page passkey second-factor ceremony.",
12326
+ icon: "fingerprint",
12327
+ bundle: AUTH_WEBAUTHN_BUNDLE,
12328
+ hosts: ["dashboard"],
12329
+ requires: {
12330
+ deviceContext: false,
12331
+ integrationContext: false
12332
+ },
12333
+ defaultSize: "sm",
12334
+ allowedSizes: ["sm"],
12335
+ defaultColumns: 4,
12336
+ defaultRows: 1,
12337
+ preAuth: true
12330
12338
  },
12331
- defaultSize: "sm",
12332
- allowedSizes: ["sm"],
12333
- defaultColumns: 4,
12334
- defaultRows: 1,
12335
- preAuth: true
12336
- }];
12339
+ {
12340
+ tab: "dashboard",
12341
+ label: "Passkey sign-in",
12342
+ kind: "remote",
12343
+ remote: {
12344
+ remoteName: AUTH_WEBAUTHN_REMOTE_NAME,
12345
+ exposedModule: "./widgets",
12346
+ componentKey: "passkey-direct-login"
12347
+ },
12348
+ stableId: "passkey-direct-login",
12349
+ description: "Login-page usernameless (discoverable-credential) passkey sign-in.",
12350
+ icon: "fingerprint",
12351
+ bundle: AUTH_WEBAUTHN_BUNDLE,
12352
+ hosts: ["dashboard"],
12353
+ requires: {
12354
+ deviceContext: false,
12355
+ integrationContext: false
12356
+ },
12357
+ defaultSize: "sm",
12358
+ allowedSizes: ["sm"],
12359
+ defaultColumns: 4,
12360
+ defaultRows: 1,
12361
+ preAuth: true
12362
+ }
12363
+ ];
12337
12364
  /**
12338
- * Login-method contributionthe `passkey-login` widget, second-factor
12339
- * stage. Aggregated by the public `auth.listLoginMethods` procedure,
12340
- * which stamps a public `bundleUrl` from `addonId` + `bundle`.
12365
+ * Login-method contributionsaggregated by the public
12366
+ * `auth.listLoginMethods` procedure, which stamps a public `bundleUrl`
12367
+ * from `addonId` + `bundle`.
12368
+ *
12369
+ * - `passkey-login` (second-factor) — the post-password 2FA ceremony,
12370
+ * mounted only when the challenged user has the `passkey` factor.
12371
+ * - `passkey-direct-login` (primary) — usernameless sign-in via the
12372
+ * standard WebAuthn discoverable-credentials flow. Contributed
12373
+ * unconditionally while the addon is registered: the browser's own
12374
+ * passkey picker is the honest gate (it simply offers nothing when
12375
+ * no resident credential matches the RP) — probing the server for
12376
+ * "does anyone have a passkey" pre-auth would leak enrollment state.
12341
12377
  */
12342
12378
  var authWebauthnLoginMethods = [{
12343
12379
  kind: "widget",
@@ -12350,6 +12386,17 @@ var authWebauthnLoginMethods = [{
12350
12386
  componentKey: "passkey-login"
12351
12387
  },
12352
12388
  stage: "second-factor"
12389
+ }, {
12390
+ kind: "widget",
12391
+ id: `${AUTH_WEBAUTHN_ADDON_ID}/passkey-direct-login`,
12392
+ addonId: AUTH_WEBAUTHN_ADDON_ID,
12393
+ bundle: AUTH_WEBAUTHN_BUNDLE,
12394
+ remote: {
12395
+ remoteName: AUTH_WEBAUTHN_REMOTE_NAME,
12396
+ exposedModule: "./widgets",
12397
+ componentKey: "passkey-direct-login"
12398
+ },
12399
+ stage: "primary"
12353
12400
  }];
12354
12401
  var EndpointsSchema = array(object({
12355
12402
  hostname: string(),
@@ -12377,6 +12424,8 @@ var AuthWebauthnAddon = class extends BaseAddon {
12377
12424
  finishRegistration: async ({ userId, response, label }) => this.finishRegistration(userId, response, label),
12378
12425
  beginAuthentication: async ({ userId }) => this.beginAuthentication(userId),
12379
12426
  finishAuthentication: async ({ userId, response }) => this.finishAuthentication(userId, response),
12427
+ beginDiscoverableAuthentication: async () => this.beginDiscoverableAuthentication(),
12428
+ finishDiscoverableAuthentication: async ({ response }) => this.finishDiscoverableAuthentication(response),
12380
12429
  listPasskeys: async ({ userId }) => this.listPasskeys(userId),
12381
12430
  removePasskey: async ({ userId, credentialId }) => this.removePasskey(userId, credentialId)
12382
12431
  };
@@ -12494,9 +12543,12 @@ var AuthWebauthnAddon = class extends BaseAddon {
12494
12543
  if (this.config.origin && this.config.origin.trim()) return this.config.origin.trim().replace(/\/+$/, "");
12495
12544
  return (process.env["CAMSTACK_PUBLIC_ORIGIN"] || "http://localhost:4443").replace(/\/+$/, "");
12496
12545
  }
12546
+ challengeTtlMs() {
12547
+ return (this.config.challengeTtlSec || DEFAULT_CONFIG.challengeTtlSec) * 1e3;
12548
+ }
12497
12549
  pruneExpiredChallenges() {
12498
12550
  const now = Date.now();
12499
- const ttlMs = (this.config.challengeTtlSec || DEFAULT_CONFIG.challengeTtlSec) * 1e3;
12551
+ const ttlMs = this.challengeTtlMs();
12500
12552
  for (const [k, v] of this.pending.entries()) if (now - v.createdAt > ttlMs) this.pending.delete(k);
12501
12553
  }
12502
12554
  base64urlEncode(bytes) {
@@ -12622,6 +12674,105 @@ var AuthWebauthnAddon = class extends BaseAddon {
12622
12674
  await this.store.updateCounter(credentialId, verification.authenticationInfo.newCounter);
12623
12675
  return { verified: true };
12624
12676
  }
12677
+ /**
12678
+ * Usernameless (discoverable-credential) login — leg 1. Issues
12679
+ * assertion options with an EMPTY `allowCredentials` list so the
12680
+ * browser offers every resident passkey it holds for this RP, and
12681
+ * `userVerification: 'required'` because the passkey is the ONLY
12682
+ * factor (it replaces both username/password and the 2FA leg).
12683
+ * The challenge is stored server-side keyed by its own value and is
12684
+ * NOT bound to any user — `finishDiscoverableAuthentication` resolves
12685
+ * the owner from the asserted credential id.
12686
+ */
12687
+ async beginDiscoverableAuthentication() {
12688
+ if (!this.store) throw new Error("passkey store unavailable");
12689
+ this.pruneExpiredChallenges();
12690
+ const options = await generateAuthenticationOptions({
12691
+ rpID: this.resolveRpID(),
12692
+ allowCredentials: [],
12693
+ userVerification: "required"
12694
+ });
12695
+ this.pending.set(options.challenge, {
12696
+ challenge: options.challenge,
12697
+ userId: "",
12698
+ kind: "discoverable-authentication",
12699
+ createdAt: Date.now()
12700
+ });
12701
+ return { optionsJSON: options };
12702
+ }
12703
+ /**
12704
+ * Usernameless login — leg 2. Resolves the credential by the
12705
+ * response's credential id, verifies the assertion against the stored
12706
+ * challenge + that credential's public key/counter, and returns the
12707
+ * OWNING userId. Fail-closed: every anomaly returns
12708
+ * `{ verified: false, userId: null }`.
12709
+ *
12710
+ * Replay protection: the pending challenge is consumed (deleted)
12711
+ * BEFORE verification, so an assertion can be spent exactly once.
12712
+ * The TTL is enforced here too — pruning alone only runs on begin
12713
+ * legs, which an attacker controls the timing of.
12714
+ */
12715
+ async finishDiscoverableAuthentication(response) {
12716
+ if (!this.store) return {
12717
+ verified: false,
12718
+ userId: null
12719
+ };
12720
+ const challenge = this.decodeClientData(response)?.challenge;
12721
+ if (!challenge) return {
12722
+ verified: false,
12723
+ userId: null
12724
+ };
12725
+ const pending = this.pending.get(challenge);
12726
+ if (!pending || pending.kind !== "discoverable-authentication") return {
12727
+ verified: false,
12728
+ userId: null
12729
+ };
12730
+ this.pending.delete(challenge);
12731
+ if (Date.now() - pending.createdAt > this.challengeTtlMs()) return {
12732
+ verified: false,
12733
+ userId: null
12734
+ };
12735
+ const credentialId = typeof response["id"] === "string" ? response["id"] : "";
12736
+ const stored = await this.store.findByCredentialId(credentialId);
12737
+ if (!stored) return {
12738
+ verified: false,
12739
+ userId: null
12740
+ };
12741
+ let verification;
12742
+ try {
12743
+ verification = await verifyAuthenticationResponse({
12744
+ response,
12745
+ expectedChallenge: challenge,
12746
+ expectedRPID: this.resolveRpID(),
12747
+ expectedOrigin: this.resolveOrigin(),
12748
+ requireUserVerification: true,
12749
+ credential: {
12750
+ id: stored.credentialId,
12751
+ publicKey: this.base64urlDecode(stored.publicKey),
12752
+ counter: stored.counter,
12753
+ transports: stored.transports
12754
+ }
12755
+ });
12756
+ } catch (err) {
12757
+ this.ctx.logger.warn("Discoverable passkey verification threw", { meta: {
12758
+ credentialId,
12759
+ err: err instanceof Error ? err.message : String(err)
12760
+ } });
12761
+ return {
12762
+ verified: false,
12763
+ userId: null
12764
+ };
12765
+ }
12766
+ if (!verification.verified || !verification.authenticationInfo) return {
12767
+ verified: false,
12768
+ userId: null
12769
+ };
12770
+ await this.store.updateCounter(credentialId, verification.authenticationInfo.newCounter);
12771
+ return {
12772
+ verified: true,
12773
+ userId: stored.userId
12774
+ };
12775
+ }
12625
12776
  async listPasskeys(userId) {
12626
12777
  if (!this.store) return [];
12627
12778
  return (await this.store.findByUserId(userId)).map((r) => ({
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.1.36",
39
+ version: "1.1.37",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -45,7 +45,7 @@ async function r() {
45
45
  }
46
46
  },
47
47
  "@camstack/sdk": {
48
- version: "1.1.20",
48
+ version: "1.1.21",
49
49
  scope: "default",
50
50
  shareConfig: {
51
51
  singleton: !0,
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_auth_webauthn_widgets-Bg_T1-iY.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_auth_webauthn_widgets-DW5sgIHW.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-auth",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "CamStack authentication bundle — magic-link, OIDC, and WebAuthn/passkey. Multi-entry npm package shipping 3 addons under a single bundle; each addon keeps its own id, capabilities, and runner.",
5
5
  "keywords": [
6
6
  "camstack",