@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,835 @@
1
+ const require_auth_errors = require("./errors/auth.errors.cjs");
2
+ const require_auth_storage = require("./auth.storage.cjs");
3
+ const require_auth_crypto_utils = require("./utils/auth-crypto.utils.cjs");
4
+ const require_email_utils = require("./utils/email.utils.cjs");
5
+ const require_auth_rate_limit = require("./auth.rate-limit.cjs");
6
+ let _puzzle_ui_storage = require("@puzzle-ui/storage");
7
+ // src/auth.lifecycle.manager.ts
8
+ const DEFAULT_TOKEN_LIFETIMES = {
9
+ emailVerification: 3600,
10
+ passwordReset: 3600,
11
+ emailChange: 3600
12
+ };
13
+ const DEFAULT_RATE_LIMITS = {
14
+ emailVerification: {
15
+ email: {
16
+ limit: 3,
17
+ windowSeconds: 3600
18
+ },
19
+ ip: {
20
+ limit: 10,
21
+ windowSeconds: 3600
22
+ }
23
+ },
24
+ passwordReset: {
25
+ email: {
26
+ limit: 3,
27
+ windowSeconds: 3600
28
+ },
29
+ ip: {
30
+ limit: 10,
31
+ windowSeconds: 3600
32
+ }
33
+ },
34
+ emailChange: {
35
+ email: {
36
+ limit: 3,
37
+ windowSeconds: 3600
38
+ },
39
+ ip: {
40
+ limit: 10,
41
+ windowSeconds: 3600
42
+ }
43
+ }
44
+ };
45
+ const toPublicUser = (record) => ({
46
+ id: record.id,
47
+ email: record.email,
48
+ emailVerifiedAt: record.emailVerifiedAt,
49
+ name: record.name,
50
+ image: record.image,
51
+ createdAt: record.createdAt,
52
+ updatedAt: record.updatedAt
53
+ });
54
+ const validateIpAddress = (ipAddress) => {
55
+ 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.");
56
+ return ipAddress.trim();
57
+ };
58
+ const validatePassword = (password, minimumLength, maximumLength) => {
59
+ if (typeof password !== "string") throw new require_auth_errors.AuthValidationError("A password is required.");
60
+ const length = Array.from(password).length;
61
+ const byteLength = new TextEncoder().encode(password).byteLength;
62
+ if (length < minimumLength) throw new require_auth_errors.AuthValidationError(`Password must contain at least ${minimumLength} characters.`);
63
+ if (length > maximumLength || byteLength > 4096) throw new require_auth_errors.AuthValidationError(`Password must contain no more than ${maximumLength} characters.`);
64
+ };
65
+ const validateImageUrl = (image) => {
66
+ if (image === void 0 || image === null || image === "") return null;
67
+ if (typeof image !== "string" || image.length > 2048) throw new require_auth_errors.AuthValidationError("The profile image URL is invalid.");
68
+ let url;
69
+ try {
70
+ url = new URL(image);
71
+ } catch {
72
+ throw new require_auth_errors.AuthValidationError("The profile image URL is invalid.");
73
+ }
74
+ if (url.protocol !== "https:" || url.username || url.password) throw new require_auth_errors.AuthValidationError("Profile image URLs must use HTTPS and cannot contain credentials.");
75
+ return url.toString();
76
+ };
77
+ const createTokenUrl = (baseUrl, token) => {
78
+ let url;
79
+ try {
80
+ url = new URL(baseUrl);
81
+ } catch {
82
+ throw new require_auth_errors.AuthValidationError("The configured authentication callback URL is invalid.");
83
+ }
84
+ const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
85
+ if (url.protocol !== "https:" && !(isLocalhost && url.protocol === "http:") || url.username || url.password) throw new require_auth_errors.AuthValidationError("Authentication callback URLs must use HTTPS.");
86
+ url.searchParams.set("token", token);
87
+ return url.toString();
88
+ };
89
+ const validateRateLimitRule = (name, rule) => {
90
+ if (!rule || !Number.isInteger(rule.limit) || rule.limit < 1 || !Number.isFinite(rule.windowSeconds) || rule.windowSeconds <= 0) throw new require_auth_errors.AuthConfigurationError(`${name} must have a positive integer limit and a positive window.`);
91
+ };
92
+ var StorageAuthLifecycleManager = class {
93
+ storage;
94
+ passwordHasher;
95
+ emailProvider;
96
+ sessions;
97
+ rateLimitProvider;
98
+ rateLimitKeySecret;
99
+ usersCollection;
100
+ credentialsCollection;
101
+ actionTokensCollection;
102
+ auditCollection;
103
+ rateLimitsCollection;
104
+ requireVerifiedEmail;
105
+ onSessionRevocationError;
106
+ emailTemplates;
107
+ emailSubjects;
108
+ minimumPasswordLength;
109
+ maximumPasswordLength;
110
+ tokenLifetimes;
111
+ rateLimits;
112
+ clock;
113
+ onEmailDeliveryError;
114
+ initialized = false;
115
+ initializationPromise = null;
116
+ constructor(dependencies, options) {
117
+ this.storage = dependencies.storage;
118
+ this.passwordHasher = dependencies.passwordHasher;
119
+ this.emailProvider = dependencies.emailProvider;
120
+ this.sessions = dependencies.sessions;
121
+ if (typeof options.rateLimitKeySecret !== "string" || options.rateLimitKeySecret.length < 32) throw new require_auth_errors.AuthConfigurationError("rateLimitKeySecret must contain at least 32 characters.");
122
+ this.rateLimitKeySecret = options.rateLimitKeySecret;
123
+ this.usersCollection = options.usersCollection ?? "auth_users";
124
+ this.credentialsCollection = options.credentialsCollection ?? "auth_credentials";
125
+ this.actionTokensCollection = options.actionTokensCollection ?? "auth_action_tokens";
126
+ this.auditCollection = options.auditCollection ?? "auth_audit_events";
127
+ this.rateLimitsCollection = options.rateLimitsCollection ?? "auth_rate_limits";
128
+ this.requireVerifiedEmail = options.requireVerifiedEmail ?? false;
129
+ this.onSessionRevocationError = options.onSessionRevocationError;
130
+ this.emailTemplates = {
131
+ emailVerification: options.emailTemplates?.emailVerification ?? "auth/email-verification",
132
+ passwordReset: options.emailTemplates?.passwordReset ?? "auth/password-reset",
133
+ emailChange: options.emailTemplates?.emailChange ?? "auth/email-change-verification"
134
+ };
135
+ this.emailSubjects = {
136
+ emailVerification: options.emailSubjects?.emailVerification ?? "Verify your email address",
137
+ passwordReset: options.emailSubjects?.passwordReset ?? "Reset your password",
138
+ emailChange: options.emailSubjects?.emailChange ?? "Confirm your new email address"
139
+ };
140
+ this.minimumPasswordLength = options.minimumPasswordLength ?? 8;
141
+ this.maximumPasswordLength = options.maximumPasswordLength ?? 1024;
142
+ if (!Number.isInteger(this.minimumPasswordLength) || !Number.isInteger(this.maximumPasswordLength) || this.minimumPasswordLength < 1 || this.maximumPasswordLength < this.minimumPasswordLength) throw new require_auth_errors.AuthConfigurationError("Password length configuration is invalid.");
143
+ this.tokenLifetimes = {
144
+ emailVerification: options.emailVerificationTokenLifetimeSeconds ?? DEFAULT_TOKEN_LIFETIMES.emailVerification,
145
+ passwordReset: options.passwordResetTokenLifetimeSeconds ?? DEFAULT_TOKEN_LIFETIMES.passwordReset,
146
+ emailChange: options.emailChangeTokenLifetimeSeconds ?? DEFAULT_TOKEN_LIFETIMES.emailChange
147
+ };
148
+ for (const [name, lifetime] of Object.entries(this.tokenLifetimes)) if (!Number.isFinite(lifetime) || lifetime <= 0) throw new require_auth_errors.AuthConfigurationError(`${name} token lifetime must be positive.`);
149
+ this.rateLimits = {
150
+ emailVerification: {
151
+ ...DEFAULT_RATE_LIMITS.emailVerification,
152
+ ...options.rateLimits?.emailVerification
153
+ },
154
+ passwordReset: {
155
+ ...DEFAULT_RATE_LIMITS.passwordReset,
156
+ ...options.rateLimits?.passwordReset
157
+ },
158
+ emailChange: {
159
+ ...DEFAULT_RATE_LIMITS.emailChange,
160
+ ...options.rateLimits?.emailChange
161
+ }
162
+ };
163
+ for (const [action, policy] of Object.entries(this.rateLimits)) {
164
+ validateRateLimitRule(`${action} email rate limit`, policy.email);
165
+ validateRateLimitRule(`${action} IP rate limit`, policy.ip);
166
+ }
167
+ this.clock = options.clock ?? (() => /* @__PURE__ */ new Date());
168
+ this.onEmailDeliveryError = options.onEmailDeliveryError;
169
+ if (!this.storage.capabilities.conditionalWrites || !this.storage.capabilities.atomicBatch || !this.storage.capabilities.uniqueConstraints) throw new require_auth_errors.AuthConfigurationError("Auth lifecycle workflows require conditional writes, unique constraints and atomic batches.");
170
+ this.rateLimitProvider = dependencies.rateLimitProvider ?? new require_auth_rate_limit.StorageRateLimitProvider(this.storage, {
171
+ collectionName: this.rateLimitsCollection,
172
+ clock: this.clock
173
+ });
174
+ }
175
+ async initialize() {
176
+ if (!this.initializationPromise) {
177
+ const initialization = (async () => {
178
+ await require_auth_storage.initializeAuthStorage(this.storage, {
179
+ usersCollection: this.usersCollection,
180
+ credentialsCollection: this.credentialsCollection,
181
+ actionTokensCollection: this.actionTokensCollection,
182
+ auditCollection: this.auditCollection,
183
+ rateLimitsCollection: this.rateLimitsCollection
184
+ });
185
+ if (this.rateLimitProvider instanceof require_auth_rate_limit.StorageRateLimitProvider) await this.rateLimitProvider.initialize();
186
+ this.initialized = true;
187
+ })();
188
+ this.initializationPromise = initialization.catch((error) => {
189
+ this.initializationPromise = null;
190
+ throw error;
191
+ });
192
+ }
193
+ return this.initializationPromise;
194
+ }
195
+ assertInitialized() {
196
+ if (!this.initialized) throw new require_auth_errors.AuthConfigurationError("Auth lifecycle manager must be initialized before use.");
197
+ }
198
+ now() {
199
+ const now = this.clock();
200
+ if (!(now instanceof Date) || !Number.isFinite(now.getTime())) throw new require_auth_errors.AuthConfigurationError("The configured clock returned an invalid date.");
201
+ return now;
202
+ }
203
+ async consumeRateLimit(action, scope, identifier) {
204
+ const rule = this.rateLimits[action][scope];
205
+ const key = await require_auth_crypto_utils.createRateLimitKey(this.rateLimitKeySecret, `auth:${action}:${scope}:${identifier}`);
206
+ let decision;
207
+ try {
208
+ decision = await this.rateLimitProvider.consume({
209
+ key,
210
+ limit: rule.limit,
211
+ windowSeconds: rule.windowSeconds
212
+ });
213
+ } catch (error) {
214
+ throw new require_auth_errors.AuthProviderError("The authentication rate limiter is unavailable.", { cause: error });
215
+ }
216
+ if (!decision || typeof decision.allowed !== "boolean" || !(decision.resetAt instanceof Date) || !Number.isFinite(decision.resetAt.getTime())) throw new require_auth_errors.AuthProviderError("The rate limiter returned an invalid response.");
217
+ if (!decision.allowed) throw new require_auth_errors.AuthRateLimitError(decision.resetAt);
218
+ }
219
+ async enforceRateLimits(action, email, ipAddress) {
220
+ await this.consumeRateLimit(action, "email", email);
221
+ await this.consumeRateLimit(action, "ip", ipAddress);
222
+ }
223
+ async createActionToken(input) {
224
+ const now = this.now();
225
+ const token = require_auth_crypto_utils.generateSecureAuthToken();
226
+ return {
227
+ token,
228
+ record: {
229
+ id: require_auth_crypto_utils.generateAuthId("action-token"),
230
+ userId: input.userId,
231
+ purpose: input.purpose,
232
+ tokenHash: await require_auth_crypto_utils.hashAuthToken(token),
233
+ expiresAt: new Date(now.getTime() + input.lifetimeSeconds * 1e3),
234
+ consumedAt: null,
235
+ targetEmail: input.targetEmail ?? null,
236
+ createdAt: now,
237
+ updatedAt: now,
238
+ revision: 1,
239
+ revokedAt: null
240
+ }
241
+ };
242
+ }
243
+ createAuditRecord(action, userId, sessionId = null, metadata) {
244
+ const now = this.now();
245
+ return {
246
+ id: require_auth_crypto_utils.generateAuthId("auth-audit"),
247
+ action,
248
+ userId,
249
+ sessionId,
250
+ metadata,
251
+ createdAt: now,
252
+ updatedAt: now,
253
+ revision: 1
254
+ };
255
+ }
256
+ /**
257
+ * All operations in a lifecycle batch must succeed.
258
+ *
259
+ * Storage adapters may report a missing conditional update or delete
260
+ * through the batch result rather than by throwing an error. Treat those
261
+ * results as conflicts so a token cannot be consumed without its related
262
+ * account or credential mutation.
263
+ */
264
+ async atomic(operations) {
265
+ try {
266
+ const result = await this.storage.atomicBatch(operations);
267
+ for (const [index, operation] of operations.entries()) {
268
+ const operationResult = result.results[index];
269
+ if (operation.type === "update") {
270
+ if (!operationResult || operationResult.type !== "update" || !operationResult.record) throw new _puzzle_ui_storage.StorageConflictError("A conditional authentication update did not match its record.");
271
+ }
272
+ if (operation.type === "delete") {
273
+ if (!operationResult || operationResult.type !== "delete" || !operationResult.deleted) throw new _puzzle_ui_storage.StorageConflictError("A conditional authentication delete did not match its record.");
274
+ }
275
+ if (operation.type === "insert") {
276
+ if (!operationResult || operationResult.type !== "insert") throw new _puzzle_ui_storage.StorageConflictError("An authentication insert did not return its record.");
277
+ }
278
+ }
279
+ } catch (error) {
280
+ if (error instanceof _puzzle_ui_storage.StorageDuplicateError) throw new require_auth_errors.AuthAccountConflictError({ cause: error });
281
+ throw error;
282
+ }
283
+ }
284
+ async deliverEmail(message, suppressFailure) {
285
+ try {
286
+ await this.emailProvider.send(message);
287
+ } catch (error) {
288
+ try {
289
+ this.onEmailDeliveryError?.(error);
290
+ } catch {}
291
+ if (!suppressFailure) throw new require_auth_errors.AuthProviderError("Authentication email delivery failed.", { cause: error });
292
+ }
293
+ }
294
+ async getSessionContext(token) {
295
+ if (typeof token !== "string" || token.length === 0) throw new require_auth_errors.AuthSessionError("An authenticated session is required.");
296
+ let session;
297
+ try {
298
+ session = await this.sessions.validate(token);
299
+ } catch (error) {
300
+ throw new require_auth_errors.AuthSessionError("Could not validate the authentication session.", { cause: error });
301
+ }
302
+ if (!session || session.kind !== "authenticated" || !session.userId) throw new require_auth_errors.AuthSessionError("An authenticated session is required.");
303
+ const user = await this.storage.findById(this.usersCollection, session.userId);
304
+ if (!user || user.deactivatedAt != null) throw new require_auth_errors.AuthSessionError("The authenticated account is unavailable.");
305
+ if (user.sessionsInvalidatedAt && session.createdAt.getTime() <= user.sessionsInvalidatedAt.getTime()) throw new require_auth_errors.AuthSessionError("The authentication session is no longer valid.");
306
+ if (this.requireVerifiedEmail && !user.emailVerifiedAt) throw new require_auth_errors.AuthEmailVerificationRequiredError();
307
+ return {
308
+ session,
309
+ user
310
+ };
311
+ }
312
+ async findActionToken(token, purpose) {
313
+ if (typeof token !== "string" || token.length < 32 || token.length > 256) throw new require_auth_errors.AuthTokenError();
314
+ const tokenHash = await require_auth_crypto_utils.hashAuthToken(token);
315
+ const record = await this.storage.findOne(this.actionTokensCollection, { where: { tokenHash: {
316
+ operator: "eq",
317
+ value: tokenHash
318
+ } } });
319
+ const now = this.now();
320
+ if (!record || record.purpose !== purpose || record.consumedAt !== null || record.revokedAt != null || record.expiresAt.getTime() <= now.getTime()) throw new require_auth_errors.AuthTokenError();
321
+ return record;
322
+ }
323
+ async findCredential(userId) {
324
+ return this.storage.findOne(this.credentialsCollection, { where: {
325
+ userId: {
326
+ operator: "eq",
327
+ value: userId
328
+ },
329
+ kind: {
330
+ operator: "eq",
331
+ value: "password"
332
+ }
333
+ } });
334
+ }
335
+ async verifyPassword(password, stored) {
336
+ try {
337
+ return await this.passwordHasher.verify(password, stored);
338
+ } catch (error) {
339
+ throw new require_auth_errors.AuthProviderError("Password verification failed.", { cause: error });
340
+ }
341
+ }
342
+ async hashPassword(password) {
343
+ try {
344
+ const result = await this.passwordHasher.hash(password);
345
+ if (!result || result.algorithm !== "argon2id" || typeof result.hash !== "string" || result.hash.length === 0) throw new Error("PasswordHasher returned an invalid Argon2id hash.");
346
+ return result;
347
+ } catch (error) {
348
+ throw new require_auth_errors.AuthProviderError("Password hashing failed.", { cause: error });
349
+ }
350
+ }
351
+ async revokeAllSessions(userId) {
352
+ try {
353
+ await this.sessions.revokeAll(userId);
354
+ } catch (error) {
355
+ try {
356
+ this.onSessionRevocationError?.(error);
357
+ } catch {}
358
+ }
359
+ }
360
+ async sendEmailVerification(input) {
361
+ this.assertInitialized();
362
+ const ipAddress = validateIpAddress(input.ipAddress);
363
+ const initialUser = await this.storage.findById(this.usersCollection, input.userId);
364
+ if (!initialUser) throw new require_auth_errors.AuthValidationError("The account does not exist.");
365
+ if (initialUser.emailVerifiedAt) return;
366
+ await this.enforceRateLimits("emailVerification", initialUser.normalizedEmail, ipAddress);
367
+ const maximumAttempts = 5;
368
+ for (let attempt = 0; attempt < maximumAttempts; attempt += 1) {
369
+ const user = await this.storage.findById(this.usersCollection, input.userId);
370
+ if (!user) throw new require_auth_errors.AuthValidationError("The account does not exist.");
371
+ if (user.emailVerifiedAt) return;
372
+ const created = await this.createActionToken({
373
+ userId: user.id,
374
+ purpose: "email-verification",
375
+ lifetimeSeconds: this.tokenLifetimes.emailVerification
376
+ });
377
+ const outstandingTokens = await this.storage.findMany(this.actionTokensCollection, { where: {
378
+ userId: {
379
+ operator: "eq",
380
+ value: user.id
381
+ },
382
+ purpose: {
383
+ operator: "eq",
384
+ value: "email-verification"
385
+ },
386
+ consumedAt: {
387
+ operator: "eq",
388
+ value: null
389
+ }
390
+ } });
391
+ const operations = [
392
+ {
393
+ type: "update",
394
+ collection: this.usersCollection,
395
+ id: user.id,
396
+ condition: { expectedRevision: user.revision },
397
+ changes: { updatedAt: created.record.createdAt }
398
+ },
399
+ {
400
+ type: "insert",
401
+ collection: this.actionTokensCollection,
402
+ record: created.record
403
+ },
404
+ {
405
+ type: "insert",
406
+ collection: this.auditCollection,
407
+ record: this.createAuditRecord("email-verification-requested", user.id)
408
+ }
409
+ ];
410
+ for (const outstanding of outstandingTokens) {
411
+ if (outstanding.revokedAt != null) continue;
412
+ operations.push({
413
+ type: "update",
414
+ collection: this.actionTokensCollection,
415
+ id: outstanding.id,
416
+ condition: { expectedRevision: outstanding.revision },
417
+ changes: { revokedAt: created.record.createdAt }
418
+ });
419
+ }
420
+ try {
421
+ await this.atomic(operations);
422
+ } catch (error) {
423
+ if (error instanceof _puzzle_ui_storage.StorageConflictError) {
424
+ if (attempt < 4) continue;
425
+ throw new require_auth_errors.AuthProviderError("Could not safely issue an email verification token due to concurrent account updates.", { cause: error });
426
+ }
427
+ throw error;
428
+ }
429
+ const verificationUrl = createTokenUrl(input.verificationUrl, created.token);
430
+ await this.deliverEmail({
431
+ to: user.email,
432
+ subject: this.emailSubjects.emailVerification,
433
+ template: this.emailTemplates.emailVerification,
434
+ variables: {
435
+ url: verificationUrl,
436
+ expiresAt: created.record.expiresAt.toISOString()
437
+ }
438
+ }, false);
439
+ return;
440
+ }
441
+ }
442
+ async verifyEmail(token) {
443
+ this.assertInitialized();
444
+ const actionToken = await this.findActionToken(token, "email-verification");
445
+ const user = await this.storage.findById(this.usersCollection, actionToken.userId);
446
+ if (!user) throw new require_auth_errors.AuthTokenError();
447
+ const now = this.now();
448
+ try {
449
+ await this.atomic([
450
+ {
451
+ type: "update",
452
+ collection: this.actionTokensCollection,
453
+ id: actionToken.id,
454
+ condition: { expectedRevision: actionToken.revision },
455
+ changes: { consumedAt: now }
456
+ },
457
+ {
458
+ type: "update",
459
+ collection: this.usersCollection,
460
+ id: user.id,
461
+ condition: { expectedRevision: user.revision },
462
+ changes: {
463
+ emailVerifiedAt: now,
464
+ updatedAt: now
465
+ }
466
+ },
467
+ {
468
+ type: "insert",
469
+ collection: this.auditCollection,
470
+ record: this.createAuditRecord("email-verified", user.id)
471
+ }
472
+ ]);
473
+ } catch (error) {
474
+ if (error instanceof _puzzle_ui_storage.StorageConflictError) throw new require_auth_errors.AuthTokenError();
475
+ throw error;
476
+ }
477
+ const updated = await this.storage.findById(this.usersCollection, user.id);
478
+ if (!updated) throw new require_auth_errors.AuthProviderError("The verified account could not be retrieved.");
479
+ return toPublicUser(updated);
480
+ }
481
+ /**
482
+ * Always resolves with the same public result, whether or not the email
483
+ * address belongs to an account. Delivery errors are reported only through
484
+ * onEmailDeliveryError.
485
+ */
486
+ async requestPasswordReset(input) {
487
+ this.assertInitialized();
488
+ const email = require_email_utils.normalizeEmail(input.email);
489
+ const ipAddress = validateIpAddress(input.ipAddress);
490
+ await this.enforceRateLimits("passwordReset", email, ipAddress);
491
+ const user = await this.storage.findOne(this.usersCollection, { where: { normalizedEmail: {
492
+ operator: "eq",
493
+ value: email
494
+ } } });
495
+ if (!user) {
496
+ await this.storage.insert(this.auditCollection, this.createAuditRecord("password-reset-requested", null));
497
+ return;
498
+ }
499
+ const created = await this.createActionToken({
500
+ userId: user.id,
501
+ purpose: "password-reset",
502
+ lifetimeSeconds: this.tokenLifetimes.passwordReset
503
+ });
504
+ await this.atomic([{
505
+ type: "insert",
506
+ collection: this.actionTokensCollection,
507
+ record: created.record
508
+ }, {
509
+ type: "insert",
510
+ collection: this.auditCollection,
511
+ record: this.createAuditRecord("password-reset-requested", user.id)
512
+ }]);
513
+ const resetUrl = createTokenUrl(input.resetUrl, created.token);
514
+ await this.deliverEmail({
515
+ to: user.email,
516
+ subject: this.emailSubjects.passwordReset,
517
+ template: this.emailTemplates.passwordReset,
518
+ variables: {
519
+ url: resetUrl,
520
+ expiresAt: created.record.expiresAt.toISOString()
521
+ }
522
+ }, true);
523
+ }
524
+ async resetPassword(input) {
525
+ this.assertInitialized();
526
+ validatePassword(input.newPassword, this.minimumPasswordLength, this.maximumPasswordLength);
527
+ const actionToken = await this.findActionToken(input.token, "password-reset");
528
+ const user = await this.storage.findById(this.usersCollection, actionToken.userId);
529
+ if (!user || user.deactivatedAt != null) throw new require_auth_errors.AuthTokenError();
530
+ const credential = await this.findCredential(actionToken.userId);
531
+ if (!credential || credential.revokedAt !== null) throw new require_auth_errors.AuthTokenError();
532
+ const passwordHash = await this.hashPassword(input.newPassword);
533
+ const now = this.now();
534
+ const otherTokens = await this.storage.findMany(this.actionTokensCollection, { where: {
535
+ userId: {
536
+ operator: "eq",
537
+ value: actionToken.userId
538
+ },
539
+ purpose: {
540
+ operator: "eq",
541
+ value: "password-reset"
542
+ },
543
+ consumedAt: {
544
+ operator: "eq",
545
+ value: null
546
+ }
547
+ } });
548
+ const operations = [
549
+ {
550
+ type: "update",
551
+ collection: this.actionTokensCollection,
552
+ id: actionToken.id,
553
+ condition: { expectedRevision: actionToken.revision },
554
+ changes: { consumedAt: now }
555
+ },
556
+ {
557
+ type: "update",
558
+ collection: this.credentialsCollection,
559
+ id: credential.id,
560
+ condition: { expectedRevision: credential.revision },
561
+ changes: {
562
+ passwordHash,
563
+ passwordUpdatedAt: now,
564
+ updatedAt: now,
565
+ revokedAt: null
566
+ }
567
+ },
568
+ {
569
+ type: "update",
570
+ collection: this.usersCollection,
571
+ id: user.id,
572
+ condition: { expectedRevision: user.revision },
573
+ changes: {
574
+ sessionsInvalidatedAt: now,
575
+ updatedAt: now
576
+ }
577
+ },
578
+ {
579
+ type: "insert",
580
+ collection: this.auditCollection,
581
+ record: this.createAuditRecord("password-reset-completed", actionToken.userId)
582
+ }
583
+ ];
584
+ for (const other of otherTokens) {
585
+ if (other.id === actionToken.id || other.revokedAt != null) continue;
586
+ operations.push({
587
+ type: "update",
588
+ collection: this.actionTokensCollection,
589
+ id: other.id,
590
+ condition: { expectedRevision: other.revision },
591
+ changes: { revokedAt: now }
592
+ });
593
+ }
594
+ try {
595
+ await this.atomic(operations);
596
+ } catch (error) {
597
+ if (error instanceof _puzzle_ui_storage.StorageConflictError || error instanceof require_auth_errors.AuthAccountConflictError) throw new require_auth_errors.AuthTokenError();
598
+ throw error;
599
+ }
600
+ await this.revokeAllSessions(actionToken.userId);
601
+ }
602
+ async changePassword(input) {
603
+ this.assertInitialized();
604
+ validatePassword(input.newPassword, this.minimumPasswordLength, this.maximumPasswordLength);
605
+ const { session, user } = await this.getSessionContext(input.sessionToken);
606
+ const credential = await this.findCredential(user.id);
607
+ if (!credential || credential.revokedAt !== null || !await this.verifyPassword(input.currentPassword, credential.passwordHash)) throw new require_auth_errors.AuthValidationError("The current password is incorrect.");
608
+ const passwordHash = await this.hashPassword(input.newPassword);
609
+ const now = this.now();
610
+ const resetTokens = await this.storage.findMany(this.actionTokensCollection, { where: {
611
+ userId: {
612
+ operator: "eq",
613
+ value: user.id
614
+ },
615
+ purpose: {
616
+ operator: "eq",
617
+ value: "password-reset"
618
+ },
619
+ consumedAt: {
620
+ operator: "eq",
621
+ value: null
622
+ }
623
+ } });
624
+ const operations = [
625
+ {
626
+ type: "update",
627
+ collection: this.credentialsCollection,
628
+ id: credential.id,
629
+ condition: { expectedRevision: credential.revision },
630
+ changes: {
631
+ passwordHash,
632
+ passwordUpdatedAt: now,
633
+ updatedAt: now
634
+ }
635
+ },
636
+ {
637
+ type: "update",
638
+ collection: this.usersCollection,
639
+ id: user.id,
640
+ condition: { expectedRevision: user.revision },
641
+ changes: {
642
+ sessionsInvalidatedAt: now,
643
+ updatedAt: now
644
+ }
645
+ },
646
+ {
647
+ type: "insert",
648
+ collection: this.auditCollection,
649
+ record: this.createAuditRecord("password-changed", user.id, session.id)
650
+ }
651
+ ];
652
+ for (const token of resetTokens) {
653
+ if (token.revokedAt != null) continue;
654
+ operations.push({
655
+ type: "update",
656
+ collection: this.actionTokensCollection,
657
+ id: token.id,
658
+ condition: { expectedRevision: token.revision },
659
+ changes: { revokedAt: now }
660
+ });
661
+ }
662
+ await this.atomic(operations);
663
+ await this.revokeAllSessions(user.id);
664
+ }
665
+ async updateProfile(input) {
666
+ this.assertInitialized();
667
+ const { session, user } = await this.getSessionContext(input.sessionToken);
668
+ const changes = { updatedAt: this.now() };
669
+ if (input.name !== void 0) changes.name = require_email_utils.normalizeDisplayName(input.name);
670
+ if (input.image !== void 0) changes.image = validateImageUrl(input.image);
671
+ await this.atomic([{
672
+ type: "update",
673
+ collection: this.usersCollection,
674
+ id: user.id,
675
+ condition: { expectedRevision: user.revision },
676
+ changes
677
+ }, {
678
+ type: "insert",
679
+ collection: this.auditCollection,
680
+ record: this.createAuditRecord("profile-updated", user.id, session.id)
681
+ }]);
682
+ const updated = await this.storage.findById(this.usersCollection, user.id);
683
+ if (!updated) throw new require_auth_errors.AuthProviderError("The updated account could not be retrieved.");
684
+ return toPublicUser(updated);
685
+ }
686
+ async requestEmailChange(input) {
687
+ this.assertInitialized();
688
+ const { session, user } = await this.getSessionContext(input.sessionToken);
689
+ const credential = await this.findCredential(user.id);
690
+ if (!credential || credential.revokedAt !== null || !await this.verifyPassword(input.currentPassword, credential.passwordHash)) throw new require_auth_errors.AuthValidationError("The current password is incorrect.");
691
+ const newEmail = require_email_utils.normalizeEmail(input.email);
692
+ if (newEmail === user.normalizedEmail) throw new require_auth_errors.AuthValidationError("The new email address is already the current email address.");
693
+ const ipAddress = validateIpAddress(input.ipAddress);
694
+ await this.enforceRateLimits("emailChange", newEmail, ipAddress);
695
+ if (await this.storage.findOne(this.usersCollection, { where: { normalizedEmail: {
696
+ operator: "eq",
697
+ value: newEmail
698
+ } } })) throw new require_auth_errors.AuthAccountConflictError();
699
+ const created = await this.createActionToken({
700
+ userId: user.id,
701
+ purpose: "email-change",
702
+ targetEmail: newEmail,
703
+ lifetimeSeconds: this.tokenLifetimes.emailChange
704
+ });
705
+ await this.atomic([{
706
+ type: "insert",
707
+ collection: this.actionTokensCollection,
708
+ record: created.record
709
+ }, {
710
+ type: "insert",
711
+ collection: this.auditCollection,
712
+ record: this.createAuditRecord("email-change-requested", user.id, session.id)
713
+ }]);
714
+ const verificationUrl = createTokenUrl(input.verificationUrl, created.token);
715
+ await this.deliverEmail({
716
+ to: newEmail,
717
+ subject: this.emailSubjects.emailChange,
718
+ template: this.emailTemplates.emailChange,
719
+ variables: {
720
+ url: verificationUrl,
721
+ expiresAt: created.record.expiresAt.toISOString()
722
+ }
723
+ }, false);
724
+ }
725
+ async verifyEmailChange(token) {
726
+ this.assertInitialized();
727
+ const actionToken = await this.findActionToken(token, "email-change");
728
+ if (!actionToken.targetEmail) throw new require_auth_errors.AuthTokenError();
729
+ const user = await this.storage.findById(this.usersCollection, actionToken.userId);
730
+ if (!user) throw new require_auth_errors.AuthTokenError();
731
+ const newEmail = require_email_utils.normalizeEmail(actionToken.targetEmail);
732
+ const now = this.now();
733
+ try {
734
+ await this.atomic([
735
+ {
736
+ type: "update",
737
+ collection: this.actionTokensCollection,
738
+ id: actionToken.id,
739
+ condition: { expectedRevision: actionToken.revision },
740
+ changes: { consumedAt: now }
741
+ },
742
+ {
743
+ type: "update",
744
+ collection: this.usersCollection,
745
+ id: user.id,
746
+ condition: { expectedRevision: user.revision },
747
+ changes: {
748
+ email: newEmail,
749
+ normalizedEmail: newEmail,
750
+ emailVerifiedAt: now,
751
+ sessionsInvalidatedAt: now,
752
+ updatedAt: now
753
+ }
754
+ },
755
+ {
756
+ type: "insert",
757
+ collection: this.auditCollection,
758
+ record: this.createAuditRecord("email-changed", user.id)
759
+ }
760
+ ]);
761
+ } catch (error) {
762
+ if (error instanceof require_auth_errors.AuthAccountConflictError) throw error;
763
+ if (error instanceof _puzzle_ui_storage.StorageConflictError) throw new require_auth_errors.AuthTokenError();
764
+ throw error;
765
+ }
766
+ await this.revokeAllSessions(user.id);
767
+ const updated = await this.storage.findById(this.usersCollection, user.id);
768
+ if (!updated) throw new require_auth_errors.AuthProviderError("The updated account could not be retrieved.");
769
+ return toPublicUser(updated);
770
+ }
771
+ async deactivateAccount(input) {
772
+ this.assertInitialized();
773
+ const { session, user } = await this.getSessionContext(input.sessionToken);
774
+ const credential = await this.findCredential(user.id);
775
+ if (!credential || credential.revokedAt !== null || !await this.verifyPassword(input.password, credential.passwordHash)) throw new require_auth_errors.AuthValidationError("The password confirmation is incorrect.");
776
+ const now = this.now();
777
+ await this.atomic([{
778
+ type: "update",
779
+ collection: this.usersCollection,
780
+ id: user.id,
781
+ condition: { expectedRevision: user.revision },
782
+ changes: {
783
+ deactivatedAt: now,
784
+ sessionsInvalidatedAt: now,
785
+ updatedAt: now
786
+ }
787
+ }, {
788
+ type: "insert",
789
+ collection: this.auditCollection,
790
+ record: this.createAuditRecord("account-deactivated", user.id, session.id)
791
+ }]);
792
+ await this.revokeAllSessions(user.id);
793
+ }
794
+ async deleteAccount(input) {
795
+ this.assertInitialized();
796
+ const { session, user } = await this.getSessionContext(input.sessionToken);
797
+ const credential = await this.findCredential(user.id);
798
+ if (!credential || credential.revokedAt !== null || !await this.verifyPassword(input.password, credential.passwordHash)) throw new require_auth_errors.AuthValidationError("The password confirmation is incorrect.");
799
+ const credentials = await this.storage.findMany(this.credentialsCollection, { where: { userId: {
800
+ operator: "eq",
801
+ value: user.id
802
+ } } });
803
+ const actionTokens = await this.storage.findMany(this.actionTokensCollection, { where: { userId: {
804
+ operator: "eq",
805
+ value: user.id
806
+ } } });
807
+ const operations = [{
808
+ type: "delete",
809
+ collection: this.usersCollection,
810
+ id: user.id,
811
+ condition: { expectedRevision: user.revision }
812
+ }, {
813
+ type: "insert",
814
+ collection: this.auditCollection,
815
+ record: this.createAuditRecord("account-deleted", user.id, session.id)
816
+ }];
817
+ for (const item of credentials) operations.push({
818
+ type: "delete",
819
+ collection: this.credentialsCollection,
820
+ id: item.id,
821
+ condition: { expectedRevision: item.revision }
822
+ });
823
+ for (const item of actionTokens) operations.push({
824
+ type: "delete",
825
+ collection: this.actionTokensCollection,
826
+ id: item.id,
827
+ condition: { expectedRevision: item.revision }
828
+ });
829
+ await this.atomic(operations);
830
+ await this.revokeAllSessions(user.id);
831
+ }
832
+ };
833
+ exports.StorageAuthLifecycleManager = StorageAuthLifecycleManager;
834
+
835
+ //# sourceMappingURL=auth.lifecycle.manager.cjs.map