@camstack/addon-auth 1.1.4 → 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.
@@ -16401,6 +16401,19 @@ method(_void(), array(TurnServerSchema).readonly());
16401
16401
  * b. `finishAuthentication({userId, response})` → server verifies
16402
16402
  * the assertion, bumps the credential counter, returns ok.
16403
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
+ *
16404
16417
  * 3. Management:
16405
16418
  * - `listPasskeys({userId})` — enumerate user's enrolled credentials.
16406
16419
  * - `removePasskey({userId, credentialId})` — revoke one credential.
@@ -16457,6 +16470,19 @@ var userPasskeysCapability = {
16457
16470
  kind: "mutation",
16458
16471
  access: "view"
16459
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
+ }),
16460
16486
  listPasskeys: method(object({ userId: string() }), array(PasskeySummarySchema), { auth: "admin" }),
16461
16487
  removePasskey: method(object({
16462
16488
  userId: string(),
@@ -22959,6 +22985,12 @@ Object.freeze({
22959
22985
  addonId: null,
22960
22986
  access: "view"
22961
22987
  },
22988
+ "userPasskeys.beginDiscoverableAuthentication": {
22989
+ capName: "user-passkeys",
22990
+ capScope: "system",
22991
+ addonId: null,
22992
+ access: "view"
22993
+ },
22962
22994
  "userPasskeys.beginRegistration": {
22963
22995
  capName: "user-passkeys",
22964
22996
  capScope: "system",
@@ -22971,6 +23003,12 @@ Object.freeze({
22971
23003
  addonId: null,
22972
23004
  access: "view"
22973
23005
  },
23006
+ "userPasskeys.finishDiscoverableAuthentication": {
23007
+ capName: "user-passkeys",
23008
+ capScope: "system",
23009
+ addonId: null,
23010
+ access: "view"
23011
+ },
22974
23012
  "userPasskeys.finishRegistration": {
22975
23013
  capName: "user-passkeys",
22976
23014
  capScope: "system",
@@ -16401,6 +16401,19 @@ method(_void(), array(TurnServerSchema).readonly());
16401
16401
  * b. `finishAuthentication({userId, response})` → server verifies
16402
16402
  * the assertion, bumps the credential counter, returns ok.
16403
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
+ *
16404
16417
  * 3. Management:
16405
16418
  * - `listPasskeys({userId})` — enumerate user's enrolled credentials.
16406
16419
  * - `removePasskey({userId, credentialId})` — revoke one credential.
@@ -16457,6 +16470,19 @@ var userPasskeysCapability = {
16457
16470
  kind: "mutation",
16458
16471
  access: "view"
16459
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
+ }),
16460
16486
  listPasskeys: method(object({ userId: string() }), array(PasskeySummarySchema), { auth: "admin" }),
16461
16487
  removePasskey: method(object({
16462
16488
  userId: string(),
@@ -22959,6 +22985,12 @@ Object.freeze({
22959
22985
  addonId: null,
22960
22986
  access: "view"
22961
22987
  },
22988
+ "userPasskeys.beginDiscoverableAuthentication": {
22989
+ capName: "user-passkeys",
22990
+ capScope: "system",
22991
+ addonId: null,
22992
+ access: "view"
22993
+ },
22962
22994
  "userPasskeys.beginRegistration": {
22963
22995
  capName: "user-passkeys",
22964
22996
  capScope: "system",
@@ -22971,6 +23003,12 @@ Object.freeze({
22971
23003
  addonId: null,
22972
23004
  access: "view"
22973
23005
  },
23006
+ "userPasskeys.finishDiscoverableAuthentication": {
23007
+ capName: "user-passkeys",
23008
+ capScope: "system",
23009
+ addonId: null,
23010
+ access: "view"
23011
+ },
22974
23012
  "userPasskeys.finishRegistration": {
22975
23013
  capName: "user-passkeys",
22976
23014
  capScope: "system",
@@ -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-BLBS0d1z.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.
@@ -1,4 +1,4 @@
1
- import { a as loginMethodCapability, c as BaseAddon, i as buildAddonRouteProvider, r as authProviderCapability, s as errMsg, t as addonRoutesCapability } from "../dist-JUeAGU3c.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.
@@ -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-BLBS0d1z.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
@@ -1,4 +1,4 @@
1
- import { a as loginMethodCapability, c as BaseAddon, i as buildAddonRouteProvider, r as authProviderCapability, s as errMsg, t as addonRoutesCapability } from "../dist-JUeAGU3c.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();
@@ -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-BLBS0d1z.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 loginMethodCapability, c as BaseAddon, d as string, l as array, n as addonWidgetsSourceCapability, o as userPasskeysCapability, u as object } from "../dist-JUeAGU3c.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.4",
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",