@puzzle-ui/auth 0.0.0-stage → 0.5.0

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.
Files changed (95) hide show
  1. package/README.md +301 -2
  2. package/dist/LICENSE +21 -0
  3. package/dist/auth.lifecycle.manager.cjs +835 -0
  4. package/dist/auth.lifecycle.manager.cjs.map +1 -0
  5. package/dist/auth.lifecycle.manager.d.cts +115 -0
  6. package/dist/auth.lifecycle.manager.d.ts +115 -0
  7. package/dist/auth.lifecycle.manager.js +835 -0
  8. package/dist/auth.lifecycle.manager.js.map +1 -0
  9. package/dist/auth.manager.cjs +454 -0
  10. package/dist/auth.manager.cjs.map +1 -0
  11. package/dist/auth.manager.d.cts +54 -0
  12. package/dist/auth.manager.d.ts +54 -0
  13. package/dist/auth.manager.js +454 -0
  14. package/dist/auth.manager.js.map +1 -0
  15. package/dist/auth.mfa.manager.cjs +1488 -0
  16. package/dist/auth.mfa.manager.cjs.map +1 -0
  17. package/dist/auth.mfa.manager.d.cts +100 -0
  18. package/dist/auth.mfa.manager.d.ts +100 -0
  19. package/dist/auth.mfa.manager.js +1488 -0
  20. package/dist/auth.mfa.manager.js.map +1 -0
  21. package/dist/auth.rate-limit.cjs +78 -0
  22. package/dist/auth.rate-limit.cjs.map +1 -0
  23. package/dist/auth.rate-limit.d.cts +24 -0
  24. package/dist/auth.rate-limit.d.ts +24 -0
  25. package/dist/auth.rate-limit.js +78 -0
  26. package/dist/auth.rate-limit.js.map +1 -0
  27. package/dist/auth.step-up.manager.cjs +269 -0
  28. package/dist/auth.step-up.manager.cjs.map +1 -0
  29. package/dist/auth.step-up.manager.d.cts +74 -0
  30. package/dist/auth.step-up.manager.d.ts +74 -0
  31. package/dist/auth.step-up.manager.js +268 -0
  32. package/dist/auth.step-up.manager.js.map +1 -0
  33. package/dist/auth.storage.cjs +164 -0
  34. package/dist/auth.storage.cjs.map +1 -0
  35. package/dist/auth.storage.d.cts +29 -0
  36. package/dist/auth.storage.d.ts +29 -0
  37. package/dist/auth.storage.js +148 -0
  38. package/dist/auth.storage.js.map +1 -0
  39. package/dist/errors/auth.errors.cjs +101 -0
  40. package/dist/errors/auth.errors.cjs.map +1 -0
  41. package/dist/errors/auth.errors.d.cts +62 -0
  42. package/dist/errors/auth.errors.d.ts +62 -0
  43. package/dist/errors/auth.errors.js +90 -0
  44. package/dist/errors/auth.errors.js.map +1 -0
  45. package/dist/index.cjs +54 -0
  46. package/dist/index.d.cts +21 -0
  47. package/dist/index.d.ts +21 -0
  48. package/dist/index.js +13 -0
  49. package/dist/providers/mfa-provider.types.d.cts +66 -0
  50. package/dist/providers/mfa-provider.types.d.ts +66 -0
  51. package/dist/providers/provider.types.d.cts +34 -0
  52. package/dist/providers/provider.types.d.ts +34 -0
  53. package/dist/providers/simplewebauthn-provider.cjs +185 -0
  54. package/dist/providers/simplewebauthn-provider.cjs.map +1 -0
  55. package/dist/providers/simplewebauthn-provider.d.cts +35 -0
  56. package/dist/providers/simplewebauthn-provider.d.ts +35 -0
  57. package/dist/providers/simplewebauthn-provider.js +185 -0
  58. package/dist/providers/simplewebauthn-provider.js.map +1 -0
  59. package/dist/providers/webcrypto-secret-protector.cjs +118 -0
  60. package/dist/providers/webcrypto-secret-protector.cjs.map +1 -0
  61. package/dist/providers/webcrypto-secret-protector.d.cts +14 -0
  62. package/dist/providers/webcrypto-secret-protector.d.ts +14 -0
  63. package/dist/providers/webcrypto-secret-protector.js +118 -0
  64. package/dist/providers/webcrypto-secret-protector.js.map +1 -0
  65. package/dist/types/auth-lifecycle.types.d.cts +53 -0
  66. package/dist/types/auth-lifecycle.types.d.ts +53 -0
  67. package/dist/types/auth-manager.types.d.cts +106 -0
  68. package/dist/types/auth-manager.types.d.ts +106 -0
  69. package/dist/types/credential.types.d.cts +9 -0
  70. package/dist/types/credential.types.d.ts +9 -0
  71. package/dist/types/mfa.types.d.cts +244 -0
  72. package/dist/types/mfa.types.d.ts +244 -0
  73. package/dist/types/state.types.d.cts +33 -0
  74. package/dist/types/state.types.d.ts +33 -0
  75. package/dist/types/user.types.d.cts +9 -0
  76. package/dist/types/user.types.d.ts +9 -0
  77. package/dist/utils/auth-crypto.utils.cjs +38 -0
  78. package/dist/utils/auth-crypto.utils.cjs.map +1 -0
  79. package/dist/utils/auth-crypto.utils.d.cts +4 -0
  80. package/dist/utils/auth-crypto.utils.d.ts +4 -0
  81. package/dist/utils/auth-crypto.utils.js +35 -0
  82. package/dist/utils/auth-crypto.utils.js.map +1 -0
  83. package/dist/utils/email.utils.cjs +20 -0
  84. package/dist/utils/email.utils.cjs.map +1 -0
  85. package/dist/utils/email.utils.d.cts +2 -0
  86. package/dist/utils/email.utils.d.ts +2 -0
  87. package/dist/utils/email.utils.js +19 -0
  88. package/dist/utils/email.utils.js.map +1 -0
  89. package/dist/utils/totp.utils.cjs +145 -0
  90. package/dist/utils/totp.utils.cjs.map +1 -0
  91. package/dist/utils/totp.utils.d.cts +26 -0
  92. package/dist/utils/totp.utils.d.ts +26 -0
  93. package/dist/utils/totp.utils.js +140 -0
  94. package/dist/utils/totp.utils.js.map +1 -0
  95. package/package.json +45 -4
@@ -0,0 +1,1488 @@
1
+ const require_auth_storage = require("./auth.storage.cjs");
2
+ const require_auth_errors = require("./errors/auth.errors.cjs");
3
+ const require_auth_crypto_utils = require("./utils/auth-crypto.utils.cjs");
4
+ const require_totp_utils = require("./utils/totp.utils.cjs");
5
+ let _puzzle_ui_storage = require("@puzzle-ui/storage");
6
+ // src/auth.mfa.manager.ts
7
+ const DEFAULT_CHALLENGE_LIFETIME_SECONDS = 300;
8
+ const DEFAULT_RECOVERY_CODE_COUNT = 10;
9
+ const DEFAULT_MAXIMUM_CHALLENGE_ATTEMPTS = 5;
10
+ const DEFAULT_TOTP = {
11
+ digits: 6,
12
+ periodSeconds: 30,
13
+ window: 1
14
+ };
15
+ const whereEquals = (value) => ({
16
+ operator: "eq",
17
+ value
18
+ });
19
+ const toPublicUser = (record) => ({
20
+ id: record.id,
21
+ email: record.email,
22
+ emailVerifiedAt: record.emailVerifiedAt,
23
+ name: record.name,
24
+ image: record.image,
25
+ createdAt: record.createdAt,
26
+ updatedAt: record.updatedAt
27
+ });
28
+ const toPublicSession = (record) => {
29
+ if (record.kind !== "authenticated" || !record.userId) throw new require_auth_errors.AuthSessionError("An authenticated session must contain a user ID.");
30
+ return {
31
+ id: record.id,
32
+ userId: record.userId,
33
+ kind: "authenticated",
34
+ createdAt: record.createdAt,
35
+ lastSeenAt: record.lastSeenAt,
36
+ expiresAt: record.expiresAt,
37
+ revokedAt: record.revokedAt
38
+ };
39
+ };
40
+ const toPublicFactor = (record) => ({
41
+ id: record.id,
42
+ type: record.type,
43
+ status: record.status,
44
+ label: record.label,
45
+ createdAt: record.createdAt,
46
+ lastUsedAt: record.lastUsedAt
47
+ });
48
+ const normalizeRecoveryCode = (value) => {
49
+ if (typeof value !== "string") return null;
50
+ const normalized = value.replace(/[\s-]/g, "").toUpperCase();
51
+ return /^[A-F0-9]{32}$/.test(normalized) ? normalized : null;
52
+ };
53
+ const generateRecoveryCode = () => {
54
+ return require_auth_crypto_utils.generateSecureAuthToken().slice(0, 32).toUpperCase().match(/.{4}/g).join("-");
55
+ };
56
+ const isRecord = (value) => typeof value === "object" && value !== null;
57
+ const getCredentialId = (response) => {
58
+ if (!isRecord(response) || typeof response.id !== "string" || response.id.length === 0 || response.id.length > 2048) throw new require_auth_errors.AuthMfaChallengeError();
59
+ return response.id;
60
+ };
61
+ /**
62
+ * Encodes random bytes using unpadded base64url.
63
+ * Does not depend on Node.js Buffer or browser btoa.
64
+ */
65
+ const encodeBase64Url = (bytes) => {
66
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
67
+ let result = "";
68
+ let index = 0;
69
+ while (index + 2 < bytes.length) {
70
+ const a = bytes[index++];
71
+ const b = bytes[index++];
72
+ const c = bytes[index++];
73
+ result += alphabet.charAt(a >> 2);
74
+ result += alphabet.charAt((a & 3) << 4 | b >> 4);
75
+ result += alphabet.charAt((b & 15) << 2 | c >> 6);
76
+ result += alphabet.charAt(c & 63);
77
+ }
78
+ if (index < bytes.length) {
79
+ const a = bytes[index++];
80
+ result += alphabet.charAt(a >> 2);
81
+ if (index < bytes.length) {
82
+ const b = bytes[index];
83
+ result += alphabet.charAt((a & 3) << 4 | b >> 4);
84
+ result += alphabet.charAt((b & 15) << 2);
85
+ } else result += alphabet.charAt((a & 3) << 4);
86
+ }
87
+ return result;
88
+ };
89
+ const generateWebAuthnChallenge = () => {
90
+ const cryptoApi = globalThis.crypto;
91
+ if (!cryptoApi?.getRandomValues) throw new require_auth_errors.AuthConfigurationError("This runtime does not provide the required Web Crypto APIs.");
92
+ const bytes = /* @__PURE__ */ new Uint8Array(32);
93
+ cryptoApi.getRandomValues(bytes);
94
+ return encodeBase64Url(bytes);
95
+ };
96
+ const validateLabel = (label, fallback) => {
97
+ const value = label === void 0 ? fallback : label.trim();
98
+ if (typeof value !== "string" || value.length === 0 || value.length > 64) throw new require_auth_errors.AuthValidationError("The authentication factor label must contain between 1 and 64 characters.");
99
+ return value;
100
+ };
101
+ var StorageAuthMfaManager = class {
102
+ storage;
103
+ sessions;
104
+ passwordHasher;
105
+ rateLimitProvider;
106
+ secretProtector;
107
+ webAuthnProvider;
108
+ usersCollection;
109
+ credentialsCollection;
110
+ auditCollection;
111
+ factorsCollection;
112
+ challengesCollection;
113
+ recoveryCodesCollection;
114
+ rateLimitKeySecret;
115
+ challengeLifetimeSeconds;
116
+ recoveryCodeCount;
117
+ issuer;
118
+ totp;
119
+ webAuthn;
120
+ requireVerifiedEmail;
121
+ clock;
122
+ initialized = false;
123
+ initializationPromise = null;
124
+ constructor(dependencies, options) {
125
+ this.storage = dependencies.storage;
126
+ this.sessions = dependencies.sessions;
127
+ this.passwordHasher = dependencies.passwordHasher;
128
+ this.rateLimitProvider = dependencies.rateLimitProvider;
129
+ this.secretProtector = dependencies.secretProtector;
130
+ this.webAuthnProvider = dependencies.webAuthnProvider ?? null;
131
+ if (typeof options.rateLimitKeySecret !== "string" || options.rateLimitKeySecret.length < 32) throw new require_auth_errors.AuthConfigurationError("rateLimitKeySecret must contain at least 32 characters.");
132
+ this.rateLimitKeySecret = options.rateLimitKeySecret;
133
+ this.usersCollection = options.usersCollection ?? "auth_users";
134
+ this.credentialsCollection = options.credentialsCollection ?? "auth_credentials";
135
+ this.auditCollection = options.auditCollection ?? "auth_audit_events";
136
+ this.factorsCollection = options.factorsCollection ?? "auth_mfa_factors";
137
+ this.challengesCollection = options.challengesCollection ?? "auth_mfa_challenges";
138
+ this.recoveryCodesCollection = options.recoveryCodesCollection ?? "auth_recovery_codes";
139
+ this.challengeLifetimeSeconds = options.challengeLifetimeSeconds ?? DEFAULT_CHALLENGE_LIFETIME_SECONDS;
140
+ this.recoveryCodeCount = options.recoveryCodeCount ?? DEFAULT_RECOVERY_CODE_COUNT;
141
+ this.issuer = options.issuer ?? "Puzzle Auth";
142
+ this.totp = {
143
+ ...DEFAULT_TOTP,
144
+ ...options.totp
145
+ };
146
+ this.webAuthn = options.webAuthn ?? null;
147
+ this.requireVerifiedEmail = options.requireVerifiedEmail ?? false;
148
+ this.clock = options.clock ?? (() => /* @__PURE__ */ new Date());
149
+ if (!Number.isInteger(this.challengeLifetimeSeconds) || this.challengeLifetimeSeconds < 30 || this.challengeLifetimeSeconds > 600) throw new require_auth_errors.AuthConfigurationError("MFA challenge lifetime must be between 30 and 600 seconds.");
150
+ if (!Number.isInteger(this.recoveryCodeCount) || this.recoveryCodeCount < 5 || this.recoveryCodeCount > 20) throw new require_auth_errors.AuthConfigurationError("Recovery code count must be between 5 and 20.");
151
+ if (!Number.isInteger(this.totp.digits) || this.totp.digits < 6 || this.totp.digits > 8 || !Number.isInteger(this.totp.periodSeconds) || this.totp.periodSeconds < 15 || this.totp.periodSeconds > 120 || !Number.isInteger(this.totp.window) || this.totp.window < 0 || this.totp.window > 2) throw new require_auth_errors.AuthConfigurationError("The TOTP configuration is invalid.");
152
+ if (Boolean(this.webAuthn) !== Boolean(this.webAuthnProvider)) throw new require_auth_errors.AuthConfigurationError("WebAuthn configuration and WebAuthnProvider must be supplied together.");
153
+ if (this.webAuthn) this.validateWebAuthnConfiguration(this.webAuthn);
154
+ }
155
+ async revokeChallenges(userId) {
156
+ this.assertInitialized();
157
+ if (typeof userId !== "string" || userId.trim().length === 0) throw new require_auth_errors.AuthValidationError("A valid user ID is required.");
158
+ const [signInChallenges, enrollmentChallenges] = await Promise.all([this.storage.findMany(this.challengesCollection, { where: {
159
+ userId: whereEquals(userId),
160
+ purpose: whereEquals("sign-in"),
161
+ consumedAt: whereEquals(null)
162
+ } }), this.storage.findMany(this.challengesCollection, { where: {
163
+ userId: whereEquals(userId),
164
+ purpose: whereEquals("enrollment"),
165
+ consumedAt: whereEquals(null)
166
+ } })]);
167
+ const challenges = [...signInChallenges, ...enrollmentChallenges];
168
+ if (challenges.length === 0) return;
169
+ const now = this.now();
170
+ const operations = challenges.map((challenge) => ({
171
+ type: "update",
172
+ collection: this.challengesCollection,
173
+ id: challenge.id,
174
+ condition: { expectedRevision: challenge.revision },
175
+ changes: {
176
+ consumedAt: now,
177
+ updatedAt: now
178
+ }
179
+ }));
180
+ try {
181
+ await this.storage.atomicBatch(operations);
182
+ } catch (error) {
183
+ throw new require_auth_errors.AuthProviderError("Could not revoke pending MFA challenges.", { cause: error });
184
+ }
185
+ }
186
+ validateWebAuthnConfiguration(config) {
187
+ let origin;
188
+ try {
189
+ origin = new URL(config.origin);
190
+ } catch {
191
+ throw new require_auth_errors.AuthConfigurationError("WebAuthn origin must be a valid URL.");
192
+ }
193
+ const isLocalhost = origin.hostname === "localhost" || origin.hostname === "127.0.0.1" || origin.hostname === "[::1]";
194
+ if (origin.origin !== config.origin || origin.protocol !== "https:" && !isLocalhost) throw new require_auth_errors.AuthConfigurationError("WebAuthn requires an exact HTTPS origin, except for localhost development.");
195
+ if (origin.hostname !== config.rpId && !origin.hostname.endsWith(`.${config.rpId}`)) throw new require_auth_errors.AuthConfigurationError("The WebAuthn RP ID must be the origin hostname or a parent domain.");
196
+ if (config.rpName.trim().length === 0 || config.rpId.trim().length === 0) throw new require_auth_errors.AuthConfigurationError("WebAuthn RP name and RP ID are required.");
197
+ }
198
+ async initialize() {
199
+ if (!this.initializationPromise) {
200
+ const initialization = (async () => {
201
+ if (!this.storage.capabilities.uniqueConstraints || !this.storage.capabilities.conditionalWrites || !this.storage.capabilities.atomicBatch) throw new require_auth_errors.AuthConfigurationError("MFA requires unique constraints, conditional writes and atomic batches.");
202
+ await require_auth_storage.initializeAuthStorage(this.storage, {
203
+ usersCollection: this.usersCollection,
204
+ credentialsCollection: this.credentialsCollection,
205
+ auditCollection: this.auditCollection,
206
+ factorsCollection: this.factorsCollection,
207
+ challengesCollection: this.challengesCollection,
208
+ recoveryCodesCollection: this.recoveryCodesCollection
209
+ });
210
+ this.initialized = true;
211
+ })();
212
+ this.initializationPromise = initialization.catch((error) => {
213
+ this.initializationPromise = null;
214
+ this.initialized = false;
215
+ throw error;
216
+ });
217
+ }
218
+ return this.initializationPromise;
219
+ }
220
+ assertInitialized() {
221
+ if (!this.initialized) throw new require_auth_errors.AuthConfigurationError("MFA manager must be initialized before use.");
222
+ }
223
+ now() {
224
+ const value = this.clock();
225
+ if (!(value instanceof Date) || !Number.isFinite(value.getTime())) throw new require_auth_errors.AuthConfigurationError("The configured MFA clock returned an invalid date.");
226
+ return value;
227
+ }
228
+ async enforceRateLimit(action, ipAddress, userId) {
229
+ if (typeof ipAddress !== "string" || ipAddress.trim().length === 0 || ipAddress.trim().length > 128) throw new require_auth_errors.AuthValidationError("A valid client IP address is required.");
230
+ const rule = action.includes("verify") ? {
231
+ limit: 5,
232
+ windowSeconds: 300
233
+ } : {
234
+ limit: 10,
235
+ windowSeconds: 900
236
+ };
237
+ const scopes = [{
238
+ type: "ip",
239
+ value: ipAddress.trim()
240
+ }, ...userId ? [{
241
+ type: "user",
242
+ value: userId
243
+ }] : []];
244
+ for (const scope of scopes) {
245
+ const key = await require_auth_crypto_utils.createRateLimitKey(this.rateLimitKeySecret, `auth:mfa:${action}:${scope.type}:${scope.value}`);
246
+ let result;
247
+ try {
248
+ result = await this.rateLimitProvider.consume({
249
+ key,
250
+ limit: rule.limit,
251
+ windowSeconds: rule.windowSeconds
252
+ });
253
+ } catch (error) {
254
+ throw new require_auth_errors.AuthProviderError("The MFA rate limiter is unavailable.", { cause: error });
255
+ }
256
+ if (!result || typeof result.allowed !== "boolean" || !(result.resetAt instanceof Date) || !Number.isFinite(result.resetAt.getTime())) throw new require_auth_errors.AuthProviderError("The MFA rate limiter returned an invalid result.");
257
+ if (!result.allowed) throw new require_auth_errors.AuthRateLimitError(result.resetAt);
258
+ }
259
+ }
260
+ createAuditRecord(action, userId, metadata, sessionId = null) {
261
+ const now = this.now();
262
+ return {
263
+ id: require_auth_crypto_utils.generateAuthId("auth-audit"),
264
+ action,
265
+ userId,
266
+ sessionId,
267
+ metadata,
268
+ createdAt: now,
269
+ updatedAt: now,
270
+ revision: 1
271
+ };
272
+ }
273
+ async getAuthenticatedContext(sessionToken) {
274
+ if (typeof sessionToken !== "string" || sessionToken.length === 0) throw new require_auth_errors.AuthSessionError("An authenticated session is required.");
275
+ let session;
276
+ try {
277
+ session = await this.sessions.validate(sessionToken);
278
+ } catch (error) {
279
+ throw new require_auth_errors.AuthSessionError("Could not validate the session.", { cause: error });
280
+ }
281
+ if (!session || session.kind !== "authenticated" || !session.userId) throw new require_auth_errors.AuthSessionError("An authenticated session is required.");
282
+ const user = await this.storage.findById(this.usersCollection, session.userId);
283
+ if (!user || user.deactivatedAt !== null) throw new require_auth_errors.AuthSessionError("The authenticated account is unavailable.");
284
+ if (user.sessionsInvalidatedAt && session.createdAt.getTime() <= user.sessionsInvalidatedAt.getTime()) throw new require_auth_errors.AuthSessionError("The session is no longer valid.");
285
+ if (this.requireVerifiedEmail && !user.emailVerifiedAt) throw new require_auth_errors.AuthEmailVerificationRequiredError();
286
+ return {
287
+ session,
288
+ user
289
+ };
290
+ }
291
+ async verifyCurrentPassword(userId, password) {
292
+ if (typeof password !== "string" || password.length === 0) throw new require_auth_errors.AuthValidationError("The current password is required for this operation.");
293
+ const credential = await this.storage.findOne(this.credentialsCollection, { where: {
294
+ userId: whereEquals(userId),
295
+ kind: whereEquals("password")
296
+ } });
297
+ if (!credential || credential.revokedAt !== null) throw new require_auth_errors.AuthInvalidCredentialsError();
298
+ let valid;
299
+ try {
300
+ valid = await this.passwordHasher.verify(password, credential.passwordHash);
301
+ } catch (error) {
302
+ throw new require_auth_errors.AuthProviderError("Password verification failed.", { cause: error });
303
+ }
304
+ if (!valid) throw new require_auth_errors.AuthInvalidCredentialsError();
305
+ }
306
+ async findActiveFactors(userId) {
307
+ return this.storage.findMany(this.factorsCollection, { where: {
308
+ userId: whereEquals(userId),
309
+ status: whereEquals("active")
310
+ } });
311
+ }
312
+ async getPasswordSnapshot(challenge) {
313
+ if (challenge.purpose !== "sign-in" && challenge.purpose !== "enrollment" || !challenge.userId || challenge.userRevision === null || challenge.credentialRevision === null) return null;
314
+ const user = await this.storage.findById(this.usersCollection, challenge.userId);
315
+ const credential = await this.storage.findOne(this.credentialsCollection, { where: {
316
+ userId: whereEquals(challenge.userId),
317
+ kind: whereEquals("password")
318
+ } });
319
+ if (!user || user.deactivatedAt !== null || user.revision !== challenge.userRevision || !credential || credential.revokedAt !== null || credential.revision !== challenge.credentialRevision) return null;
320
+ return {
321
+ user,
322
+ credential
323
+ };
324
+ }
325
+ async createChallengeRecord(purpose, userId, factorIds, webAuthnChallenge = null, metadata = {}) {
326
+ const now = this.now();
327
+ const token = require_auth_crypto_utils.generateSecureAuthToken();
328
+ return {
329
+ record: {
330
+ id: require_auth_crypto_utils.generateAuthId("mfa-challenge"),
331
+ purpose,
332
+ userId,
333
+ tokenHash: await require_auth_crypto_utils.hashAuthToken(token),
334
+ factorIds,
335
+ webAuthnFactorIds: [],
336
+ webAuthnChallenge,
337
+ factorLabel: metadata.factorLabel ?? null,
338
+ userRevision: metadata.userRevision ?? null,
339
+ credentialRevision: metadata.credentialRevision ?? null,
340
+ attemptCount: 0,
341
+ maximumAttempts: DEFAULT_MAXIMUM_CHALLENGE_ATTEMPTS,
342
+ expiresAt: new Date(now.getTime() + this.challengeLifetimeSeconds * 1e3),
343
+ consumedAt: null,
344
+ createdAt: now,
345
+ updatedAt: now,
346
+ revision: 1
347
+ },
348
+ token
349
+ };
350
+ }
351
+ async getChallenge(token, purpose) {
352
+ if (typeof token !== "string" || token.length === 0) throw new require_auth_errors.AuthMfaChallengeError();
353
+ const tokenHash = await require_auth_crypto_utils.hashAuthToken(token);
354
+ const record = await this.storage.findOne(this.challengesCollection, { where: { tokenHash: whereEquals(tokenHash) } });
355
+ if (!record || record.purpose !== purpose || record.consumedAt !== null || record.expiresAt.getTime() <= this.now().getTime() || record.attemptCount >= record.maximumAttempts) throw new require_auth_errors.AuthMfaChallengeError();
356
+ if ((purpose === "sign-in" || purpose === "enrollment") && !await this.getPasswordSnapshot(record)) throw new require_auth_errors.AuthMfaChallengeError();
357
+ return record;
358
+ }
359
+ async createSignInChallenge(input) {
360
+ this.assertInitialized();
361
+ await this.enforceRateLimit("challenge", input.ipAddress, input.userId);
362
+ const factors = await this.findActiveFactors(input.userId);
363
+ if (factors.length === 0) return null;
364
+ const recoveryCodeCount = await this.storage.count(this.recoveryCodesCollection, { where: {
365
+ userId: whereEquals(input.userId),
366
+ consumedAt: whereEquals(null)
367
+ } });
368
+ const availableFactors = /* @__PURE__ */ new Set();
369
+ for (const factor of factors) {
370
+ if (factor.type === "totp") availableFactors.add("totp");
371
+ if (factor.type === "passkey") availableFactors.add("passkey");
372
+ }
373
+ if (recoveryCodeCount > 0) availableFactors.add("recovery-code");
374
+ const user = await this.storage.findById(this.usersCollection, input.userId);
375
+ const credential = await this.storage.findOne(this.credentialsCollection, { where: {
376
+ userId: whereEquals(input.userId),
377
+ kind: whereEquals("password")
378
+ } });
379
+ if (!user || user.deactivatedAt !== null || !credential || credential.revokedAt !== null) throw new require_auth_errors.AuthMfaChallengeError();
380
+ const created = await this.createChallengeRecord("sign-in", input.userId, factors.map((factor) => factor.id), null, {
381
+ userRevision: user.revision,
382
+ credentialRevision: credential.revision
383
+ });
384
+ const audit = this.createAuditRecord("mfa-challenge-created", input.userId, { factors: factors.length });
385
+ try {
386
+ await this.storage.atomicBatch([{
387
+ type: "insert",
388
+ collection: this.challengesCollection,
389
+ record: created.record
390
+ }, {
391
+ type: "insert",
392
+ collection: this.auditCollection,
393
+ record: audit
394
+ }]);
395
+ } catch (error) {
396
+ throw new require_auth_errors.AuthProviderError("Could not create the MFA challenge.", { cause: error });
397
+ }
398
+ return {
399
+ status: "mfa-pending",
400
+ challengeId: created.record.id,
401
+ challengeToken: created.token,
402
+ expiresAt: created.record.expiresAt,
403
+ availableFactors: [...availableFactors]
404
+ };
405
+ }
406
+ async createEnrollmentChallenge(input) {
407
+ this.assertInitialized();
408
+ await this.enforceRateLimit("enrollment-challenge", input.ipAddress, input.userId);
409
+ if ((await this.findActiveFactors(input.userId)).length > 0) throw new require_auth_errors.AuthMfaChallengeError();
410
+ const user = await this.storage.findById(this.usersCollection, input.userId);
411
+ const credential = await this.storage.findOne(this.credentialsCollection, { where: {
412
+ userId: whereEquals(input.userId),
413
+ kind: whereEquals("password")
414
+ } });
415
+ if (!user || user.deactivatedAt !== null || !credential || credential.revokedAt !== null) throw new require_auth_errors.AuthMfaChallengeError();
416
+ const created = await this.createChallengeRecord("enrollment", input.userId, [], null, {
417
+ userRevision: user.revision,
418
+ credentialRevision: credential.revision
419
+ });
420
+ const audit = this.createAuditRecord("mfa-challenge-created", input.userId, { purpose: "required-enrollment" });
421
+ try {
422
+ await this.storage.atomicBatch([{
423
+ type: "insert",
424
+ collection: this.challengesCollection,
425
+ record: created.record
426
+ }, {
427
+ type: "insert",
428
+ collection: this.auditCollection,
429
+ record: audit
430
+ }]);
431
+ } catch (error) {
432
+ throw new require_auth_errors.AuthProviderError("Could not create the required MFA enrollment challenge.", { cause: error });
433
+ }
434
+ const availableFactors = this.webAuthn ? ["totp", "passkey"] : ["totp"];
435
+ return {
436
+ status: "mfa-enrollment-pending",
437
+ challengeId: created.record.id,
438
+ challengeToken: created.token,
439
+ expiresAt: created.record.expiresAt,
440
+ availableFactors
441
+ };
442
+ }
443
+ async getPendingAuthState(token) {
444
+ this.assertInitialized();
445
+ if (typeof token !== "string" || token.length === 0) return null;
446
+ const tokenHash = await require_auth_crypto_utils.hashAuthToken(token);
447
+ const challenge = await this.storage.findOne(this.challengesCollection, { where: { tokenHash: whereEquals(tokenHash) } });
448
+ if (!challenge || challenge.purpose !== "sign-in" && challenge.purpose !== "enrollment" || !challenge.userId || challenge.consumedAt !== null || challenge.expiresAt.getTime() <= this.now().getTime() || challenge.attemptCount >= challenge.maximumAttempts) return null;
449
+ if (!await this.getPasswordSnapshot(challenge)) return null;
450
+ if (challenge.purpose === "enrollment") {
451
+ const availableFactors = this.webAuthn ? ["totp", "passkey"] : ["totp"];
452
+ return {
453
+ status: "mfa-enrollment-pending",
454
+ userId: challenge.userId,
455
+ challengeId: challenge.id,
456
+ expiresAt: challenge.expiresAt,
457
+ availableFactors
458
+ };
459
+ }
460
+ return {
461
+ status: "mfa-pending",
462
+ userId: challenge.userId,
463
+ challengeId: challenge.id,
464
+ expiresAt: challenge.expiresAt
465
+ };
466
+ }
467
+ async getAvailableFactors(challengeToken) {
468
+ this.assertInitialized();
469
+ const challenge = await this.getChallenge(challengeToken, "sign-in");
470
+ if (!challenge.userId) throw new require_auth_errors.AuthMfaChallengeError();
471
+ const challengeFactors = (await this.findActiveFactors(challenge.userId)).filter((factor) => challenge.factorIds.includes(factor.id));
472
+ const available = /* @__PURE__ */ new Set();
473
+ for (const factor of challengeFactors) available.add(factor.type);
474
+ if ((await this.storage.findMany(this.recoveryCodesCollection, { where: {
475
+ userId: whereEquals(challenge.userId),
476
+ consumedAt: whereEquals(null)
477
+ } })).some((record) => challenge.factorIds.includes(record.factorId))) available.add("recovery-code");
478
+ return [...available];
479
+ }
480
+ async failChallenge(challenge, method) {
481
+ const now = this.now();
482
+ const nextAttempt = challenge.attemptCount + 1;
483
+ const operations = [{
484
+ type: "update",
485
+ collection: this.challengesCollection,
486
+ id: challenge.id,
487
+ condition: { expectedRevision: challenge.revision },
488
+ changes: {
489
+ attemptCount: nextAttempt,
490
+ consumedAt: nextAttempt >= challenge.maximumAttempts ? now : null,
491
+ updatedAt: now
492
+ }
493
+ }, {
494
+ type: "insert",
495
+ collection: this.auditCollection,
496
+ record: this.createAuditRecord("mfa-challenge-failed", challenge.userId, {
497
+ method,
498
+ attempt: nextAttempt
499
+ })
500
+ }];
501
+ try {
502
+ await this.storage.atomicBatch(operations);
503
+ } catch (error) {
504
+ throw new require_auth_errors.AuthMfaChallengeError({ cause: error });
505
+ }
506
+ throw new require_auth_errors.AuthMfaChallengeError();
507
+ }
508
+ async createRecoveryCodeBundle(userId, factorId, force = false) {
509
+ if (!force) {
510
+ if (await this.storage.count(this.recoveryCodesCollection, { where: {
511
+ userId: whereEquals(userId),
512
+ consumedAt: whereEquals(null)
513
+ } }) > 0) return {
514
+ codes: [],
515
+ records: []
516
+ };
517
+ }
518
+ const codes = [];
519
+ const records = [];
520
+ const now = this.now();
521
+ for (let index = 0; index < this.recoveryCodeCount; index++) {
522
+ const code = generateRecoveryCode();
523
+ const normalized = normalizeRecoveryCode(code);
524
+ codes.push(code);
525
+ records.push({
526
+ id: require_auth_crypto_utils.generateAuthId("recovery-code"),
527
+ userId,
528
+ factorId,
529
+ codeHash: await require_auth_crypto_utils.hashAuthToken(normalized),
530
+ consumedAt: null,
531
+ createdAt: now,
532
+ updatedAt: now,
533
+ revision: 1
534
+ });
535
+ }
536
+ return {
537
+ codes,
538
+ records
539
+ };
540
+ }
541
+ async getRecoveryCodeRecord(userId, code) {
542
+ const normalized = normalizeRecoveryCode(code);
543
+ if (!normalized) return null;
544
+ const codeHash = await require_auth_crypto_utils.hashAuthToken(normalized);
545
+ return this.storage.findOne(this.recoveryCodesCollection, { where: {
546
+ userId: whereEquals(userId),
547
+ codeHash: whereEquals(codeHash),
548
+ consumedAt: whereEquals(null)
549
+ } });
550
+ }
551
+ async createAuthenticatedResult(userId, metadata) {
552
+ const user = await this.storage.findById(this.usersCollection, userId);
553
+ if (!user || user.deactivatedAt !== null) throw new require_auth_errors.AuthMfaChallengeError();
554
+ if (this.requireVerifiedEmail && !user.emailVerifiedAt) throw new require_auth_errors.AuthEmailVerificationRequiredError();
555
+ try {
556
+ const created = await this.sessions.create({
557
+ userId,
558
+ kind: "authenticated",
559
+ metadata
560
+ });
561
+ return {
562
+ sessionId: created.session.id,
563
+ result: {
564
+ user: toPublicUser(user),
565
+ session: toPublicSession(created.session),
566
+ token: created.token
567
+ }
568
+ };
569
+ } catch (error) {
570
+ throw new require_auth_errors.AuthSessionError("Could not create a session after MFA verification.", { cause: error });
571
+ }
572
+ }
573
+ /**
574
+ * Atomically consumes the challenge and the verified factor state.
575
+ *
576
+ * A session is created before the batch, but its raw token is never
577
+ * returned until the batch has successfully committed.
578
+ */
579
+ async completeChallenge(input) {
580
+ const now = this.now();
581
+ const requiresPasswordSnapshot = input.challenge.purpose === "sign-in" || input.challenge.purpose === "enrollment";
582
+ const passwordSnapshot = requiresPasswordSnapshot ? await this.getPasswordSnapshot(input.challenge) : null;
583
+ if (requiresPasswordSnapshot && !passwordSnapshot) throw new require_auth_errors.AuthMfaChallengeError();
584
+ const authenticated = await this.createAuthenticatedResult(input.userId, {
585
+ authenticationMethod: input.method,
586
+ mfaVerifiedAt: now.toISOString()
587
+ });
588
+ const operations = [{
589
+ type: "update",
590
+ collection: this.challengesCollection,
591
+ id: input.challenge.id,
592
+ condition: { expectedRevision: input.challenge.revision },
593
+ changes: {
594
+ consumedAt: now,
595
+ updatedAt: now
596
+ }
597
+ }];
598
+ if (input.factorInsert) operations.push({
599
+ type: "insert",
600
+ collection: this.factorsCollection,
601
+ record: input.factorInsert
602
+ });
603
+ if (input.factorUpdate) operations.push({
604
+ type: "update",
605
+ collection: this.factorsCollection,
606
+ id: input.factorUpdate.factor.id,
607
+ condition: { expectedRevision: input.factorUpdate.factor.revision },
608
+ changes: input.factorUpdate.changes
609
+ });
610
+ if (input.recoveryCode) operations.push({
611
+ type: "update",
612
+ collection: this.recoveryCodesCollection,
613
+ id: input.recoveryCode.id,
614
+ condition: { expectedRevision: input.recoveryCode.revision },
615
+ changes: {
616
+ consumedAt: now,
617
+ updatedAt: now
618
+ }
619
+ });
620
+ for (const recoveryRecord of input.recoveryRecords ?? []) operations.push({
621
+ type: "insert",
622
+ collection: this.recoveryCodesCollection,
623
+ record: recoveryRecord
624
+ });
625
+ if (passwordSnapshot) operations.push({
626
+ type: "update",
627
+ collection: this.usersCollection,
628
+ id: passwordSnapshot.user.id,
629
+ condition: { expectedRevision: passwordSnapshot.user.revision },
630
+ changes: { updatedAt: passwordSnapshot.user.updatedAt }
631
+ }, {
632
+ type: "update",
633
+ collection: this.credentialsCollection,
634
+ id: passwordSnapshot.credential.id,
635
+ condition: { expectedRevision: passwordSnapshot.credential.revision },
636
+ changes: {
637
+ updatedAt: passwordSnapshot.credential.updatedAt,
638
+ passwordUpdatedAt: passwordSnapshot.credential.passwordUpdatedAt
639
+ }
640
+ });
641
+ operations.push({
642
+ type: "insert",
643
+ collection: this.auditCollection,
644
+ record: this.createAuditRecord(input.auditAction ?? (input.method === "passkey" ? "passkey-authenticated" : "mfa-completed"), input.userId, { method: input.method }, authenticated.sessionId)
645
+ });
646
+ try {
647
+ await this.storage.atomicBatch(operations);
648
+ } catch (error) {
649
+ try {
650
+ await this.sessions.revoke(authenticated.sessionId);
651
+ } catch (revokeError) {
652
+ throw new require_auth_errors.AuthSessionError("Could not revoke an uncommitted MFA session.", { cause: revokeError });
653
+ }
654
+ if (error instanceof _puzzle_ui_storage.StorageConflictError || error instanceof _puzzle_ui_storage.StorageDuplicateError) throw new require_auth_errors.AuthMfaChallengeError({ cause: error });
655
+ throw new require_auth_errors.AuthProviderError("Could not complete MFA authentication.", { cause: error });
656
+ }
657
+ return authenticated.result;
658
+ }
659
+ async beginRequiredTotpEnrollment(input) {
660
+ this.assertInitialized();
661
+ const challenge = await this.getChallenge(input.challengeToken, "enrollment");
662
+ if (!challenge.userId || challenge.factorIds.length > 0) throw new require_auth_errors.AuthMfaChallengeError();
663
+ if (challenge.webAuthnChallenge) throw new require_auth_errors.AuthMfaChallengeError();
664
+ await this.enforceRateLimit("required-enrollment", input.ipAddress, challenge.userId);
665
+ const snapshot = await this.getPasswordSnapshot(challenge);
666
+ if (!snapshot) throw new require_auth_errors.AuthMfaChallengeError();
667
+ const secret = require_totp_utils.generateTotpSecret();
668
+ let protectedSecret;
669
+ try {
670
+ protectedSecret = await this.secretProtector.encrypt(secret);
671
+ } catch (error) {
672
+ throw new require_auth_errors.AuthProviderError("Could not encrypt the TOTP secret.", { cause: error });
673
+ }
674
+ if (!protectedSecret || typeof protectedSecret.ciphertext !== "string" || protectedSecret.ciphertext.length === 0 || typeof protectedSecret.iv !== "string" || protectedSecret.iv.length === 0 || typeof protectedSecret.keyId !== "string" || protectedSecret.keyId.length === 0) throw new require_auth_errors.AuthProviderError("The secret protector returned an invalid encrypted secret.");
675
+ const now = this.now();
676
+ const factorId = require_auth_crypto_utils.generateAuthId("mfa-factor");
677
+ const factor = {
678
+ id: factorId,
679
+ userId: challenge.userId,
680
+ factorKey: `totp:${factorId}`,
681
+ type: "totp",
682
+ status: "pending",
683
+ label: validateLabel(input.label, "Authenticator app"),
684
+ secret: protectedSecret,
685
+ credentialId: null,
686
+ publicKey: null,
687
+ counter: 0,
688
+ transports: [],
689
+ lastUsedStep: null,
690
+ lastUsedAt: null,
691
+ expiresAt: challenge.expiresAt,
692
+ createdAt: now,
693
+ updatedAt: now,
694
+ revision: 1
695
+ };
696
+ const operations = [
697
+ {
698
+ type: "insert",
699
+ collection: this.factorsCollection,
700
+ record: factor
701
+ },
702
+ {
703
+ type: "update",
704
+ collection: this.challengesCollection,
705
+ id: challenge.id,
706
+ condition: { expectedRevision: challenge.revision },
707
+ changes: {
708
+ factorIds: [factor.id],
709
+ updatedAt: now
710
+ }
711
+ },
712
+ {
713
+ type: "insert",
714
+ collection: this.auditCollection,
715
+ record: this.createAuditRecord("mfa-challenge-created", challenge.userId, {
716
+ purpose: "required-totp-enrollment",
717
+ factorId
718
+ })
719
+ }
720
+ ];
721
+ try {
722
+ await this.storage.atomicBatch(operations);
723
+ } catch (error) {
724
+ if (error instanceof _puzzle_ui_storage.StorageConflictError || error instanceof _puzzle_ui_storage.StorageDuplicateError) throw new require_auth_errors.AuthMfaChallengeError({ cause: error });
725
+ throw new require_auth_errors.AuthProviderError("Could not create the pending TOTP factor.", { cause: error });
726
+ }
727
+ return {
728
+ factorId,
729
+ secret,
730
+ otpauthUrl: require_totp_utils.createTotpUri({
731
+ issuer: this.issuer,
732
+ account: snapshot.user.email,
733
+ secret,
734
+ digits: this.totp.digits,
735
+ periodSeconds: this.totp.periodSeconds
736
+ }),
737
+ expiresAt: challenge.expiresAt
738
+ };
739
+ }
740
+ async verifyRequiredTotpEnrollment(input) {
741
+ this.assertInitialized();
742
+ const challenge = await this.getChallenge(input.challengeToken, "enrollment");
743
+ if (!challenge.userId || challenge.factorIds.length !== 1 || !challenge.factorIds.includes(input.factorId)) throw new require_auth_errors.AuthMfaChallengeError();
744
+ await this.enforceRateLimit("verify-required-totp-enrollment", input.ipAddress, challenge.userId);
745
+ const factor = await this.storage.findById(this.factorsCollection, input.factorId);
746
+ if (!factor || factor.userId !== challenge.userId || factor.type !== "totp" || factor.status !== "pending" || !factor.secret || !factor.expiresAt || factor.expiresAt.getTime() <= this.now().getTime()) throw new require_auth_errors.AuthMfaFactorError("The pending TOTP enrollment is invalid or expired.");
747
+ let secret;
748
+ try {
749
+ secret = await this.secretProtector.decrypt(factor.secret);
750
+ } catch (error) {
751
+ throw new require_auth_errors.AuthProviderError("Could not decrypt the TOTP secret.", { cause: error });
752
+ }
753
+ const now = this.now();
754
+ const matchedStep = await require_totp_utils.verifyTotpCode(secret, input.code, now.getTime(), this.totp);
755
+ if (matchedStep === null || factor.lastUsedStep !== null && matchedStep <= factor.lastUsedStep) return this.failChallenge(challenge, "required-totp-enrollment");
756
+ const recovery = await this.createRecoveryCodeBundle(challenge.userId, factor.id);
757
+ return {
758
+ auth: await this.completeChallenge({
759
+ challenge,
760
+ userId: challenge.userId,
761
+ method: "totp",
762
+ factorUpdate: {
763
+ factor,
764
+ changes: {
765
+ status: "active",
766
+ lastUsedStep: matchedStep,
767
+ lastUsedAt: now,
768
+ expiresAt: null,
769
+ updatedAt: now
770
+ }
771
+ },
772
+ recoveryRecords: recovery.records,
773
+ auditAction: "mfa-factor-added"
774
+ }),
775
+ factor: toPublicFactor({
776
+ ...factor,
777
+ status: "active",
778
+ lastUsedStep: matchedStep,
779
+ lastUsedAt: now,
780
+ expiresAt: null,
781
+ updatedAt: now,
782
+ revision: factor.revision + 1
783
+ }),
784
+ recoveryCodes: recovery.codes
785
+ };
786
+ }
787
+ async beginRequiredPasskeyEnrollment(input) {
788
+ this.assertInitialized();
789
+ const challenge = await this.getChallenge(input.challengeToken, "enrollment");
790
+ if (!challenge.userId || challenge.factorIds.length > 0 || challenge.webAuthnChallenge) throw new require_auth_errors.AuthMfaChallengeError();
791
+ await this.enforceRateLimit("required-passkey-enrollment", input.ipAddress, challenge.userId);
792
+ const snapshot = await this.getPasswordSnapshot(challenge);
793
+ if (!snapshot) throw new require_auth_errors.AuthMfaChallengeError();
794
+ const { provider, config } = this.getWebAuthn();
795
+ const excludeCredentialIds = (await this.findActiveFactors(challenge.userId)).filter((factor) => factor.type === "passkey" && factor.credentialId !== null).map((factor) => factor.credentialId);
796
+ const webAuthnChallenge = generateWebAuthnChallenge();
797
+ let options;
798
+ try {
799
+ options = await provider.generateRegistrationOptions({
800
+ challenge: webAuthnChallenge,
801
+ rpId: config.rpId,
802
+ rpName: config.rpName,
803
+ userId: challenge.userId,
804
+ userName: snapshot.user.email,
805
+ excludeCredentialIds
806
+ });
807
+ } catch (error) {
808
+ throw new require_auth_errors.AuthProviderError("Could not create WebAuthn registration options.", { cause: error });
809
+ }
810
+ this.assertWebAuthnOptions(options, webAuthnChallenge);
811
+ const now = this.now();
812
+ const factorLabel = validateLabel(input.label, "Passkey");
813
+ const operations = [{
814
+ type: "update",
815
+ collection: this.challengesCollection,
816
+ id: challenge.id,
817
+ condition: { expectedRevision: challenge.revision },
818
+ changes: {
819
+ webAuthnChallenge,
820
+ factorLabel,
821
+ updatedAt: now
822
+ }
823
+ }, {
824
+ type: "insert",
825
+ collection: this.auditCollection,
826
+ record: this.createAuditRecord("mfa-challenge-created", challenge.userId, { purpose: "required-passkey-enrollment" })
827
+ }];
828
+ try {
829
+ await this.storage.atomicBatch(operations);
830
+ } catch (error) {
831
+ if (error instanceof _puzzle_ui_storage.StorageConflictError || error instanceof _puzzle_ui_storage.StorageDuplicateError) throw new require_auth_errors.AuthMfaChallengeError({ cause: error });
832
+ throw new require_auth_errors.AuthProviderError("Could not persist the required passkey enrollment challenge.", { cause: error });
833
+ }
834
+ return {
835
+ challengeId: challenge.id,
836
+ challengeToken: input.challengeToken,
837
+ options,
838
+ expiresAt: challenge.expiresAt
839
+ };
840
+ }
841
+ async finishRequiredPasskeyEnrollment(input) {
842
+ this.assertInitialized();
843
+ const challenge = await this.getChallenge(input.challengeToken, "enrollment");
844
+ if (!challenge.userId || challenge.factorIds.length > 0 || !challenge.webAuthnChallenge) throw new require_auth_errors.AuthMfaChallengeError();
845
+ await this.enforceRateLimit("verify-required-passkey-enrollment", input.ipAddress, challenge.userId);
846
+ if (!await this.getPasswordSnapshot(challenge)) throw new require_auth_errors.AuthMfaChallengeError();
847
+ const { provider, config } = this.getWebAuthn();
848
+ let verification;
849
+ try {
850
+ verification = await provider.verifyRegistrationResponse({
851
+ response: input.response,
852
+ expectedChallenge: challenge.webAuthnChallenge,
853
+ expectedOrigin: config.origin,
854
+ expectedRpId: config.rpId
855
+ });
856
+ } catch (error) {
857
+ throw new require_auth_errors.AuthProviderError("WebAuthn registration verification failed.", { cause: error });
858
+ }
859
+ if (!verification.verified || !verification.userVerified || !verification.credential) return this.failChallenge(challenge, "required-passkey-enrollment");
860
+ const credential = verification.credential;
861
+ if (getCredentialId(input.response) !== credential.credentialId || !credential.publicKey || !Number.isSafeInteger(credential.counter) || credential.counter < 0) return this.failChallenge(challenge, "required-passkey-enrollment");
862
+ const now = this.now();
863
+ const factor = {
864
+ id: require_auth_crypto_utils.generateAuthId("mfa-factor"),
865
+ userId: challenge.userId,
866
+ factorKey: `passkey:${credential.credentialId}`,
867
+ type: "passkey",
868
+ status: "active",
869
+ label: challenge.factorLabel ?? "Passkey",
870
+ secret: null,
871
+ credentialId: credential.credentialId,
872
+ publicKey: credential.publicKey,
873
+ counter: credential.counter,
874
+ transports: credential.transports ?? [],
875
+ lastUsedStep: null,
876
+ lastUsedAt: now,
877
+ expiresAt: null,
878
+ createdAt: now,
879
+ updatedAt: now,
880
+ revision: 1
881
+ };
882
+ const recovery = await this.createRecoveryCodeBundle(challenge.userId, factor.id);
883
+ return {
884
+ auth: await this.completeChallenge({
885
+ challenge,
886
+ userId: challenge.userId,
887
+ method: "passkey",
888
+ factorInsert: factor,
889
+ recoveryRecords: recovery.records,
890
+ auditAction: "passkey-registered"
891
+ }),
892
+ factor: toPublicFactor(factor),
893
+ recoveryCodes: recovery.codes
894
+ };
895
+ }
896
+ async beginTotpEnrollment(input) {
897
+ this.assertInitialized();
898
+ const context = await this.getAuthenticatedContext(input.sessionToken);
899
+ await this.verifyCurrentPassword(context.user.id, input.currentPassword);
900
+ await this.enforceRateLimit("enrollment", input.ipAddress, context.user.id);
901
+ const secret = require_totp_utils.generateTotpSecret();
902
+ let protectedSecret;
903
+ try {
904
+ protectedSecret = await this.secretProtector.encrypt(secret);
905
+ } catch (error) {
906
+ throw new require_auth_errors.AuthProviderError("Could not encrypt the TOTP secret.", { cause: error });
907
+ }
908
+ if (!protectedSecret || typeof protectedSecret.ciphertext !== "string" || protectedSecret.ciphertext.length === 0 || typeof protectedSecret.iv !== "string" || protectedSecret.iv.length === 0 || typeof protectedSecret.keyId !== "string" || protectedSecret.keyId.length === 0) throw new require_auth_errors.AuthProviderError("The secret protector returned an invalid encrypted secret.");
909
+ const now = this.now();
910
+ const factorId = require_auth_crypto_utils.generateAuthId("mfa-factor");
911
+ const expiresAt = new Date(now.getTime() + this.challengeLifetimeSeconds * 1e3);
912
+ const factor = {
913
+ id: factorId,
914
+ userId: context.user.id,
915
+ factorKey: `totp:${factorId}`,
916
+ type: "totp",
917
+ status: "pending",
918
+ label: validateLabel(input.label, "Authenticator app"),
919
+ secret: protectedSecret,
920
+ credentialId: null,
921
+ publicKey: null,
922
+ counter: 0,
923
+ transports: [],
924
+ lastUsedStep: null,
925
+ lastUsedAt: null,
926
+ expiresAt,
927
+ createdAt: now,
928
+ updatedAt: now,
929
+ revision: 1
930
+ };
931
+ const operations = [{
932
+ type: "insert",
933
+ collection: this.factorsCollection,
934
+ record: factor
935
+ }, {
936
+ type: "insert",
937
+ collection: this.auditCollection,
938
+ record: this.createAuditRecord("mfa-factor-added", context.user.id, {
939
+ type: "totp",
940
+ status: "pending"
941
+ }, context.session.id)
942
+ }];
943
+ try {
944
+ await this.storage.atomicBatch(operations);
945
+ } catch (error) {
946
+ throw new require_auth_errors.AuthProviderError("Could not create the pending TOTP factor.", { cause: error });
947
+ }
948
+ return {
949
+ factorId,
950
+ secret,
951
+ otpauthUrl: require_totp_utils.createTotpUri({
952
+ issuer: this.issuer,
953
+ account: context.user.email,
954
+ secret,
955
+ digits: this.totp.digits,
956
+ periodSeconds: this.totp.periodSeconds
957
+ }),
958
+ expiresAt
959
+ };
960
+ }
961
+ async verifyTotpEnrollment(input) {
962
+ this.assertInitialized();
963
+ const context = await this.getAuthenticatedContext(input.sessionToken);
964
+ await this.enforceRateLimit("verify-enrollment", input.ipAddress, context.user.id);
965
+ const factor = await this.storage.findById(this.factorsCollection, input.factorId);
966
+ if (!factor || factor.userId !== context.user.id || factor.type !== "totp" || factor.status !== "pending" || !factor.secret || !factor.expiresAt || factor.expiresAt.getTime() <= this.now().getTime()) throw new require_auth_errors.AuthMfaFactorError("The pending TOTP enrollment is invalid or expired.");
967
+ let secret;
968
+ try {
969
+ secret = await this.secretProtector.decrypt(factor.secret);
970
+ } catch (error) {
971
+ throw new require_auth_errors.AuthProviderError("Could not decrypt the TOTP secret.", { cause: error });
972
+ }
973
+ const now = this.now();
974
+ const matchedStep = await require_totp_utils.verifyTotpCode(secret, input.code, now.getTime(), this.totp);
975
+ if (matchedStep === null) throw new require_auth_errors.AuthMfaFactorError("The authenticator code is invalid.");
976
+ const recovery = await this.createRecoveryCodeBundle(context.user.id, factor.id);
977
+ const activatedFactor = {
978
+ ...factor,
979
+ status: "active",
980
+ lastUsedStep: matchedStep,
981
+ lastUsedAt: now,
982
+ expiresAt: null,
983
+ updatedAt: now,
984
+ revision: factor.revision + 1
985
+ };
986
+ const operations = [
987
+ {
988
+ type: "update",
989
+ collection: this.factorsCollection,
990
+ id: factor.id,
991
+ condition: { expectedRevision: factor.revision },
992
+ changes: {
993
+ status: "active",
994
+ lastUsedStep: matchedStep,
995
+ lastUsedAt: now,
996
+ expiresAt: null,
997
+ updatedAt: now
998
+ }
999
+ },
1000
+ ...recovery.records.map((record) => ({
1001
+ type: "insert",
1002
+ collection: this.recoveryCodesCollection,
1003
+ record
1004
+ })),
1005
+ {
1006
+ type: "insert",
1007
+ collection: this.auditCollection,
1008
+ record: this.createAuditRecord("mfa-factor-added", context.user.id, {
1009
+ type: "totp",
1010
+ recoveryCodesGenerated: recovery.codes.length
1011
+ }, context.session.id)
1012
+ }
1013
+ ];
1014
+ try {
1015
+ await this.storage.atomicBatch(operations);
1016
+ } catch (error) {
1017
+ if (error instanceof _puzzle_ui_storage.StorageConflictError) throw new require_auth_errors.AuthMfaFactorError("The TOTP enrollment has already been completed.", { cause: error });
1018
+ throw new require_auth_errors.AuthProviderError("Could not activate the TOTP factor.", { cause: error });
1019
+ }
1020
+ return {
1021
+ factor: toPublicFactor(activatedFactor),
1022
+ recoveryCodes: recovery.codes
1023
+ };
1024
+ }
1025
+ async verifySignInTotp(input) {
1026
+ this.assertInitialized();
1027
+ const challenge = await this.getChallenge(input.challengeToken, "sign-in");
1028
+ if (!challenge.userId) throw new require_auth_errors.AuthMfaChallengeError();
1029
+ await this.enforceRateLimit("verify-totp", input.ipAddress, challenge.userId);
1030
+ for (const factorId of challenge.factorIds) {
1031
+ const factor = await this.storage.findById(this.factorsCollection, factorId);
1032
+ if (!factor || factor.userId !== challenge.userId || factor.status !== "active" || factor.type !== "totp" || !factor.secret) continue;
1033
+ let secret;
1034
+ try {
1035
+ secret = await this.secretProtector.decrypt(factor.secret);
1036
+ } catch (error) {
1037
+ throw new require_auth_errors.AuthProviderError("Could not decrypt the TOTP secret.", { cause: error });
1038
+ }
1039
+ const now = this.now();
1040
+ const matchedStep = await require_totp_utils.verifyTotpCode(secret, input.code, now.getTime(), this.totp);
1041
+ if (matchedStep === null || factor.lastUsedStep !== null && matchedStep <= factor.lastUsedStep) continue;
1042
+ return this.completeChallenge({
1043
+ challenge,
1044
+ userId: challenge.userId,
1045
+ method: "totp",
1046
+ factorUpdate: {
1047
+ factor,
1048
+ changes: {
1049
+ lastUsedStep: matchedStep,
1050
+ lastUsedAt: now,
1051
+ updatedAt: now
1052
+ }
1053
+ }
1054
+ });
1055
+ }
1056
+ return this.failChallenge(challenge, "totp");
1057
+ }
1058
+ async verifySignInRecoveryCode(input) {
1059
+ this.assertInitialized();
1060
+ const challenge = await this.getChallenge(input.challengeToken, "sign-in");
1061
+ if (!challenge.userId) throw new require_auth_errors.AuthMfaChallengeError();
1062
+ await this.enforceRateLimit("verify-recovery", input.ipAddress, challenge.userId);
1063
+ const recoveryCode = await this.getRecoveryCodeRecord(challenge.userId, input.code);
1064
+ if (!recoveryCode) return this.failChallenge(challenge, "recovery-code");
1065
+ if (!challenge.factorIds.length) throw new require_auth_errors.AuthMfaChallengeError();
1066
+ const factor = await this.storage.findById(this.factorsCollection, recoveryCode.factorId);
1067
+ if (!factor || factor.userId !== challenge.userId || factor.status !== "active" || !challenge.factorIds.includes(factor.id)) return this.failChallenge(challenge, "recovery-code");
1068
+ return this.completeChallenge({
1069
+ challenge,
1070
+ userId: challenge.userId,
1071
+ method: "recovery-code",
1072
+ recoveryCode
1073
+ });
1074
+ }
1075
+ async hasActiveFactor(userId) {
1076
+ this.assertInitialized();
1077
+ if (typeof userId !== "string" || userId.trim().length === 0) return false;
1078
+ return await this.storage.findOne(this.factorsCollection, { where: {
1079
+ userId: whereEquals(userId),
1080
+ status: whereEquals("active")
1081
+ } }) !== null;
1082
+ }
1083
+ async listFactors(sessionToken) {
1084
+ this.assertInitialized();
1085
+ const context = await this.getAuthenticatedContext(sessionToken);
1086
+ return (await this.storage.findMany(this.factorsCollection, {
1087
+ where: { userId: whereEquals(context.user.id) },
1088
+ orderBy: [{
1089
+ field: "createdAt",
1090
+ direction: "asc"
1091
+ }]
1092
+ })).map(toPublicFactor);
1093
+ }
1094
+ async getRecoveryCodeCount(sessionToken) {
1095
+ this.assertInitialized();
1096
+ const context = await this.getAuthenticatedContext(sessionToken);
1097
+ return this.storage.count(this.recoveryCodesCollection, { where: {
1098
+ userId: whereEquals(context.user.id),
1099
+ consumedAt: whereEquals(null)
1100
+ } });
1101
+ }
1102
+ async regenerateRecoveryCodes(input) {
1103
+ this.assertInitialized();
1104
+ const context = await this.getAuthenticatedContext(input.sessionToken);
1105
+ await this.verifyCurrentPassword(context.user.id, input.currentPassword);
1106
+ await this.enforceRateLimit("regenerate-recovery", input.ipAddress, context.user.id);
1107
+ const factors = await this.findActiveFactors(context.user.id);
1108
+ if (factors.length === 0) throw new require_auth_errors.AuthMfaFactorError("An active MFA factor is required to generate recovery codes.");
1109
+ const existing = await this.storage.findMany(this.recoveryCodesCollection, { where: {
1110
+ userId: whereEquals(context.user.id),
1111
+ consumedAt: whereEquals(null)
1112
+ } });
1113
+ const firstFactor = factors[0];
1114
+ if (!firstFactor) throw new require_auth_errors.AuthMfaFactorError("An active MFA factor is required to generate recovery codes.");
1115
+ const bundle = await this.createRecoveryCodeBundle(context.user.id, firstFactor.id, true);
1116
+ const operations = [
1117
+ ...existing.map((record) => ({
1118
+ type: "delete",
1119
+ collection: this.recoveryCodesCollection,
1120
+ id: record.id,
1121
+ condition: { expectedRevision: record.revision }
1122
+ })),
1123
+ ...bundle.records.map((record) => ({
1124
+ type: "insert",
1125
+ collection: this.recoveryCodesCollection,
1126
+ record
1127
+ })),
1128
+ {
1129
+ type: "insert",
1130
+ collection: this.auditCollection,
1131
+ record: this.createAuditRecord("mfa-recovery-codes-regenerated", context.user.id, { count: bundle.records.length }, context.session.id)
1132
+ }
1133
+ ];
1134
+ try {
1135
+ await this.storage.atomicBatch(operations);
1136
+ } catch (error) {
1137
+ throw new require_auth_errors.AuthProviderError("Could not regenerate recovery codes.", { cause: error });
1138
+ }
1139
+ return bundle.codes;
1140
+ }
1141
+ async removeFactor(input) {
1142
+ this.assertInitialized();
1143
+ const context = await this.getAuthenticatedContext(input.sessionToken);
1144
+ await this.verifyCurrentPassword(context.user.id, input.currentPassword);
1145
+ await this.enforceRateLimit("remove-factor", input.ipAddress, context.user.id);
1146
+ const factor = await this.storage.findById(this.factorsCollection, input.factorId);
1147
+ if (!factor || factor.userId !== context.user.id) throw new require_auth_errors.AuthMfaFactorError();
1148
+ const recoveryCodes = await this.storage.findMany(this.recoveryCodesCollection, { where: { factorId: whereEquals(factor.id) } });
1149
+ const pendingChallenges = await this.storage.findMany(this.challengesCollection, { where: {
1150
+ userId: whereEquals(context.user.id),
1151
+ purpose: whereEquals("sign-in"),
1152
+ consumedAt: whereEquals(null)
1153
+ } });
1154
+ const now = this.now();
1155
+ const operations = [
1156
+ {
1157
+ type: "delete",
1158
+ collection: this.factorsCollection,
1159
+ id: factor.id,
1160
+ condition: { expectedRevision: factor.revision }
1161
+ },
1162
+ ...recoveryCodes.map((record) => ({
1163
+ type: "delete",
1164
+ collection: this.recoveryCodesCollection,
1165
+ id: record.id,
1166
+ condition: { expectedRevision: record.revision }
1167
+ })),
1168
+ ...pendingChallenges.map((challenge) => ({
1169
+ type: "update",
1170
+ collection: this.challengesCollection,
1171
+ id: challenge.id,
1172
+ condition: { expectedRevision: challenge.revision },
1173
+ changes: {
1174
+ consumedAt: now,
1175
+ updatedAt: now
1176
+ }
1177
+ })),
1178
+ {
1179
+ type: "insert",
1180
+ collection: this.auditCollection,
1181
+ record: this.createAuditRecord("mfa-factor-removed", context.user.id, { type: factor.type }, context.session.id)
1182
+ }
1183
+ ];
1184
+ try {
1185
+ await this.storage.atomicBatch(operations);
1186
+ } catch (error) {
1187
+ if (error instanceof _puzzle_ui_storage.StorageConflictError) throw new require_auth_errors.AuthMfaFactorError("The factor changed during removal.", { cause: error });
1188
+ throw new require_auth_errors.AuthProviderError("Could not remove the MFA factor.", { cause: error });
1189
+ }
1190
+ }
1191
+ getWebAuthn() {
1192
+ if (!this.webAuthn || !this.webAuthnProvider) throw new require_auth_errors.AuthConfigurationError("WebAuthn is not configured for this application.");
1193
+ return {
1194
+ provider: this.webAuthnProvider,
1195
+ config: this.webAuthn
1196
+ };
1197
+ }
1198
+ assertWebAuthnOptions(options, expectedChallenge) {
1199
+ if (options.challenge !== expectedChallenge) throw new require_auth_errors.AuthProviderError("The WebAuthn provider returned an unexpected challenge.");
1200
+ }
1201
+ async beginPasskeyRegistration(input) {
1202
+ this.assertInitialized();
1203
+ const context = await this.getAuthenticatedContext(input.sessionToken);
1204
+ await this.verifyCurrentPassword(context.user.id, input.currentPassword);
1205
+ await this.enforceRateLimit("passkey-register", input.ipAddress, context.user.id);
1206
+ const { provider, config } = this.getWebAuthn();
1207
+ const activeFactors = await this.findActiveFactors(context.user.id);
1208
+ const excludeCredentialIds = activeFactors.filter((factor) => factor.type === "passkey" && factor.credentialId !== null).map((factor) => factor.credentialId);
1209
+ const challenge = generateWebAuthnChallenge();
1210
+ let options;
1211
+ try {
1212
+ options = await provider.generateRegistrationOptions({
1213
+ challenge,
1214
+ rpId: config.rpId,
1215
+ rpName: config.rpName,
1216
+ userId: context.user.id,
1217
+ userName: context.user.email,
1218
+ excludeCredentialIds
1219
+ });
1220
+ } catch (error) {
1221
+ throw new require_auth_errors.AuthProviderError("Could not create WebAuthn registration options.", { cause: error });
1222
+ }
1223
+ this.assertWebAuthnOptions(options, challenge);
1224
+ const created = await this.createChallengeRecord("passkey-registration", context.user.id, activeFactors.map((factor) => factor.id), challenge, { factorLabel: validateLabel(input.label, "Passkey") });
1225
+ const audit = this.createAuditRecord("mfa-challenge-created", context.user.id, { purpose: "passkey-registration" }, context.session.id);
1226
+ try {
1227
+ await this.storage.atomicBatch([{
1228
+ type: "insert",
1229
+ collection: this.challengesCollection,
1230
+ record: created.record
1231
+ }, {
1232
+ type: "insert",
1233
+ collection: this.auditCollection,
1234
+ record: audit
1235
+ }]);
1236
+ } catch (error) {
1237
+ throw new require_auth_errors.AuthProviderError("Could not persist the WebAuthn registration challenge.", { cause: error });
1238
+ }
1239
+ return {
1240
+ challengeId: created.record.id,
1241
+ challengeToken: created.token,
1242
+ options,
1243
+ expiresAt: created.record.expiresAt
1244
+ };
1245
+ }
1246
+ async finishPasskeyRegistration(input) {
1247
+ this.assertInitialized();
1248
+ const context = await this.getAuthenticatedContext(input.sessionToken);
1249
+ await this.enforceRateLimit("verify-passkey-registration", input.ipAddress, context.user.id);
1250
+ const challenge = await this.getChallenge(input.challengeToken, "passkey-registration");
1251
+ if (challenge.userId !== context.user.id || !challenge.webAuthnChallenge) throw new require_auth_errors.AuthMfaChallengeError();
1252
+ const { provider, config } = this.getWebAuthn();
1253
+ let verification;
1254
+ try {
1255
+ verification = await provider.verifyRegistrationResponse({
1256
+ response: input.response,
1257
+ expectedChallenge: challenge.webAuthnChallenge,
1258
+ expectedOrigin: config.origin,
1259
+ expectedRpId: config.rpId
1260
+ });
1261
+ } catch (error) {
1262
+ throw new require_auth_errors.AuthProviderError("WebAuthn registration verification failed.", { cause: error });
1263
+ }
1264
+ if (!verification.verified || !verification.userVerified || !verification.credential) return this.failChallenge(challenge, "passkey-registration");
1265
+ const credential = verification.credential;
1266
+ if (getCredentialId(input.response) !== credential.credentialId || !credential.publicKey || !Number.isSafeInteger(credential.counter) || credential.counter < 0) return this.failChallenge(challenge, "passkey-registration");
1267
+ const now = this.now();
1268
+ const factor = {
1269
+ id: require_auth_crypto_utils.generateAuthId("mfa-factor"),
1270
+ userId: context.user.id,
1271
+ factorKey: `passkey:${credential.credentialId}`,
1272
+ type: "passkey",
1273
+ status: "active",
1274
+ label: challenge.factorLabel ?? "Passkey",
1275
+ secret: null,
1276
+ credentialId: credential.credentialId,
1277
+ publicKey: credential.publicKey,
1278
+ counter: credential.counter,
1279
+ transports: credential.transports ?? [],
1280
+ lastUsedStep: null,
1281
+ lastUsedAt: now,
1282
+ expiresAt: null,
1283
+ createdAt: now,
1284
+ updatedAt: now,
1285
+ revision: 1
1286
+ };
1287
+ const recovery = await this.createRecoveryCodeBundle(context.user.id, factor.id);
1288
+ const operations = [
1289
+ {
1290
+ type: "insert",
1291
+ collection: this.factorsCollection,
1292
+ record: factor
1293
+ },
1294
+ {
1295
+ type: "update",
1296
+ collection: this.challengesCollection,
1297
+ id: challenge.id,
1298
+ condition: { expectedRevision: challenge.revision },
1299
+ changes: {
1300
+ consumedAt: now,
1301
+ updatedAt: now
1302
+ }
1303
+ },
1304
+ ...recovery.records.map((record) => ({
1305
+ type: "insert",
1306
+ collection: this.recoveryCodesCollection,
1307
+ record
1308
+ })),
1309
+ {
1310
+ type: "insert",
1311
+ collection: this.auditCollection,
1312
+ record: this.createAuditRecord("passkey-registered", context.user.id, {
1313
+ transports: factor.transports.length,
1314
+ recoveryCodesGenerated: recovery.codes.length
1315
+ }, context.session.id)
1316
+ }
1317
+ ];
1318
+ try {
1319
+ await this.storage.atomicBatch(operations);
1320
+ } catch (error) {
1321
+ if (error instanceof _puzzle_ui_storage.StorageDuplicateError || error instanceof _puzzle_ui_storage.StorageConflictError) throw new require_auth_errors.AuthMfaFactorError("This passkey is already registered or the challenge was consumed.", { cause: error });
1322
+ throw new require_auth_errors.AuthProviderError("Could not store the passkey.", { cause: error });
1323
+ }
1324
+ return {
1325
+ factor: toPublicFactor(factor),
1326
+ recoveryCodes: recovery.codes
1327
+ };
1328
+ }
1329
+ async beginPasskeySignIn(input) {
1330
+ this.assertInitialized();
1331
+ await this.enforceRateLimit("passkey-sign-in", input.ipAddress);
1332
+ const { provider, config } = this.getWebAuthn();
1333
+ const challenge = generateWebAuthnChallenge();
1334
+ let options;
1335
+ try {
1336
+ options = await provider.generateAuthenticationOptions({
1337
+ challenge,
1338
+ rpId: config.rpId,
1339
+ userVerification: "required"
1340
+ });
1341
+ } catch (error) {
1342
+ throw new require_auth_errors.AuthProviderError("Could not create passkey authentication options.", { cause: error });
1343
+ }
1344
+ this.assertWebAuthnOptions(options, challenge);
1345
+ const created = await this.createChallengeRecord("passkey-sign-in", null, [], challenge);
1346
+ const audit = this.createAuditRecord("mfa-challenge-created", null, { purpose: "passkey-sign-in" });
1347
+ try {
1348
+ await this.storage.atomicBatch([{
1349
+ type: "insert",
1350
+ collection: this.challengesCollection,
1351
+ record: created.record
1352
+ }, {
1353
+ type: "insert",
1354
+ collection: this.auditCollection,
1355
+ record: audit
1356
+ }]);
1357
+ } catch (error) {
1358
+ throw new require_auth_errors.AuthProviderError("Could not persist the passkey sign-in challenge.", { cause: error });
1359
+ }
1360
+ return {
1361
+ challengeId: created.record.id,
1362
+ challengeToken: created.token,
1363
+ options,
1364
+ expiresAt: created.record.expiresAt
1365
+ };
1366
+ }
1367
+ async verifyPasskeyAssertion(input) {
1368
+ if (!input.challenge.webAuthnChallenge) throw new require_auth_errors.AuthMfaChallengeError();
1369
+ if (!input.factor.credentialId || !input.factor.publicKey) throw new require_auth_errors.AuthMfaFactorError();
1370
+ const { provider, config } = this.getWebAuthn();
1371
+ let verification;
1372
+ try {
1373
+ verification = await provider.verifyAuthenticationResponse({
1374
+ response: input.response,
1375
+ expectedChallenge: input.challenge.webAuthnChallenge,
1376
+ expectedOrigin: config.origin,
1377
+ expectedRpId: config.rpId,
1378
+ credential: {
1379
+ credentialId: input.factor.credentialId,
1380
+ publicKey: input.factor.publicKey,
1381
+ counter: input.factor.counter
1382
+ }
1383
+ });
1384
+ } catch (error) {
1385
+ throw new require_auth_errors.AuthProviderError("WebAuthn authentication verification failed.", { cause: error });
1386
+ }
1387
+ if (!verification.verified || !verification.userVerified || verification.credentialId !== input.factor.credentialId || !Number.isSafeInteger(verification.newCounter) || verification.newCounter < 0) return this.failChallenge(input.challenge, "passkey-authentication");
1388
+ if ((input.factor.counter > 0 || verification.newCounter > 0) && verification.newCounter <= input.factor.counter) return this.failChallenge(input.challenge, "passkey-counter-regression");
1389
+ return verification.newCounter;
1390
+ }
1391
+ async finishPasskeySignIn(input) {
1392
+ this.assertInitialized();
1393
+ const challenge = await this.getChallenge(input.challengeToken, "passkey-sign-in");
1394
+ await this.enforceRateLimit("verify-passkey-sign-in", input.ipAddress);
1395
+ const credentialId = getCredentialId(input.response);
1396
+ const factor = await this.storage.findOne(this.factorsCollection, { where: {
1397
+ credentialId: whereEquals(credentialId),
1398
+ type: whereEquals("passkey"),
1399
+ status: whereEquals("active")
1400
+ } });
1401
+ if (!factor || !factor.userId) return this.failChallenge(challenge, "passkey-sign-in");
1402
+ const newCounter = await this.verifyPasskeyAssertion({
1403
+ response: input.response,
1404
+ challenge,
1405
+ factor
1406
+ });
1407
+ return this.completeChallenge({
1408
+ challenge,
1409
+ userId: factor.userId,
1410
+ method: "passkey",
1411
+ auditAction: "passkey-authenticated",
1412
+ factorUpdate: {
1413
+ factor,
1414
+ changes: {
1415
+ counter: newCounter,
1416
+ lastUsedAt: this.now(),
1417
+ updatedAt: this.now()
1418
+ }
1419
+ }
1420
+ });
1421
+ }
1422
+ async beginPasskeyMfa(challengeToken, ipAddress) {
1423
+ this.assertInitialized();
1424
+ const challenge = await this.getChallenge(challengeToken, "sign-in");
1425
+ if (!challenge.userId) throw new require_auth_errors.AuthMfaChallengeError();
1426
+ await this.enforceRateLimit("passkey-mfa", ipAddress, challenge.userId);
1427
+ const passkeys = (await this.findActiveFactors(challenge.userId)).filter((factor) => factor.type === "passkey" && factor.credentialId !== null && challenge.factorIds.includes(factor.id));
1428
+ if (passkeys.length === 0) throw new require_auth_errors.AuthMfaFactorError("No passkey is available for this challenge.");
1429
+ const { provider, config } = this.getWebAuthn();
1430
+ const webAuthnChallenge = generateWebAuthnChallenge();
1431
+ let options;
1432
+ try {
1433
+ options = await provider.generateAuthenticationOptions({
1434
+ challenge: webAuthnChallenge,
1435
+ rpId: config.rpId,
1436
+ allowCredentialIds: passkeys.map((factor) => factor.credentialId),
1437
+ userVerification: "required"
1438
+ });
1439
+ } catch (error) {
1440
+ throw new require_auth_errors.AuthProviderError("Could not create passkey MFA options.", { cause: error });
1441
+ }
1442
+ this.assertWebAuthnOptions(options, webAuthnChallenge);
1443
+ if (!await this.storage.update(this.challengesCollection, challenge.id, {
1444
+ webAuthnChallenge,
1445
+ webAuthnFactorIds: passkeys.map((factor) => factor.id),
1446
+ updatedAt: this.now()
1447
+ }, { expectedRevision: challenge.revision })) throw new require_auth_errors.AuthMfaChallengeError();
1448
+ return {
1449
+ options,
1450
+ expiresAt: challenge.expiresAt
1451
+ };
1452
+ }
1453
+ async finishPasskeyMfa(input) {
1454
+ this.assertInitialized();
1455
+ const challenge = await this.getChallenge(input.challengeToken, "sign-in");
1456
+ if (!challenge.userId || !challenge.webAuthnChallenge) throw new require_auth_errors.AuthMfaChallengeError();
1457
+ await this.enforceRateLimit("verify-passkey-mfa", input.ipAddress, challenge.userId);
1458
+ const credentialId = getCredentialId(input.response);
1459
+ const factor = await this.storage.findOne(this.factorsCollection, { where: {
1460
+ credentialId: whereEquals(credentialId),
1461
+ type: whereEquals("passkey"),
1462
+ status: whereEquals("active"),
1463
+ userId: whereEquals(challenge.userId)
1464
+ } });
1465
+ if (!factor || !challenge.webAuthnFactorIds.includes(factor.id)) return this.failChallenge(challenge, "passkey-mfa");
1466
+ const newCounter = await this.verifyPasskeyAssertion({
1467
+ response: input.response,
1468
+ challenge,
1469
+ factor
1470
+ });
1471
+ return this.completeChallenge({
1472
+ challenge,
1473
+ userId: challenge.userId,
1474
+ method: "passkey",
1475
+ factorUpdate: {
1476
+ factor,
1477
+ changes: {
1478
+ counter: newCounter,
1479
+ lastUsedAt: this.now(),
1480
+ updatedAt: this.now()
1481
+ }
1482
+ }
1483
+ });
1484
+ }
1485
+ };
1486
+ exports.StorageAuthMfaManager = StorageAuthMfaManager;
1487
+
1488
+ //# sourceMappingURL=auth.mfa.manager.cjs.map