@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.
@@ -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-CS-GzG-v.js");
7
7
  let stream = require("stream");
8
8
  stream = require_chunk.__toESM(stream, 1);
9
9
  let http = require("http");
@@ -12123,6 +12123,36 @@ async function verifyAuthenticationResponse(options) {
12123
12123
  * auth like Touch ID), `usb`, `nfc`, `ble`.
12124
12124
  */
12125
12125
  var COLLECTION = "user_passkeys";
12126
+ /**
12127
+ * Per-user passkey preferences — one row per userId, keyed upserts via
12128
+ * the settings-store `set` surface. Today a single flag: whether the
12129
+ * user OPTED IN to their passkey doubling as a mandatory second factor
12130
+ * after a password login (default OFF — enrolling a passkey only
12131
+ * enables passkey-first sign-in).
12132
+ */
12133
+ var PREF_COLLECTION = "user_passkey_prefs";
12134
+ var PREF_COLUMNS = [
12135
+ {
12136
+ name: "userId",
12137
+ type: "TEXT",
12138
+ primaryKey: true,
12139
+ notNull: true
12140
+ },
12141
+ {
12142
+ name: "secondFactorEnabled",
12143
+ type: "BOOLEAN",
12144
+ notNull: true
12145
+ },
12146
+ {
12147
+ name: "updatedAt",
12148
+ type: "INTEGER",
12149
+ notNull: true
12150
+ }
12151
+ ];
12152
+ var StoredPreferenceSchema = require_dist.object({
12153
+ secondFactorEnabled: require_dist.boolean(),
12154
+ updatedAt: require_dist.number()
12155
+ });
12126
12156
  var COLUMNS = [
12127
12157
  {
12128
12158
  name: "credentialId",
@@ -12173,6 +12203,10 @@ async function declarePasskeySchema(store) {
12173
12203
  columns: ["userId"]
12174
12204
  }]
12175
12205
  });
12206
+ await store.declareCollection.mutate({
12207
+ collection: PREF_COLLECTION,
12208
+ columns: PREF_COLUMNS
12209
+ });
12176
12210
  }
12177
12211
  function parseRow(data) {
12178
12212
  const credentialId = data["credentialId"];
@@ -12248,6 +12282,31 @@ var PasskeyStore = class {
12248
12282
  key: credentialId
12249
12283
  });
12250
12284
  }
12285
+ /**
12286
+ * Whether `userId` opted in to the passkey-as-second-factor gate.
12287
+ * Fail-safe: a missing or malformed row reads as `false` — the
12288
+ * password login flow must never demand a factor the user did not
12289
+ * explicitly enable.
12290
+ */
12291
+ async getSecondFactorPreference(userId) {
12292
+ const raw = await this.store.get.query({
12293
+ collection: PREF_COLLECTION,
12294
+ key: userId
12295
+ });
12296
+ const parsed = StoredPreferenceSchema.safeParse(raw);
12297
+ return parsed.success ? parsed.data.secondFactorEnabled : false;
12298
+ }
12299
+ async setSecondFactorPreference(userId, enabled) {
12300
+ const value = {
12301
+ secondFactorEnabled: enabled,
12302
+ updatedAt: Date.now()
12303
+ };
12304
+ await this.store.set.mutate({
12305
+ collection: PREF_COLLECTION,
12306
+ key: userId,
12307
+ value
12308
+ });
12309
+ }
12251
12310
  };
12252
12311
  //#endregion
12253
12312
  //#region src/webauthn/widget-catalog.ts
@@ -12257,61 +12316,97 @@ var AUTH_WEBAUTHN_REMOTE_NAME = "addon_auth_webauthn_widgets";
12257
12316
  var AUTH_WEBAUTHN_BUNDLE = "remoteEntry.js";
12258
12317
  /** Manifest addon id — the public bundle namespace + login-method addonId. */
12259
12318
  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"
12319
+ var authWebauthnWidgets = [
12320
+ {
12321
+ tab: "dashboard",
12322
+ label: "Passkeys",
12323
+ kind: "remote",
12324
+ remote: {
12325
+ remoteName: AUTH_WEBAUTHN_REMOTE_NAME,
12326
+ exposedModule: "./widgets",
12327
+ componentKey: "passkey-enrollment"
12328
+ },
12329
+ stableId: "passkey-enrollment",
12330
+ description: "Enroll and manage passkeys (WebAuthn) for your account.",
12331
+ icon: "fingerprint",
12332
+ bundle: AUTH_WEBAUTHN_BUNDLE,
12333
+ hosts: ["dashboard"],
12334
+ requires: {
12335
+ deviceContext: false,
12336
+ integrationContext: false
12337
+ },
12338
+ defaultSize: "lg",
12339
+ allowedSizes: [
12340
+ "md",
12341
+ "lg",
12342
+ "xl"
12343
+ ],
12344
+ defaultColumns: 12,
12345
+ defaultRows: 3,
12346
+ preAuth: false
12295
12347
  },
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
12348
+ {
12349
+ tab: "dashboard",
12350
+ label: "Passkey login",
12351
+ kind: "remote",
12352
+ remote: {
12353
+ remoteName: AUTH_WEBAUTHN_REMOTE_NAME,
12354
+ exposedModule: "./widgets",
12355
+ componentKey: "passkey-login"
12356
+ },
12357
+ stableId: "passkey-login",
12358
+ description: "Login-page passkey second-factor ceremony.",
12359
+ icon: "fingerprint",
12360
+ bundle: AUTH_WEBAUTHN_BUNDLE,
12361
+ hosts: ["dashboard"],
12362
+ requires: {
12363
+ deviceContext: false,
12364
+ integrationContext: false
12365
+ },
12366
+ defaultSize: "sm",
12367
+ allowedSizes: ["sm"],
12368
+ defaultColumns: 4,
12369
+ defaultRows: 1,
12370
+ preAuth: true
12304
12371
  },
12305
- defaultSize: "sm",
12306
- allowedSizes: ["sm"],
12307
- defaultColumns: 4,
12308
- defaultRows: 1,
12309
- preAuth: true
12310
- }];
12372
+ {
12373
+ tab: "dashboard",
12374
+ label: "Passkey sign-in",
12375
+ kind: "remote",
12376
+ remote: {
12377
+ remoteName: AUTH_WEBAUTHN_REMOTE_NAME,
12378
+ exposedModule: "./widgets",
12379
+ componentKey: "passkey-direct-login"
12380
+ },
12381
+ stableId: "passkey-direct-login",
12382
+ description: "Login-page usernameless (discoverable-credential) passkey sign-in.",
12383
+ icon: "fingerprint",
12384
+ bundle: AUTH_WEBAUTHN_BUNDLE,
12385
+ hosts: ["dashboard"],
12386
+ requires: {
12387
+ deviceContext: false,
12388
+ integrationContext: false
12389
+ },
12390
+ defaultSize: "sm",
12391
+ allowedSizes: ["sm"],
12392
+ defaultColumns: 4,
12393
+ defaultRows: 1,
12394
+ preAuth: true
12395
+ }
12396
+ ];
12311
12397
  /**
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`.
12398
+ * Login-method contributionsaggregated by the public
12399
+ * `auth.listLoginMethods` procedure, which stamps a public `bundleUrl`
12400
+ * from `addonId` + `bundle`.
12401
+ *
12402
+ * - `passkey-login` (second-factor) — the post-password 2FA ceremony,
12403
+ * mounted only when the challenged user has the `passkey` factor.
12404
+ * - `passkey-direct-login` (primary) — usernameless sign-in via the
12405
+ * standard WebAuthn discoverable-credentials flow. Contributed
12406
+ * unconditionally while the addon is registered: the browser's own
12407
+ * passkey picker is the honest gate (it simply offers nothing when
12408
+ * no resident credential matches the RP) — probing the server for
12409
+ * "does anyone have a passkey" pre-auth would leak enrollment state.
12315
12410
  */
12316
12411
  var authWebauthnLoginMethods = [{
12317
12412
  kind: "widget",
@@ -12324,6 +12419,17 @@ var authWebauthnLoginMethods = [{
12324
12419
  componentKey: "passkey-login"
12325
12420
  },
12326
12421
  stage: "second-factor"
12422
+ }, {
12423
+ kind: "widget",
12424
+ id: `${AUTH_WEBAUTHN_ADDON_ID}/passkey-direct-login`,
12425
+ addonId: AUTH_WEBAUTHN_ADDON_ID,
12426
+ bundle: AUTH_WEBAUTHN_BUNDLE,
12427
+ remote: {
12428
+ remoteName: AUTH_WEBAUTHN_REMOTE_NAME,
12429
+ exposedModule: "./widgets",
12430
+ componentKey: "passkey-direct-login"
12431
+ },
12432
+ stage: "primary"
12327
12433
  }];
12328
12434
  //#endregion
12329
12435
  //#region src/webauthn/auth-webauthn.addon.ts
@@ -12388,8 +12494,12 @@ var AuthWebauthnAddon = class extends require_dist.BaseAddon {
12388
12494
  finishRegistration: async ({ userId, response, label }) => this.finishRegistration(userId, response, label),
12389
12495
  beginAuthentication: async ({ userId }) => this.beginAuthentication(userId),
12390
12496
  finishAuthentication: async ({ userId, response }) => this.finishAuthentication(userId, response),
12497
+ beginDiscoverableAuthentication: async () => this.beginDiscoverableAuthentication(),
12498
+ finishDiscoverableAuthentication: async ({ response }) => this.finishDiscoverableAuthentication(response),
12391
12499
  listPasskeys: async ({ userId }) => this.listPasskeys(userId),
12392
- removePasskey: async ({ userId, credentialId }) => this.removePasskey(userId, credentialId)
12500
+ removePasskey: async ({ userId, credentialId }) => this.removePasskey(userId, credentialId),
12501
+ getSecondFactorPreference: async ({ userId }) => this.getSecondFactorPreference(userId),
12502
+ setSecondFactorPreference: async ({ userId, enabled }) => this.setSecondFactorPreference(userId, enabled)
12393
12503
  };
12394
12504
  const widgetsProvider = { listWidgets: async () => authWebauthnWidgets };
12395
12505
  const loginMethodProvider = { getLoginMethods: async () => authWebauthnLoginMethods };
@@ -12505,9 +12615,12 @@ var AuthWebauthnAddon = class extends require_dist.BaseAddon {
12505
12615
  if (this.config.origin && this.config.origin.trim()) return this.config.origin.trim().replace(/\/+$/, "");
12506
12616
  return (process.env["CAMSTACK_PUBLIC_ORIGIN"] || "http://localhost:4443").replace(/\/+$/, "");
12507
12617
  }
12618
+ challengeTtlMs() {
12619
+ return (this.config.challengeTtlSec || DEFAULT_CONFIG.challengeTtlSec) * 1e3;
12620
+ }
12508
12621
  pruneExpiredChallenges() {
12509
12622
  const now = Date.now();
12510
- const ttlMs = (this.config.challengeTtlSec || DEFAULT_CONFIG.challengeTtlSec) * 1e3;
12623
+ const ttlMs = this.challengeTtlMs();
12511
12624
  for (const [k, v] of this.pending.entries()) if (now - v.createdAt > ttlMs) this.pending.delete(k);
12512
12625
  }
12513
12626
  base64urlEncode(bytes) {
@@ -12633,6 +12746,105 @@ var AuthWebauthnAddon = class extends require_dist.BaseAddon {
12633
12746
  await this.store.updateCounter(credentialId, verification.authenticationInfo.newCounter);
12634
12747
  return { verified: true };
12635
12748
  }
12749
+ /**
12750
+ * Usernameless (discoverable-credential) login — leg 1. Issues
12751
+ * assertion options with an EMPTY `allowCredentials` list so the
12752
+ * browser offers every resident passkey it holds for this RP, and
12753
+ * `userVerification: 'required'` because the passkey is the ONLY
12754
+ * factor (it replaces both username/password and the 2FA leg).
12755
+ * The challenge is stored server-side keyed by its own value and is
12756
+ * NOT bound to any user — `finishDiscoverableAuthentication` resolves
12757
+ * the owner from the asserted credential id.
12758
+ */
12759
+ async beginDiscoverableAuthentication() {
12760
+ if (!this.store) throw new Error("passkey store unavailable");
12761
+ this.pruneExpiredChallenges();
12762
+ const options = await generateAuthenticationOptions({
12763
+ rpID: this.resolveRpID(),
12764
+ allowCredentials: [],
12765
+ userVerification: "required"
12766
+ });
12767
+ this.pending.set(options.challenge, {
12768
+ challenge: options.challenge,
12769
+ userId: "",
12770
+ kind: "discoverable-authentication",
12771
+ createdAt: Date.now()
12772
+ });
12773
+ return { optionsJSON: options };
12774
+ }
12775
+ /**
12776
+ * Usernameless login — leg 2. Resolves the credential by the
12777
+ * response's credential id, verifies the assertion against the stored
12778
+ * challenge + that credential's public key/counter, and returns the
12779
+ * OWNING userId. Fail-closed: every anomaly returns
12780
+ * `{ verified: false, userId: null }`.
12781
+ *
12782
+ * Replay protection: the pending challenge is consumed (deleted)
12783
+ * BEFORE verification, so an assertion can be spent exactly once.
12784
+ * The TTL is enforced here too — pruning alone only runs on begin
12785
+ * legs, which an attacker controls the timing of.
12786
+ */
12787
+ async finishDiscoverableAuthentication(response) {
12788
+ if (!this.store) return {
12789
+ verified: false,
12790
+ userId: null
12791
+ };
12792
+ const challenge = this.decodeClientData(response)?.challenge;
12793
+ if (!challenge) return {
12794
+ verified: false,
12795
+ userId: null
12796
+ };
12797
+ const pending = this.pending.get(challenge);
12798
+ if (!pending || pending.kind !== "discoverable-authentication") return {
12799
+ verified: false,
12800
+ userId: null
12801
+ };
12802
+ this.pending.delete(challenge);
12803
+ if (Date.now() - pending.createdAt > this.challengeTtlMs()) return {
12804
+ verified: false,
12805
+ userId: null
12806
+ };
12807
+ const credentialId = typeof response["id"] === "string" ? response["id"] : "";
12808
+ const stored = await this.store.findByCredentialId(credentialId);
12809
+ if (!stored) return {
12810
+ verified: false,
12811
+ userId: null
12812
+ };
12813
+ let verification;
12814
+ try {
12815
+ verification = await verifyAuthenticationResponse({
12816
+ response,
12817
+ expectedChallenge: challenge,
12818
+ expectedRPID: this.resolveRpID(),
12819
+ expectedOrigin: this.resolveOrigin(),
12820
+ requireUserVerification: true,
12821
+ credential: {
12822
+ id: stored.credentialId,
12823
+ publicKey: this.base64urlDecode(stored.publicKey),
12824
+ counter: stored.counter,
12825
+ transports: stored.transports
12826
+ }
12827
+ });
12828
+ } catch (err) {
12829
+ this.ctx.logger.warn("Discoverable passkey verification threw", { meta: {
12830
+ credentialId,
12831
+ err: err instanceof Error ? err.message : String(err)
12832
+ } });
12833
+ return {
12834
+ verified: false,
12835
+ userId: null
12836
+ };
12837
+ }
12838
+ if (!verification.verified || !verification.authenticationInfo) return {
12839
+ verified: false,
12840
+ userId: null
12841
+ };
12842
+ await this.store.updateCounter(credentialId, verification.authenticationInfo.newCounter);
12843
+ return {
12844
+ verified: true,
12845
+ userId: stored.userId
12846
+ };
12847
+ }
12636
12848
  async listPasskeys(userId) {
12637
12849
  if (!this.store) return [];
12638
12850
  return (await this.store.findByUserId(userId)).map((r) => ({
@@ -12643,6 +12855,20 @@ var AuthWebauthnAddon = class extends require_dist.BaseAddon {
12643
12855
  transports: [...r.transports]
12644
12856
  }));
12645
12857
  }
12858
+ /**
12859
+ * Second-factor preference (opt-in, default OFF). Enrolling a passkey
12860
+ * only enables passkey-first sign-in; the password login flow demands
12861
+ * the passkey second leg ONLY when this flag is explicitly enabled.
12862
+ */
12863
+ async getSecondFactorPreference(userId) {
12864
+ if (!this.store) return { enabled: false };
12865
+ return { enabled: await this.store.getSecondFactorPreference(userId) };
12866
+ }
12867
+ async setSecondFactorPreference(userId, enabled) {
12868
+ if (!this.store) throw new Error("passkey store unavailable");
12869
+ await this.store.setSecondFactorPreference(userId, enabled);
12870
+ return { success: true };
12871
+ }
12646
12872
  async removePasskey(userId, credentialId) {
12647
12873
  if (!this.store) throw new Error("passkey store unavailable");
12648
12874
  const existing = await this.store.findByCredentialId(credentialId);