@camstack/addon-auth 1.1.4 → 1.1.6

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.
@@ -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 number, f as object, l as array, n as addonWidgetsSourceCapability, o as userPasskeysCapability, p as string, u as boolean } from "../dist-BlBNW5nA.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import Stream from "stream";
4
4
  import http from "http";
@@ -12149,6 +12149,36 @@ async function verifyAuthenticationResponse(options) {
12149
12149
  * auth like Touch ID), `usb`, `nfc`, `ble`.
12150
12150
  */
12151
12151
  var COLLECTION = "user_passkeys";
12152
+ /**
12153
+ * Per-user passkey preferences — one row per userId, keyed upserts via
12154
+ * the settings-store `set` surface. Today a single flag: whether the
12155
+ * user OPTED IN to their passkey doubling as a mandatory second factor
12156
+ * after a password login (default OFF — enrolling a passkey only
12157
+ * enables passkey-first sign-in).
12158
+ */
12159
+ var PREF_COLLECTION = "user_passkey_prefs";
12160
+ var PREF_COLUMNS = [
12161
+ {
12162
+ name: "userId",
12163
+ type: "TEXT",
12164
+ primaryKey: true,
12165
+ notNull: true
12166
+ },
12167
+ {
12168
+ name: "secondFactorEnabled",
12169
+ type: "BOOLEAN",
12170
+ notNull: true
12171
+ },
12172
+ {
12173
+ name: "updatedAt",
12174
+ type: "INTEGER",
12175
+ notNull: true
12176
+ }
12177
+ ];
12178
+ var StoredPreferenceSchema = object({
12179
+ secondFactorEnabled: boolean(),
12180
+ updatedAt: number()
12181
+ });
12152
12182
  var COLUMNS = [
12153
12183
  {
12154
12184
  name: "credentialId",
@@ -12199,6 +12229,10 @@ async function declarePasskeySchema(store) {
12199
12229
  columns: ["userId"]
12200
12230
  }]
12201
12231
  });
12232
+ await store.declareCollection.mutate({
12233
+ collection: PREF_COLLECTION,
12234
+ columns: PREF_COLUMNS
12235
+ });
12202
12236
  }
12203
12237
  function parseRow(data) {
12204
12238
  const credentialId = data["credentialId"];
@@ -12274,6 +12308,31 @@ var PasskeyStore = class {
12274
12308
  key: credentialId
12275
12309
  });
12276
12310
  }
12311
+ /**
12312
+ * Whether `userId` opted in to the passkey-as-second-factor gate.
12313
+ * Fail-safe: a missing or malformed row reads as `false` — the
12314
+ * password login flow must never demand a factor the user did not
12315
+ * explicitly enable.
12316
+ */
12317
+ async getSecondFactorPreference(userId) {
12318
+ const raw = await this.store.get.query({
12319
+ collection: PREF_COLLECTION,
12320
+ key: userId
12321
+ });
12322
+ const parsed = StoredPreferenceSchema.safeParse(raw);
12323
+ return parsed.success ? parsed.data.secondFactorEnabled : false;
12324
+ }
12325
+ async setSecondFactorPreference(userId, enabled) {
12326
+ const value = {
12327
+ secondFactorEnabled: enabled,
12328
+ updatedAt: Date.now()
12329
+ };
12330
+ await this.store.set.mutate({
12331
+ collection: PREF_COLLECTION,
12332
+ key: userId,
12333
+ value
12334
+ });
12335
+ }
12277
12336
  };
12278
12337
  //#endregion
12279
12338
  //#region src/webauthn/widget-catalog.ts
@@ -12283,61 +12342,97 @@ var AUTH_WEBAUTHN_REMOTE_NAME = "addon_auth_webauthn_widgets";
12283
12342
  var AUTH_WEBAUTHN_BUNDLE = "remoteEntry.js";
12284
12343
  /** Manifest addon id — the public bundle namespace + login-method addonId. */
12285
12344
  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"
12345
+ var authWebauthnWidgets = [
12346
+ {
12347
+ tab: "dashboard",
12348
+ label: "Passkeys",
12349
+ kind: "remote",
12350
+ remote: {
12351
+ remoteName: AUTH_WEBAUTHN_REMOTE_NAME,
12352
+ exposedModule: "./widgets",
12353
+ componentKey: "passkey-enrollment"
12354
+ },
12355
+ stableId: "passkey-enrollment",
12356
+ description: "Enroll and manage passkeys (WebAuthn) for your account.",
12357
+ icon: "fingerprint",
12358
+ bundle: AUTH_WEBAUTHN_BUNDLE,
12359
+ hosts: ["dashboard"],
12360
+ requires: {
12361
+ deviceContext: false,
12362
+ integrationContext: false
12363
+ },
12364
+ defaultSize: "lg",
12365
+ allowedSizes: [
12366
+ "md",
12367
+ "lg",
12368
+ "xl"
12369
+ ],
12370
+ defaultColumns: 12,
12371
+ defaultRows: 3,
12372
+ preAuth: false
12321
12373
  },
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
12374
+ {
12375
+ tab: "dashboard",
12376
+ label: "Passkey login",
12377
+ kind: "remote",
12378
+ remote: {
12379
+ remoteName: AUTH_WEBAUTHN_REMOTE_NAME,
12380
+ exposedModule: "./widgets",
12381
+ componentKey: "passkey-login"
12382
+ },
12383
+ stableId: "passkey-login",
12384
+ description: "Login-page passkey second-factor ceremony.",
12385
+ icon: "fingerprint",
12386
+ bundle: AUTH_WEBAUTHN_BUNDLE,
12387
+ hosts: ["dashboard"],
12388
+ requires: {
12389
+ deviceContext: false,
12390
+ integrationContext: false
12391
+ },
12392
+ defaultSize: "sm",
12393
+ allowedSizes: ["sm"],
12394
+ defaultColumns: 4,
12395
+ defaultRows: 1,
12396
+ preAuth: true
12330
12397
  },
12331
- defaultSize: "sm",
12332
- allowedSizes: ["sm"],
12333
- defaultColumns: 4,
12334
- defaultRows: 1,
12335
- preAuth: true
12336
- }];
12398
+ {
12399
+ tab: "dashboard",
12400
+ label: "Passkey sign-in",
12401
+ kind: "remote",
12402
+ remote: {
12403
+ remoteName: AUTH_WEBAUTHN_REMOTE_NAME,
12404
+ exposedModule: "./widgets",
12405
+ componentKey: "passkey-direct-login"
12406
+ },
12407
+ stableId: "passkey-direct-login",
12408
+ description: "Login-page usernameless (discoverable-credential) passkey sign-in.",
12409
+ icon: "fingerprint",
12410
+ bundle: AUTH_WEBAUTHN_BUNDLE,
12411
+ hosts: ["dashboard"],
12412
+ requires: {
12413
+ deviceContext: false,
12414
+ integrationContext: false
12415
+ },
12416
+ defaultSize: "sm",
12417
+ allowedSizes: ["sm"],
12418
+ defaultColumns: 4,
12419
+ defaultRows: 1,
12420
+ preAuth: true
12421
+ }
12422
+ ];
12337
12423
  /**
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`.
12424
+ * Login-method contributionsaggregated by the public
12425
+ * `auth.listLoginMethods` procedure, which stamps a public `bundleUrl`
12426
+ * from `addonId` + `bundle`.
12427
+ *
12428
+ * - `passkey-login` (second-factor) — the post-password 2FA ceremony,
12429
+ * mounted only when the challenged user has the `passkey` factor.
12430
+ * - `passkey-direct-login` (primary) — usernameless sign-in via the
12431
+ * standard WebAuthn discoverable-credentials flow. Contributed
12432
+ * unconditionally while the addon is registered: the browser's own
12433
+ * passkey picker is the honest gate (it simply offers nothing when
12434
+ * no resident credential matches the RP) — probing the server for
12435
+ * "does anyone have a passkey" pre-auth would leak enrollment state.
12341
12436
  */
12342
12437
  var authWebauthnLoginMethods = [{
12343
12438
  kind: "widget",
@@ -12350,6 +12445,17 @@ var authWebauthnLoginMethods = [{
12350
12445
  componentKey: "passkey-login"
12351
12446
  },
12352
12447
  stage: "second-factor"
12448
+ }, {
12449
+ kind: "widget",
12450
+ id: `${AUTH_WEBAUTHN_ADDON_ID}/passkey-direct-login`,
12451
+ addonId: AUTH_WEBAUTHN_ADDON_ID,
12452
+ bundle: AUTH_WEBAUTHN_BUNDLE,
12453
+ remote: {
12454
+ remoteName: AUTH_WEBAUTHN_REMOTE_NAME,
12455
+ exposedModule: "./widgets",
12456
+ componentKey: "passkey-direct-login"
12457
+ },
12458
+ stage: "primary"
12353
12459
  }];
12354
12460
  var EndpointsSchema = array(object({
12355
12461
  hostname: string(),
@@ -12377,8 +12483,12 @@ var AuthWebauthnAddon = class extends BaseAddon {
12377
12483
  finishRegistration: async ({ userId, response, label }) => this.finishRegistration(userId, response, label),
12378
12484
  beginAuthentication: async ({ userId }) => this.beginAuthentication(userId),
12379
12485
  finishAuthentication: async ({ userId, response }) => this.finishAuthentication(userId, response),
12486
+ beginDiscoverableAuthentication: async () => this.beginDiscoverableAuthentication(),
12487
+ finishDiscoverableAuthentication: async ({ response }) => this.finishDiscoverableAuthentication(response),
12380
12488
  listPasskeys: async ({ userId }) => this.listPasskeys(userId),
12381
- removePasskey: async ({ userId, credentialId }) => this.removePasskey(userId, credentialId)
12489
+ removePasskey: async ({ userId, credentialId }) => this.removePasskey(userId, credentialId),
12490
+ getSecondFactorPreference: async ({ userId }) => this.getSecondFactorPreference(userId),
12491
+ setSecondFactorPreference: async ({ userId, enabled }) => this.setSecondFactorPreference(userId, enabled)
12382
12492
  };
12383
12493
  const widgetsProvider = { listWidgets: async () => authWebauthnWidgets };
12384
12494
  const loginMethodProvider = { getLoginMethods: async () => authWebauthnLoginMethods };
@@ -12494,9 +12604,12 @@ var AuthWebauthnAddon = class extends BaseAddon {
12494
12604
  if (this.config.origin && this.config.origin.trim()) return this.config.origin.trim().replace(/\/+$/, "");
12495
12605
  return (process.env["CAMSTACK_PUBLIC_ORIGIN"] || "http://localhost:4443").replace(/\/+$/, "");
12496
12606
  }
12607
+ challengeTtlMs() {
12608
+ return (this.config.challengeTtlSec || DEFAULT_CONFIG.challengeTtlSec) * 1e3;
12609
+ }
12497
12610
  pruneExpiredChallenges() {
12498
12611
  const now = Date.now();
12499
- const ttlMs = (this.config.challengeTtlSec || DEFAULT_CONFIG.challengeTtlSec) * 1e3;
12612
+ const ttlMs = this.challengeTtlMs();
12500
12613
  for (const [k, v] of this.pending.entries()) if (now - v.createdAt > ttlMs) this.pending.delete(k);
12501
12614
  }
12502
12615
  base64urlEncode(bytes) {
@@ -12622,6 +12735,105 @@ var AuthWebauthnAddon = class extends BaseAddon {
12622
12735
  await this.store.updateCounter(credentialId, verification.authenticationInfo.newCounter);
12623
12736
  return { verified: true };
12624
12737
  }
12738
+ /**
12739
+ * Usernameless (discoverable-credential) login — leg 1. Issues
12740
+ * assertion options with an EMPTY `allowCredentials` list so the
12741
+ * browser offers every resident passkey it holds for this RP, and
12742
+ * `userVerification: 'required'` because the passkey is the ONLY
12743
+ * factor (it replaces both username/password and the 2FA leg).
12744
+ * The challenge is stored server-side keyed by its own value and is
12745
+ * NOT bound to any user — `finishDiscoverableAuthentication` resolves
12746
+ * the owner from the asserted credential id.
12747
+ */
12748
+ async beginDiscoverableAuthentication() {
12749
+ if (!this.store) throw new Error("passkey store unavailable");
12750
+ this.pruneExpiredChallenges();
12751
+ const options = await generateAuthenticationOptions({
12752
+ rpID: this.resolveRpID(),
12753
+ allowCredentials: [],
12754
+ userVerification: "required"
12755
+ });
12756
+ this.pending.set(options.challenge, {
12757
+ challenge: options.challenge,
12758
+ userId: "",
12759
+ kind: "discoverable-authentication",
12760
+ createdAt: Date.now()
12761
+ });
12762
+ return { optionsJSON: options };
12763
+ }
12764
+ /**
12765
+ * Usernameless login — leg 2. Resolves the credential by the
12766
+ * response's credential id, verifies the assertion against the stored
12767
+ * challenge + that credential's public key/counter, and returns the
12768
+ * OWNING userId. Fail-closed: every anomaly returns
12769
+ * `{ verified: false, userId: null }`.
12770
+ *
12771
+ * Replay protection: the pending challenge is consumed (deleted)
12772
+ * BEFORE verification, so an assertion can be spent exactly once.
12773
+ * The TTL is enforced here too — pruning alone only runs on begin
12774
+ * legs, which an attacker controls the timing of.
12775
+ */
12776
+ async finishDiscoverableAuthentication(response) {
12777
+ if (!this.store) return {
12778
+ verified: false,
12779
+ userId: null
12780
+ };
12781
+ const challenge = this.decodeClientData(response)?.challenge;
12782
+ if (!challenge) return {
12783
+ verified: false,
12784
+ userId: null
12785
+ };
12786
+ const pending = this.pending.get(challenge);
12787
+ if (!pending || pending.kind !== "discoverable-authentication") return {
12788
+ verified: false,
12789
+ userId: null
12790
+ };
12791
+ this.pending.delete(challenge);
12792
+ if (Date.now() - pending.createdAt > this.challengeTtlMs()) return {
12793
+ verified: false,
12794
+ userId: null
12795
+ };
12796
+ const credentialId = typeof response["id"] === "string" ? response["id"] : "";
12797
+ const stored = await this.store.findByCredentialId(credentialId);
12798
+ if (!stored) return {
12799
+ verified: false,
12800
+ userId: null
12801
+ };
12802
+ let verification;
12803
+ try {
12804
+ verification = await verifyAuthenticationResponse({
12805
+ response,
12806
+ expectedChallenge: challenge,
12807
+ expectedRPID: this.resolveRpID(),
12808
+ expectedOrigin: this.resolveOrigin(),
12809
+ requireUserVerification: true,
12810
+ credential: {
12811
+ id: stored.credentialId,
12812
+ publicKey: this.base64urlDecode(stored.publicKey),
12813
+ counter: stored.counter,
12814
+ transports: stored.transports
12815
+ }
12816
+ });
12817
+ } catch (err) {
12818
+ this.ctx.logger.warn("Discoverable passkey verification threw", { meta: {
12819
+ credentialId,
12820
+ err: err instanceof Error ? err.message : String(err)
12821
+ } });
12822
+ return {
12823
+ verified: false,
12824
+ userId: null
12825
+ };
12826
+ }
12827
+ if (!verification.verified || !verification.authenticationInfo) return {
12828
+ verified: false,
12829
+ userId: null
12830
+ };
12831
+ await this.store.updateCounter(credentialId, verification.authenticationInfo.newCounter);
12832
+ return {
12833
+ verified: true,
12834
+ userId: stored.userId
12835
+ };
12836
+ }
12625
12837
  async listPasskeys(userId) {
12626
12838
  if (!this.store) return [];
12627
12839
  return (await this.store.findByUserId(userId)).map((r) => ({
@@ -12632,6 +12844,20 @@ var AuthWebauthnAddon = class extends BaseAddon {
12632
12844
  transports: [...r.transports]
12633
12845
  }));
12634
12846
  }
12847
+ /**
12848
+ * Second-factor preference (opt-in, default OFF). Enrolling a passkey
12849
+ * only enables passkey-first sign-in; the password login flow demands
12850
+ * the passkey second leg ONLY when this flag is explicitly enabled.
12851
+ */
12852
+ async getSecondFactorPreference(userId) {
12853
+ if (!this.store) return { enabled: false };
12854
+ return { enabled: await this.store.getSecondFactorPreference(userId) };
12855
+ }
12856
+ async setSecondFactorPreference(userId, enabled) {
12857
+ if (!this.store) throw new Error("passkey store unavailable");
12858
+ await this.store.setSecondFactorPreference(userId, enabled);
12859
+ return { success: true };
12860
+ }
12635
12861
  async removePasskey(userId, credentialId) {
12636
12862
  if (!this.store) throw new Error("passkey store unavailable");
12637
12863
  const existing = await this.store.findByCredentialId(credentialId);
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.1.36",
39
+ version: "1.1.39",
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.22",
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-Du23U8IZ.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.6",
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",