@puzzle-ui/auth 0.0.0-stage → 0.5.1

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 +54 -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 +50 -4
@@ -0,0 +1,1488 @@
1
+ import { initializeAuthStorage } from "./auth.storage.js";
2
+ import { AuthConfigurationError, AuthEmailVerificationRequiredError, AuthInvalidCredentialsError, AuthMfaChallengeError, AuthMfaFactorError, AuthProviderError, AuthRateLimitError, AuthSessionError, AuthValidationError } from "./errors/auth.errors.js";
3
+ import { createRateLimitKey, generateAuthId, generateSecureAuthToken, hashAuthToken } from "./utils/auth-crypto.utils.js";
4
+ import { createTotpUri, generateTotpSecret, verifyTotpCode } from "./utils/totp.utils.js";
5
+ import { StorageConflictError, StorageDuplicateError } from "@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 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 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 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 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 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 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 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 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 AuthConfigurationError("The TOTP configuration is invalid.");
152
+ if (Boolean(this.webAuthn) !== Boolean(this.webAuthnProvider)) throw new 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 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 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 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 AuthConfigurationError("WebAuthn requires an exact HTTPS origin, except for localhost development.");
195
+ if (origin.hostname !== config.rpId && !origin.hostname.endsWith(`.${config.rpId}`)) throw new 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 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 AuthConfigurationError("MFA requires unique constraints, conditional writes and atomic batches.");
202
+ await 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 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 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 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 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 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 AuthProviderError("The MFA rate limiter returned an invalid result.");
257
+ if (!result.allowed) throw new AuthRateLimitError(result.resetAt);
258
+ }
259
+ }
260
+ createAuditRecord(action, userId, metadata, sessionId = null) {
261
+ const now = this.now();
262
+ return {
263
+ id: 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 AuthSessionError("An authenticated session is required.");
275
+ let session;
276
+ try {
277
+ session = await this.sessions.validate(sessionToken);
278
+ } catch (error) {
279
+ throw new AuthSessionError("Could not validate the session.", { cause: error });
280
+ }
281
+ if (!session || session.kind !== "authenticated" || !session.userId) throw new 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 AuthSessionError("The authenticated account is unavailable.");
284
+ if (user.sessionsInvalidatedAt && session.createdAt.getTime() <= user.sessionsInvalidatedAt.getTime()) throw new AuthSessionError("The session is no longer valid.");
285
+ if (this.requireVerifiedEmail && !user.emailVerifiedAt) throw new AuthEmailVerificationRequiredError();
286
+ return {
287
+ session,
288
+ user
289
+ };
290
+ }
291
+ async verifyCurrentPassword(userId, password) {
292
+ if (typeof password !== "string" || password.length === 0) throw new 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 AuthInvalidCredentialsError();
298
+ let valid;
299
+ try {
300
+ valid = await this.passwordHasher.verify(password, credential.passwordHash);
301
+ } catch (error) {
302
+ throw new AuthProviderError("Password verification failed.", { cause: error });
303
+ }
304
+ if (!valid) throw new 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 = generateSecureAuthToken();
328
+ return {
329
+ record: {
330
+ id: generateAuthId("mfa-challenge"),
331
+ purpose,
332
+ userId,
333
+ tokenHash: await 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 AuthMfaChallengeError();
353
+ const tokenHash = await 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 AuthMfaChallengeError();
356
+ if ((purpose === "sign-in" || purpose === "enrollment") && !await this.getPasswordSnapshot(record)) throw new 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 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 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 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 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 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 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 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 AuthMfaChallengeError({ cause: error });
505
+ }
506
+ throw new 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: generateAuthId("recovery-code"),
527
+ userId,
528
+ factorId,
529
+ codeHash: await 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 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 AuthMfaChallengeError();
554
+ if (this.requireVerifiedEmail && !user.emailVerifiedAt) throw new 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 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 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 AuthSessionError("Could not revoke an uncommitted MFA session.", { cause: revokeError });
653
+ }
654
+ if (error instanceof StorageConflictError || error instanceof StorageDuplicateError) throw new AuthMfaChallengeError({ cause: error });
655
+ throw new 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 AuthMfaChallengeError();
663
+ if (challenge.webAuthnChallenge) throw new AuthMfaChallengeError();
664
+ await this.enforceRateLimit("required-enrollment", input.ipAddress, challenge.userId);
665
+ const snapshot = await this.getPasswordSnapshot(challenge);
666
+ if (!snapshot) throw new AuthMfaChallengeError();
667
+ const secret = generateTotpSecret();
668
+ let protectedSecret;
669
+ try {
670
+ protectedSecret = await this.secretProtector.encrypt(secret);
671
+ } catch (error) {
672
+ throw new 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 AuthProviderError("The secret protector returned an invalid encrypted secret.");
675
+ const now = this.now();
676
+ const factorId = 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 StorageConflictError || error instanceof StorageDuplicateError) throw new AuthMfaChallengeError({ cause: error });
725
+ throw new AuthProviderError("Could not create the pending TOTP factor.", { cause: error });
726
+ }
727
+ return {
728
+ factorId,
729
+ secret,
730
+ otpauthUrl: 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 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 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 AuthProviderError("Could not decrypt the TOTP secret.", { cause: error });
752
+ }
753
+ const now = this.now();
754
+ const matchedStep = await 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 AuthMfaChallengeError();
791
+ await this.enforceRateLimit("required-passkey-enrollment", input.ipAddress, challenge.userId);
792
+ const snapshot = await this.getPasswordSnapshot(challenge);
793
+ if (!snapshot) throw new 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 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 StorageConflictError || error instanceof StorageDuplicateError) throw new AuthMfaChallengeError({ cause: error });
832
+ throw new 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 AuthMfaChallengeError();
845
+ await this.enforceRateLimit("verify-required-passkey-enrollment", input.ipAddress, challenge.userId);
846
+ if (!await this.getPasswordSnapshot(challenge)) throw new 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 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: 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 = generateTotpSecret();
902
+ let protectedSecret;
903
+ try {
904
+ protectedSecret = await this.secretProtector.encrypt(secret);
905
+ } catch (error) {
906
+ throw new 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 AuthProviderError("The secret protector returned an invalid encrypted secret.");
909
+ const now = this.now();
910
+ const factorId = 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 AuthProviderError("Could not create the pending TOTP factor.", { cause: error });
947
+ }
948
+ return {
949
+ factorId,
950
+ secret,
951
+ otpauthUrl: 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 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 AuthProviderError("Could not decrypt the TOTP secret.", { cause: error });
972
+ }
973
+ const now = this.now();
974
+ const matchedStep = await verifyTotpCode(secret, input.code, now.getTime(), this.totp);
975
+ if (matchedStep === null) throw new 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 StorageConflictError) throw new AuthMfaFactorError("The TOTP enrollment has already been completed.", { cause: error });
1018
+ throw new 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 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 AuthProviderError("Could not decrypt the TOTP secret.", { cause: error });
1038
+ }
1039
+ const now = this.now();
1040
+ const matchedStep = await 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 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 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 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 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 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 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 StorageConflictError) throw new AuthMfaFactorError("The factor changed during removal.", { cause: error });
1188
+ throw new AuthProviderError("Could not remove the MFA factor.", { cause: error });
1189
+ }
1190
+ }
1191
+ getWebAuthn() {
1192
+ if (!this.webAuthn || !this.webAuthnProvider) throw new 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 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 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 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 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 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: 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 StorageDuplicateError || error instanceof StorageConflictError) throw new AuthMfaFactorError("This passkey is already registered or the challenge was consumed.", { cause: error });
1322
+ throw new 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 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 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 AuthMfaChallengeError();
1369
+ if (!input.factor.credentialId || !input.factor.publicKey) throw new 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 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 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 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 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 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 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
+ export { StorageAuthMfaManager };
1487
+
1488
+ //# sourceMappingURL=auth.mfa.manager.js.map