@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.
- package/README.md +301 -2
- package/dist/LICENSE +21 -0
- package/dist/auth.lifecycle.manager.cjs +835 -0
- package/dist/auth.lifecycle.manager.cjs.map +1 -0
- package/dist/auth.lifecycle.manager.d.cts +115 -0
- package/dist/auth.lifecycle.manager.d.ts +115 -0
- package/dist/auth.lifecycle.manager.js +835 -0
- package/dist/auth.lifecycle.manager.js.map +1 -0
- package/dist/auth.manager.cjs +454 -0
- package/dist/auth.manager.cjs.map +1 -0
- package/dist/auth.manager.d.cts +54 -0
- package/dist/auth.manager.d.ts +54 -0
- package/dist/auth.manager.js +454 -0
- package/dist/auth.manager.js.map +1 -0
- package/dist/auth.mfa.manager.cjs +1488 -0
- package/dist/auth.mfa.manager.cjs.map +1 -0
- package/dist/auth.mfa.manager.d.cts +100 -0
- package/dist/auth.mfa.manager.d.ts +100 -0
- package/dist/auth.mfa.manager.js +1488 -0
- package/dist/auth.mfa.manager.js.map +1 -0
- package/dist/auth.rate-limit.cjs +78 -0
- package/dist/auth.rate-limit.cjs.map +1 -0
- package/dist/auth.rate-limit.d.cts +24 -0
- package/dist/auth.rate-limit.d.ts +24 -0
- package/dist/auth.rate-limit.js +78 -0
- package/dist/auth.rate-limit.js.map +1 -0
- package/dist/auth.step-up.manager.cjs +269 -0
- package/dist/auth.step-up.manager.cjs.map +1 -0
- package/dist/auth.step-up.manager.d.cts +74 -0
- package/dist/auth.step-up.manager.d.ts +74 -0
- package/dist/auth.step-up.manager.js +268 -0
- package/dist/auth.step-up.manager.js.map +1 -0
- package/dist/auth.storage.cjs +164 -0
- package/dist/auth.storage.cjs.map +1 -0
- package/dist/auth.storage.d.cts +29 -0
- package/dist/auth.storage.d.ts +29 -0
- package/dist/auth.storage.js +148 -0
- package/dist/auth.storage.js.map +1 -0
- package/dist/errors/auth.errors.cjs +101 -0
- package/dist/errors/auth.errors.cjs.map +1 -0
- package/dist/errors/auth.errors.d.cts +62 -0
- package/dist/errors/auth.errors.d.ts +62 -0
- package/dist/errors/auth.errors.js +90 -0
- package/dist/errors/auth.errors.js.map +1 -0
- package/dist/index.cjs +54 -0
- package/dist/index.d.cts +21 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +13 -0
- package/dist/providers/mfa-provider.types.d.cts +66 -0
- package/dist/providers/mfa-provider.types.d.ts +66 -0
- package/dist/providers/provider.types.d.cts +34 -0
- package/dist/providers/provider.types.d.ts +34 -0
- package/dist/providers/simplewebauthn-provider.cjs +185 -0
- package/dist/providers/simplewebauthn-provider.cjs.map +1 -0
- package/dist/providers/simplewebauthn-provider.d.cts +35 -0
- package/dist/providers/simplewebauthn-provider.d.ts +35 -0
- package/dist/providers/simplewebauthn-provider.js +185 -0
- package/dist/providers/simplewebauthn-provider.js.map +1 -0
- package/dist/providers/webcrypto-secret-protector.cjs +118 -0
- package/dist/providers/webcrypto-secret-protector.cjs.map +1 -0
- package/dist/providers/webcrypto-secret-protector.d.cts +14 -0
- package/dist/providers/webcrypto-secret-protector.d.ts +14 -0
- package/dist/providers/webcrypto-secret-protector.js +118 -0
- package/dist/providers/webcrypto-secret-protector.js.map +1 -0
- package/dist/types/auth-lifecycle.types.d.cts +53 -0
- package/dist/types/auth-lifecycle.types.d.ts +53 -0
- package/dist/types/auth-manager.types.d.cts +106 -0
- package/dist/types/auth-manager.types.d.ts +106 -0
- package/dist/types/credential.types.d.cts +9 -0
- package/dist/types/credential.types.d.ts +9 -0
- package/dist/types/mfa.types.d.cts +244 -0
- package/dist/types/mfa.types.d.ts +244 -0
- package/dist/types/state.types.d.cts +33 -0
- package/dist/types/state.types.d.ts +33 -0
- package/dist/types/user.types.d.cts +9 -0
- package/dist/types/user.types.d.ts +9 -0
- package/dist/utils/auth-crypto.utils.cjs +38 -0
- package/dist/utils/auth-crypto.utils.cjs.map +1 -0
- package/dist/utils/auth-crypto.utils.d.cts +4 -0
- package/dist/utils/auth-crypto.utils.d.ts +4 -0
- package/dist/utils/auth-crypto.utils.js +35 -0
- package/dist/utils/auth-crypto.utils.js.map +1 -0
- package/dist/utils/email.utils.cjs +20 -0
- package/dist/utils/email.utils.cjs.map +1 -0
- package/dist/utils/email.utils.d.cts +2 -0
- package/dist/utils/email.utils.d.ts +2 -0
- package/dist/utils/email.utils.js +19 -0
- package/dist/utils/email.utils.js.map +1 -0
- package/dist/utils/totp.utils.cjs +145 -0
- package/dist/utils/totp.utils.cjs.map +1 -0
- package/dist/utils/totp.utils.d.cts +26 -0
- package/dist/utils/totp.utils.d.ts +26 -0
- package/dist/utils/totp.utils.js +140 -0
- package/dist/utils/totp.utils.js.map +1 -0
- package/package.json +45 -4
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
import { AuthAccountConflictError, AuthConfigurationError, AuthEmailVerificationRequiredError, AuthProviderError, AuthRateLimitError, AuthSessionError, AuthTokenError, AuthValidationError } from "./errors/auth.errors.js";
|
|
2
|
+
import { initializeAuthStorage } from "./auth.storage.js";
|
|
3
|
+
import { createRateLimitKey, generateAuthId, generateSecureAuthToken, hashAuthToken } from "./utils/auth-crypto.utils.js";
|
|
4
|
+
import { normalizeDisplayName, normalizeEmail } from "./utils/email.utils.js";
|
|
5
|
+
import { StorageRateLimitProvider } from "./auth.rate-limit.js";
|
|
6
|
+
import { StorageConflictError, StorageDuplicateError } from "@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 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 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 AuthValidationError(`Password must contain at least ${minimumLength} characters.`);
|
|
63
|
+
if (length > maximumLength || byteLength > 4096) throw new 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 AuthValidationError("The profile image URL is invalid.");
|
|
68
|
+
let url;
|
|
69
|
+
try {
|
|
70
|
+
url = new URL(image);
|
|
71
|
+
} catch {
|
|
72
|
+
throw new AuthValidationError("The profile image URL is invalid.");
|
|
73
|
+
}
|
|
74
|
+
if (url.protocol !== "https:" || url.username || url.password) throw new 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 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 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 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 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 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 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 AuthConfigurationError("Auth lifecycle workflows require conditional writes, unique constraints and atomic batches.");
|
|
170
|
+
this.rateLimitProvider = dependencies.rateLimitProvider ?? new 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 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 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 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 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 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 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 AuthProviderError("The rate limiter returned an invalid response.");
|
|
217
|
+
if (!decision.allowed) throw new 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 = generateSecureAuthToken();
|
|
226
|
+
return {
|
|
227
|
+
token,
|
|
228
|
+
record: {
|
|
229
|
+
id: generateAuthId("action-token"),
|
|
230
|
+
userId: input.userId,
|
|
231
|
+
purpose: input.purpose,
|
|
232
|
+
tokenHash: await 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: 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 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 StorageConflictError("A conditional authentication delete did not match its record.");
|
|
274
|
+
}
|
|
275
|
+
if (operation.type === "insert") {
|
|
276
|
+
if (!operationResult || operationResult.type !== "insert") throw new StorageConflictError("An authentication insert did not return its record.");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (error instanceof StorageDuplicateError) throw new 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 AuthProviderError("Authentication email delivery failed.", { cause: error });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async getSessionContext(token) {
|
|
295
|
+
if (typeof token !== "string" || token.length === 0) throw new AuthSessionError("An authenticated session is required.");
|
|
296
|
+
let session;
|
|
297
|
+
try {
|
|
298
|
+
session = await this.sessions.validate(token);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
throw new AuthSessionError("Could not validate the authentication session.", { cause: error });
|
|
301
|
+
}
|
|
302
|
+
if (!session || session.kind !== "authenticated" || !session.userId) throw new 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 AuthSessionError("The authenticated account is unavailable.");
|
|
305
|
+
if (user.sessionsInvalidatedAt && session.createdAt.getTime() <= user.sessionsInvalidatedAt.getTime()) throw new AuthSessionError("The authentication session is no longer valid.");
|
|
306
|
+
if (this.requireVerifiedEmail && !user.emailVerifiedAt) throw new 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 AuthTokenError();
|
|
314
|
+
const tokenHash = await 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 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 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 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 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 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 StorageConflictError) {
|
|
424
|
+
if (attempt < 4) continue;
|
|
425
|
+
throw new 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 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 StorageConflictError) throw new AuthTokenError();
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
const updated = await this.storage.findById(this.usersCollection, user.id);
|
|
478
|
+
if (!updated) throw new 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 = 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 AuthTokenError();
|
|
530
|
+
const credential = await this.findCredential(actionToken.userId);
|
|
531
|
+
if (!credential || credential.revokedAt !== null) throw new 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 StorageConflictError || error instanceof AuthAccountConflictError) throw new 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 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 = 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 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 AuthValidationError("The current password is incorrect.");
|
|
691
|
+
const newEmail = normalizeEmail(input.email);
|
|
692
|
+
if (newEmail === user.normalizedEmail) throw new 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 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 AuthTokenError();
|
|
729
|
+
const user = await this.storage.findById(this.usersCollection, actionToken.userId);
|
|
730
|
+
if (!user) throw new AuthTokenError();
|
|
731
|
+
const newEmail = 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 AuthAccountConflictError) throw error;
|
|
763
|
+
if (error instanceof StorageConflictError) throw new 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 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 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 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
|
+
export { StorageAuthLifecycleManager };
|
|
834
|
+
|
|
835
|
+
//# sourceMappingURL=auth.lifecycle.manager.js.map
|