@prsm/auth 1.0.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 (61) hide show
  1. package/README.md +226 -0
  2. package/index.d.ts +19 -0
  3. package/package.json +76 -0
  4. package/src/__tests__/auth.test.js +1171 -0
  5. package/src/__tests__/impersonation-test-setup.js +208 -0
  6. package/src/__tests__/impersonation.test.js +473 -0
  7. package/src/__tests__/oauth-test-setup.js +136 -0
  8. package/src/__tests__/oauth.test.js +400 -0
  9. package/src/__tests__/prsm.test.js +215 -0
  10. package/src/__tests__/test-setup.js +385 -0
  11. package/src/__tests__/totp.test.js +158 -0
  12. package/src/__tests__/two-factor-test-setup.js +331 -0
  13. package/src/__tests__/two-factor.test.js +396 -0
  14. package/src/activity-logger.js +228 -0
  15. package/src/auth-context.js +120 -0
  16. package/src/auth-functions.js +520 -0
  17. package/src/auth-manager.js +1371 -0
  18. package/src/errors.js +173 -0
  19. package/src/hooks.js +41 -0
  20. package/src/index.js +23 -0
  21. package/src/invalidation.js +166 -0
  22. package/src/middleware.js +33 -0
  23. package/src/providers/azure-provider.js +114 -0
  24. package/src/providers/base-provider.js +152 -0
  25. package/src/providers/github-provider.js +86 -0
  26. package/src/providers/google-provider.js +76 -0
  27. package/src/providers/index.js +4 -0
  28. package/src/queries.js +543 -0
  29. package/src/schema.js +261 -0
  30. package/src/totp.js +221 -0
  31. package/src/two-factor/index.js +3 -0
  32. package/src/two-factor/otp-provider.js +128 -0
  33. package/src/two-factor/totp-provider.js +98 -0
  34. package/src/two-factor/two-factor-manager.js +676 -0
  35. package/src/types.js +399 -0
  36. package/src/user-roles.js +128 -0
  37. package/src/util.js +32 -0
  38. package/types/activity-logger.d.ts +73 -0
  39. package/types/auth-context.d.ts +88 -0
  40. package/types/auth-functions.d.ts +151 -0
  41. package/types/auth-manager.d.ts +365 -0
  42. package/types/errors.d.ts +108 -0
  43. package/types/hooks.d.ts +30 -0
  44. package/types/index.d.ts +13 -0
  45. package/types/invalidation.d.ts +40 -0
  46. package/types/middleware.d.ts +11 -0
  47. package/types/providers/azure-provider.d.ts +35 -0
  48. package/types/providers/base-provider.d.ts +52 -0
  49. package/types/providers/github-provider.d.ts +29 -0
  50. package/types/providers/google-provider.d.ts +29 -0
  51. package/types/providers/index.d.ts +4 -0
  52. package/types/queries.d.ts +287 -0
  53. package/types/schema.d.ts +37 -0
  54. package/types/totp.d.ts +72 -0
  55. package/types/two-factor/index.d.ts +3 -0
  56. package/types/two-factor/otp-provider.d.ts +57 -0
  57. package/types/two-factor/totp-provider.d.ts +58 -0
  58. package/types/two-factor/two-factor-manager.d.ts +191 -0
  59. package/types/types.d.ts +688 -0
  60. package/types/user-roles.d.ts +47 -0
  61. package/types/util.d.ts +3 -0
@@ -0,0 +1,1371 @@
1
+ import hash from "@prsm/hash"
2
+ import ms from "@prsm/ms"
3
+ import { AuthStatus, AuthRole, AuthActivityAction } from "./types.js"
4
+ import { AuthQueries } from "./queries.js"
5
+ import { ActivityLogger } from "./activity-logger.js"
6
+ import { wasInvalidatedSince } from "./invalidation.js"
7
+ import { validateEmail, createMapFromEnum } from "./util.js"
8
+ import {
9
+ ConfirmationExpiredError,
10
+ ConfirmationNotFoundError,
11
+ EmailNotVerifiedError,
12
+ EmailTakenError,
13
+ InvalidPasswordError,
14
+ InvalidTokenError,
15
+ ResetDisabledError,
16
+ ResetExpiredError,
17
+ ResetNotFoundError,
18
+ TooManyResetsError,
19
+ UserInactiveError,
20
+ UserNotFoundError,
21
+ UserNotLoggedInError,
22
+ SecondFactorRequiredError,
23
+ TwoFactorExpiredError,
24
+ ImpersonationDisabledError,
25
+ ImpersonationNotAllowedError,
26
+ AlreadyImpersonatingError,
27
+ NotImpersonatingError,
28
+ RateLimitedError,
29
+ } from "./errors.js"
30
+ import { GitHubProvider, GoogleProvider, AzureProvider } from "./providers/index.js"
31
+ import { TwoFactorManager } from "./two-factor/index.js"
32
+ import * as authFunctions from "./auth-functions.js"
33
+ import { withSpan, consumeLimit } from "./hooks.js"
34
+
35
+ /**
36
+ * @typedef {import("./types.js").AuthConfig} AuthConfig
37
+ * @typedef {import("./types.js").AuthAccount} AuthAccount
38
+ * @typedef {import("./types.js").AuthSession} AuthSession
39
+ * @typedef {import("./types.js").TokenCallback} TokenCallback
40
+ * @typedef {import("./types.js").OAuthProvider} OAuthProvider
41
+ * @typedef {import("./types.js").StartImpersonationOptions} StartImpersonationOptions
42
+ * @typedef {import("./types.js").ImpersonationInfo} ImpersonationInfo
43
+ * @typedef {import("./types.js").ImpersonationActor} ImpersonationActor
44
+ * @typedef {import("./types.js").UserIdentifier} UserIdentifier
45
+ */
46
+
47
+ export class AuthManager {
48
+ /**
49
+ * @param {import("express").Request} req
50
+ * @param {import("express").Response} res
51
+ * @param {AuthConfig} config
52
+ */
53
+ constructor(req, res, config) {
54
+ this.req = req
55
+ this.res = res
56
+ this.config = config
57
+ this.queries = new AuthQueries(config)
58
+ this.activityLogger = new ActivityLogger(config)
59
+ this.providers = this.initializeProviders()
60
+ this.twoFactor = new TwoFactorManager(req, res, config)
61
+ }
62
+
63
+ initializeProviders() {
64
+ const providers = {}
65
+
66
+ if (this.config.providers?.github) {
67
+ providers.github = new GitHubProvider(this.config.providers.github, this.config, this)
68
+ }
69
+
70
+ if (this.config.providers?.google) {
71
+ providers.google = new GoogleProvider(this.config.providers.google, this.config, this)
72
+ }
73
+
74
+ if (this.config.providers?.azure) {
75
+ providers.azure = new AzureProvider(this.config.providers.azure, this.config, this)
76
+ }
77
+
78
+ return providers
79
+ }
80
+
81
+ generateAutoUserId() {
82
+ return crypto.randomUUID()
83
+ }
84
+
85
+ async shouldRequire2FA(account) {
86
+ // skip 2FA for OAuth users unless explicitly configured
87
+ const providers = await this.queries.findProvidersByAccountId(account.id)
88
+ const hasOAuthProviders = providers.length > 0
89
+
90
+ if (hasOAuthProviders && !this.config.twoFactor?.requireForOAuth) {
91
+ return false
92
+ }
93
+ return true
94
+ }
95
+
96
+ validatePassword(password) {
97
+ const minLength = this.config.minPasswordLength || 8
98
+ const maxLength = this.config.maxPasswordLength || 64
99
+
100
+ if (typeof password !== "string") {
101
+ throw new InvalidPasswordError()
102
+ }
103
+
104
+ if (password.length < minLength) {
105
+ throw new InvalidPasswordError()
106
+ }
107
+
108
+ if (password.length > maxLength) {
109
+ throw new InvalidPasswordError()
110
+ }
111
+ }
112
+
113
+ getRoleMap() {
114
+ return createMapFromEnum(this.config.roles || AuthRole)
115
+ }
116
+
117
+ getStatusMap() {
118
+ return createMapFromEnum(AuthStatus)
119
+ }
120
+
121
+ async getAuthAccount() {
122
+ if (!this.req.session?.auth?.accountId) {
123
+ return null
124
+ }
125
+
126
+ return await this.queries.findAccountById(this.req.session.auth.accountId)
127
+ }
128
+
129
+ setRememberCookie(token, expires) {
130
+ const cookieName = this.config.rememberCookieName || "remember_token"
131
+ const cookieConfig = this.config.cookie || {}
132
+
133
+ if (token === null) {
134
+ this.res.clearCookie(cookieName, {
135
+ domain: cookieConfig.domain,
136
+ secure: cookieConfig.secure ?? this.req.secure,
137
+ sameSite: cookieConfig.sameSite,
138
+ })
139
+ } else {
140
+ this.res.cookie(cookieName, token, {
141
+ expires,
142
+ httpOnly: true,
143
+ secure: cookieConfig.secure ?? this.req.secure,
144
+ domain: cookieConfig.domain,
145
+ sameSite: cookieConfig.sameSite,
146
+ })
147
+ }
148
+ }
149
+
150
+ getRememberToken() {
151
+ const { cookies } = this.req
152
+ if (!cookies) {
153
+ return { token: null }
154
+ }
155
+
156
+ const cookieName = this.config.rememberCookieName || "remember_token"
157
+ const token = cookies[cookieName]
158
+
159
+ return { token: token || null }
160
+ }
161
+
162
+ async regenerateSession() {
163
+ const { auth } = this.req.session
164
+
165
+ return new Promise((resolve, reject) => {
166
+ this.req.session.regenerate((err) => {
167
+ if (err) {
168
+ reject(err)
169
+ return
170
+ }
171
+ this.req.session.auth = auth
172
+ resolve()
173
+ })
174
+ })
175
+ }
176
+
177
+ /**
178
+ * Resync the current session against the persisted account state.
179
+ * @param {boolean} [force=false]
180
+ * @returns {Promise<void>}
181
+ */
182
+ async resyncSession(force = false) {
183
+ if (!this.isLoggedIn()) {
184
+ return
185
+ }
186
+
187
+ if (this.req.session.auth.shouldForceLogout) {
188
+ await this.logout()
189
+ return
190
+ }
191
+
192
+ const interval = ms(this.config.resyncInterval || "30s")
193
+ const lastResync = new Date(this.req.session.auth.lastResync)
194
+
195
+ if (!force && lastResync && lastResync.getTime() > Date.now() - interval) {
196
+ // honor the interval unless another instance signaled this account invalid
197
+ // more recently than our last resync (cross-instance LISTEN/NOTIFY)
198
+ if (!wasInvalidatedSince(this.config, this.req.session.auth.accountId, lastResync.getTime())) {
199
+ return
200
+ }
201
+ }
202
+
203
+ if (this.isImpersonating()) {
204
+ const actorSnapshot = this.req.session.auth.actor
205
+
206
+ // expiry first - revert to actor before any other action
207
+ if (actorSnapshot.expiresAt && new Date() >= new Date(actorSnapshot.expiresAt)) {
208
+ await this.stopImpersonationInternal("expired")
209
+ return
210
+ }
211
+
212
+ const actorAccount = await this.queries.findAccountById(actorSnapshot.accountId)
213
+ if (!actorAccount || actorAccount.status !== AuthStatus.Normal || actorAccount.force_logout > actorSnapshot.forceLogout) {
214
+ // actor is no longer valid - kill the whole session.
215
+ // clear actor first so the Logout activity row doesn't reference a deleted/invalid actor via FK
216
+ this.req.session.auth.actor = undefined
217
+ await this.logout()
218
+ return
219
+ }
220
+
221
+ // target may have been modified by the admin while impersonating; refresh effective fields
222
+ const target = await this.queries.findAccountById(this.req.session.auth.accountId)
223
+ if (!target) {
224
+ // target deleted mid-impersonation - revert to actor
225
+ await this.stopImpersonationInternal("target_gone")
226
+ return
227
+ }
228
+
229
+ this.req.session.auth.email = target.email
230
+ this.req.session.auth.status = target.status
231
+ this.req.session.auth.rolemask = target.rolemask
232
+ this.req.session.auth.verified = target.verified
233
+ this.req.session.auth.hasPassword = target.password !== null
234
+ this.req.session.auth.lastResync = new Date()
235
+ return
236
+ }
237
+
238
+ const account = await this.getAuthAccount()
239
+
240
+ if (!account) {
241
+ await this.logout()
242
+ return
243
+ }
244
+
245
+ if (account.force_logout > this.req.session.auth.forceLogout) {
246
+ await this.logout()
247
+ return
248
+ }
249
+
250
+ this.req.session.auth.shouldForceLogout = false
251
+ this.req.session.auth.email = account.email
252
+ this.req.session.auth.status = account.status
253
+ this.req.session.auth.rolemask = account.rolemask
254
+ this.req.session.auth.verified = account.verified
255
+ this.req.session.auth.hasPassword = account.password !== null
256
+ this.req.session.auth.lastResync = new Date()
257
+ }
258
+
259
+ /**
260
+ * Restore a session from a valid remember token when not already logged in.
261
+ * @returns {Promise<void>}
262
+ */
263
+ async processRememberDirective() {
264
+ if (this.isLoggedIn()) {
265
+ return
266
+ }
267
+
268
+ const { token } = this.getRememberToken()
269
+ if (!token) {
270
+ return
271
+ }
272
+
273
+ const remember = await this.queries.findRememberToken(token)
274
+ if (!remember) {
275
+ this.setRememberCookie(null, new Date(0))
276
+ return
277
+ }
278
+
279
+ // expired?
280
+ if (new Date() > remember.expires) {
281
+ await this.queries.deleteRememberToken(token)
282
+ this.setRememberCookie(null, new Date(0))
283
+ return
284
+ }
285
+
286
+ // clean up expired tokens for this account
287
+ await this.queries.deleteExpiredRememberTokensForAccount(remember.account_id)
288
+
289
+ // get the account and log in
290
+ const account = await this.queries.findAccountById(remember.account_id)
291
+ if (!account) {
292
+ await this.queries.deleteRememberToken(token)
293
+ this.setRememberCookie(null, new Date(0))
294
+ return
295
+ }
296
+
297
+ // pass false to avoid creating a new remember token - we're restoring from an existing one
298
+ await this.onLoginSuccessful(account, false)
299
+ }
300
+
301
+ async onLoginSuccessful(account, remember = false) {
302
+ await this.queries.updateAccountLastLogin(account.id)
303
+
304
+ return new Promise((resolve, reject) => {
305
+ if (!this.req.session?.regenerate) {
306
+ resolve()
307
+ return
308
+ }
309
+
310
+ this.req.session.regenerate(async (err) => {
311
+ if (err) {
312
+ reject(err)
313
+ return
314
+ }
315
+
316
+ const session = {
317
+ loggedIn: true,
318
+ accountId: account.id,
319
+ userId: account.user_id,
320
+ email: account.email,
321
+ status: account.status,
322
+ rolemask: account.rolemask,
323
+ remembered: remember,
324
+ lastResync: new Date(),
325
+ lastRememberCheck: new Date(),
326
+ forceLogout: account.force_logout,
327
+ verified: account.verified,
328
+ hasPassword: account.password !== null,
329
+ shouldForceLogout: false,
330
+ }
331
+
332
+ this.req.session.auth = session
333
+
334
+ if (remember) {
335
+ await this.createRememberDirective(account)
336
+ }
337
+
338
+ this.req.session.save((err) => {
339
+ if (err) {
340
+ reject(err)
341
+ return
342
+ }
343
+ resolve()
344
+ })
345
+ })
346
+ })
347
+ }
348
+
349
+ async createRememberDirective(account) {
350
+ const token = await hash.encode(account.email)
351
+ const duration = this.config.rememberDuration || "30d"
352
+ const expires = new Date(Date.now() + ms(duration))
353
+
354
+ await this.queries.createRememberToken({
355
+ accountId: account.id,
356
+ token,
357
+ expires,
358
+ })
359
+
360
+ this.setRememberCookie(token, expires)
361
+
362
+ await this.activityLogger.logActivity(account.id, AuthActivityAction.RememberTokenCreated, this.req, true, { email: account.email, duration })
363
+
364
+ return token
365
+ }
366
+
367
+ /**
368
+ * Check if the current user is logged in.
369
+ * @returns {boolean} true if user has an active authenticated session
370
+ */
371
+ isLoggedIn() {
372
+ return this.req.session?.auth?.loggedIn ?? false
373
+ }
374
+
375
+ /**
376
+ * Authenticate user with email and password, creating a session.
377
+ * @param {string} email
378
+ * @param {string} password
379
+ * @param {boolean} [remember=false]
380
+ * @returns {Promise<void>}
381
+ * @throws {UserNotFoundError} Account with this email doesn't exist
382
+ * @throws {InvalidPasswordError} Password is incorrect
383
+ * @throws {EmailNotVerifiedError} Account exists but email is not verified
384
+ * @throws {UserInactiveError} Account is banned, locked, or otherwise inactive
385
+ * @throws {SecondFactorRequiredError} Two-factor authentication is required
386
+ */
387
+ async login(email, password, remember = false) {
388
+ return withSpan(this.config.tracer, "auth.login", { email }, async () => {
389
+ // optional brute-force throttle, keyed by email; only active when a limiter
390
+ // is configured. behavior is unchanged when absent
391
+ if (this.config.limiter) {
392
+ const result = await consumeLimit(this.config.limiter, `login:${email}`)
393
+ if (result && result.allowed === false) {
394
+ await this.activityLogger.logActivity(null, AuthActivityAction.FailedLogin, this.req, false, { email, reason: "rate_limited" })
395
+ throw new RateLimitedError(result.retryAfter)
396
+ }
397
+ }
398
+
399
+ const account = await this.queries.findAccountByEmail(email)
400
+
401
+ if (!account) {
402
+ await this.activityLogger.logActivity(null, AuthActivityAction.FailedLogin, this.req, false, { email, reason: "account_not_found" })
403
+ throw new UserNotFoundError()
404
+ }
405
+
406
+ if (!account.password || !(await hash.verify(account.password, password))) {
407
+ await this.activityLogger.logActivity(account.id, AuthActivityAction.FailedLogin, this.req, false, { email, reason: "invalid_password" })
408
+ throw new InvalidPasswordError()
409
+ }
410
+
411
+ if (!account.verified) {
412
+ await this.activityLogger.logActivity(account.id, AuthActivityAction.FailedLogin, this.req, false, { email, reason: "email_not_verified" })
413
+ throw new EmailNotVerifiedError()
414
+ }
415
+
416
+ if (account.status !== AuthStatus.Normal) {
417
+ await this.activityLogger.logActivity(account.id, AuthActivityAction.FailedLogin, this.req, false, { email, reason: "account_inactive", status: account.status })
418
+ throw new UserInactiveError()
419
+ }
420
+
421
+ // check if 2FA is enabled and required for this user
422
+ if (this.config.twoFactor?.enabled && (await this.shouldRequire2FA(account))) {
423
+ const twoFactorMethods = await this.queries.findTwoFactorMethodsByAccountId(account.id)
424
+ const enabledMethods = twoFactorMethods.filter((method) => method.verified)
425
+
426
+ if (enabledMethods.length > 0) {
427
+ // create 2FA challenge
428
+ const challenge = await this.twoFactor.createChallenge(account.id)
429
+
430
+ // set 2FA session state (user NOT logged in yet)
431
+ const expiryDuration = this.config.twoFactor?.tokenExpiry || "5m"
432
+ const expiresAt = new Date(Date.now() + ms(expiryDuration))
433
+
434
+ this.req.session.auth = {
435
+ loggedIn: false,
436
+ accountId: 0,
437
+ userId: "",
438
+ email: "",
439
+ status: 0,
440
+ rolemask: 0,
441
+ remembered: false,
442
+ lastResync: new Date(),
443
+ lastRememberCheck: new Date(),
444
+ forceLogout: 0,
445
+ verified: false,
446
+ hasPassword: false,
447
+ awaitingTwoFactor: {
448
+ accountId: account.id,
449
+ expiresAt,
450
+ remember,
451
+ availableMechanisms: enabledMethods.map((m) => m.mechanism),
452
+ attemptedMechanisms: [],
453
+ originalEmail: account.email,
454
+ selectors: challenge.selectors,
455
+ },
456
+ }
457
+
458
+ await this.activityLogger.logActivity(account.id, AuthActivityAction.TwoFactorFailed, this.req, true, { prompt: true, mechanisms: enabledMethods.map((m) => m.mechanism) })
459
+
460
+ throw new SecondFactorRequiredError(challenge)
461
+ }
462
+ }
463
+
464
+ await this.onLoginSuccessful(account, remember)
465
+ await this.activityLogger.logActivity(account.id, AuthActivityAction.Login, this.req, true, { email, remember })
466
+ })
467
+ }
468
+
469
+ /**
470
+ * Complete two-factor authentication and log in the user.
471
+ * @returns {Promise<void>}
472
+ * @throws {TwoFactorExpiredError} No pending 2FA state or it has expired
473
+ * @throws {UserNotFoundError} Associated account no longer exists
474
+ */
475
+ async completeTwoFactorLogin() {
476
+ const twoFactorState = this.req.session?.auth?.awaitingTwoFactor
477
+
478
+ if (!twoFactorState) {
479
+ throw new TwoFactorExpiredError()
480
+ }
481
+
482
+ // check if the 2FA session has expired
483
+ if (twoFactorState.expiresAt <= new Date()) {
484
+ // clear expired 2FA state
485
+ delete this.req.session.auth.awaitingTwoFactor
486
+ throw new TwoFactorExpiredError()
487
+ }
488
+
489
+ // get the account that was awaiting 2FA
490
+ const account = await this.queries.findAccountById(twoFactorState.accountId)
491
+ if (!account) {
492
+ delete this.req.session.auth.awaitingTwoFactor
493
+ throw new UserNotFoundError()
494
+ }
495
+
496
+ // complete the login process
497
+ await this.onLoginSuccessful(account, twoFactorState.remember)
498
+
499
+ // clear the 2FA state
500
+ delete this.req.session.auth.awaitingTwoFactor
501
+
502
+ await this.activityLogger.logActivity(account.id, AuthActivityAction.Login, this.req, true, { email: account.email, remember: twoFactorState.remember, twoFactorCompleted: true })
503
+ }
504
+
505
+ /**
506
+ * Log out the current user, clearing the session and remember tokens.
507
+ * @returns {Promise<void>}
508
+ */
509
+ async logout() {
510
+ if (!this.isLoggedIn()) {
511
+ return
512
+ }
513
+
514
+ const accountId = this.getId()
515
+ const email = this.getEmail()
516
+ const { token } = this.getRememberToken()
517
+
518
+ if (token) {
519
+ await this.queries.deleteRememberToken(token)
520
+ this.setRememberCookie(null, new Date(0))
521
+ }
522
+
523
+ // log BEFORE clearing the session so actor_account_id auto-pickup catches
524
+ // impersonation context (e.g. forced logout while impersonating)
525
+ if (accountId && email) {
526
+ await this.activityLogger.logActivity(accountId, AuthActivityAction.Logout, this.req, true, { email })
527
+ }
528
+
529
+ this.req.session.auth = undefined
530
+ }
531
+
532
+ /**
533
+ * Register a new account.
534
+ * @param {string} email
535
+ * @param {string} password
536
+ * @param {string|number} [userId] Optional user ID to link; a UUID is generated if omitted
537
+ * @param {TokenCallback} [callback] If provided, account is created unverified and callback receives the confirmation token
538
+ * @returns {Promise<AuthAccount>} The created account record
539
+ * @throws {EmailTakenError} Email is already registered
540
+ * @throws {InvalidPasswordError} Password doesn't meet length requirements
541
+ */
542
+ async register(email, password, userId, callback) {
543
+ validateEmail(email)
544
+ this.validatePassword(password)
545
+
546
+ const existing = await this.queries.findAccountByEmail(email)
547
+ if (existing) {
548
+ throw new EmailTakenError()
549
+ }
550
+
551
+ const finalUserId = userId || this.generateAutoUserId()
552
+
553
+ const hashedPassword = await hash.encode(password)
554
+ const verified = typeof callback !== "function"
555
+
556
+ const account = await this.queries.createAccount({
557
+ userId: finalUserId,
558
+ email,
559
+ password: hashedPassword,
560
+ verified,
561
+ status: AuthStatus.Normal,
562
+ rolemask: 0,
563
+ })
564
+
565
+ if (!verified && callback) {
566
+ await this.createConfirmationToken(account, email, callback)
567
+ }
568
+
569
+ await this.activityLogger.logActivity(account.id, AuthActivityAction.Register, this.req, true, { email, verified, userId: finalUserId })
570
+
571
+ return account
572
+ }
573
+
574
+ async createConfirmationToken(account, email, callback) {
575
+ const token = await hash.encode(email)
576
+ const expires = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7) // 1 week
577
+
578
+ await this.queries.createConfirmation({
579
+ accountId: account.id,
580
+ token,
581
+ email,
582
+ expires,
583
+ })
584
+
585
+ if (callback) {
586
+ callback(token)
587
+ }
588
+ }
589
+
590
+ /**
591
+ * Get the current user's account ID.
592
+ * @returns {number|null} Account ID if logged in, null otherwise
593
+ */
594
+ getId() {
595
+ return this.req.session?.auth?.accountId || null
596
+ }
597
+
598
+ /**
599
+ * Get the current user's email address.
600
+ * @returns {string|null} Email if logged in, null otherwise
601
+ */
602
+ getEmail() {
603
+ return this.req.session?.auth?.email || null
604
+ }
605
+
606
+ /**
607
+ * Get the current user's account status.
608
+ * @returns {number|null} Status number if logged in, null otherwise
609
+ */
610
+ getStatus() {
611
+ return this.req.session?.auth?.status ?? null
612
+ }
613
+
614
+ /**
615
+ * Check if the current user's email is verified.
616
+ * @returns {boolean|null} true if verified, false if unverified, null if not logged in
617
+ */
618
+ getVerified() {
619
+ return this.req.session?.auth?.verified ?? null
620
+ }
621
+
622
+ /**
623
+ * Check if the current user has a password set.
624
+ * @returns {boolean|null} true if user has a password, false if OAuth-only, null if not logged in
625
+ */
626
+ hasPassword() {
627
+ return this.req.session?.auth?.hasPassword ?? null
628
+ }
629
+
630
+ /**
631
+ * Get human-readable role names for the current user or a specific rolemask.
632
+ * @param {number} [rolemask] Optional rolemask; defaults to the current user's roles
633
+ * @returns {string[]} Array of role names
634
+ */
635
+ getRoleNames(rolemask) {
636
+ const mask = rolemask !== undefined ? rolemask : (this.req.session?.auth?.rolemask ?? 0)
637
+
638
+ if (!mask && mask !== 0) {
639
+ return []
640
+ }
641
+
642
+ return Object.entries(this.getRoleMap())
643
+ .filter(([key]) => mask & parseInt(key))
644
+ .map(([, value]) => value)
645
+ }
646
+
647
+ /**
648
+ * Get the human-readable status name for the current user.
649
+ * @returns {string|null} Status name if logged in, null otherwise
650
+ */
651
+ getStatusName() {
652
+ const status = this.getStatus()
653
+ if (status === null) return null
654
+ return this.getStatusMap()[status] || null
655
+ }
656
+
657
+ /**
658
+ * Check if the current user has a specific role.
659
+ * @param {number} role Role bitmask to check
660
+ * @returns {Promise<boolean>} true if user has the role, false otherwise
661
+ */
662
+ async hasRole(role) {
663
+ if (this.req.session?.auth) {
664
+ return (this.req.session.auth.rolemask & role) === role
665
+ }
666
+
667
+ const account = await this.getAuthAccount()
668
+ return account ? (account.rolemask & role) === role : false
669
+ }
670
+
671
+ /**
672
+ * Check if the current user has admin privileges.
673
+ * @returns {Promise<boolean>} true if user has Admin role, false otherwise
674
+ */
675
+ async isAdmin() {
676
+ return this.hasRole(AuthRole.Admin)
677
+ }
678
+
679
+ /**
680
+ * Check if the current user was automatically logged in via remember token.
681
+ * @returns {boolean} true if auto-logged in from a persistent cookie, false otherwise
682
+ */
683
+ isRemembered() {
684
+ return this.req.session?.auth?.remembered ?? false
685
+ }
686
+
687
+ /**
688
+ * Request an email change for the current user, sending a confirmation token.
689
+ * @param {string} newEmail
690
+ * @param {TokenCallback} callback Called with the confirmation token
691
+ * @returns {Promise<void>}
692
+ * @throws {UserNotLoggedInError} User is not logged in
693
+ * @throws {EmailTakenError} New email is already registered
694
+ * @throws {UserNotFoundError} Current user account not found
695
+ * @throws {EmailNotVerifiedError} Current account's email is not verified
696
+ */
697
+ async changeEmail(newEmail, callback) {
698
+ if (!this.isLoggedIn()) {
699
+ throw new UserNotLoggedInError()
700
+ }
701
+
702
+ validateEmail(newEmail)
703
+
704
+ const existing = await this.queries.findAccountByEmail(newEmail)
705
+ if (existing) {
706
+ throw new EmailTakenError()
707
+ }
708
+
709
+ const account = await this.getAuthAccount()
710
+ if (!account) {
711
+ throw new UserNotFoundError()
712
+ }
713
+
714
+ if (!account.verified) {
715
+ throw new EmailNotVerifiedError()
716
+ }
717
+
718
+ await this.createConfirmationToken(account, newEmail, callback)
719
+ }
720
+
721
+ /**
722
+ * Confirm an email address using a token from registration or email change.
723
+ * @param {string} token
724
+ * @returns {Promise<string>} The confirmed email address
725
+ * @throws {ConfirmationNotFoundError} Token is invalid or doesn't exist
726
+ * @throws {ConfirmationExpiredError} Token has expired
727
+ * @throws {InvalidTokenError} Token format is invalid
728
+ */
729
+ async confirmEmail(token) {
730
+ const confirmation = await this.queries.findConfirmation(token)
731
+
732
+ if (!confirmation) {
733
+ throw new ConfirmationNotFoundError()
734
+ }
735
+
736
+ if (new Date(confirmation.expires) < new Date()) {
737
+ throw new ConfirmationExpiredError()
738
+ }
739
+
740
+ if (!(await hash.verify(token, confirmation.email))) {
741
+ throw new InvalidTokenError()
742
+ }
743
+
744
+ await this.queries.updateAccount(confirmation.account_id, {
745
+ verified: true,
746
+ email: confirmation.email,
747
+ })
748
+
749
+ if (this.isLoggedIn() && this.req.session?.auth?.accountId === confirmation.account_id) {
750
+ this.req.session.auth.verified = true
751
+ this.req.session.auth.email = confirmation.email
752
+ }
753
+
754
+ await this.queries.deleteConfirmation(token)
755
+
756
+ await this.activityLogger.logActivity(confirmation.account_id, AuthActivityAction.EmailConfirmed, this.req, true, { email: confirmation.email })
757
+
758
+ return confirmation.email
759
+ }
760
+
761
+ /**
762
+ * Confirm an email address and automatically log in the user.
763
+ * @param {string} token
764
+ * @param {boolean} [remember=false]
765
+ * @returns {Promise<void>}
766
+ * @throws {ConfirmationNotFoundError} Token is invalid or doesn't exist
767
+ * @throws {ConfirmationExpiredError} Token has expired
768
+ * @throws {InvalidTokenError} Token format is invalid
769
+ * @throws {UserNotFoundError} Associated account no longer exists
770
+ * @throws {SecondFactorRequiredError} Two-factor authentication is required
771
+ */
772
+ async confirmEmailAndLogin(token, remember = false) {
773
+ const email = await this.confirmEmail(token)
774
+
775
+ if (this.isLoggedIn()) {
776
+ return
777
+ }
778
+
779
+ const account = await this.queries.findAccountByEmail(email)
780
+ if (!account) {
781
+ throw new UserNotFoundError()
782
+ }
783
+
784
+ // check if 2FA is enabled and required for this user
785
+ if (this.config.twoFactor?.enabled && (await this.shouldRequire2FA(account))) {
786
+ const twoFactorMethods = await this.queries.findTwoFactorMethodsByAccountId(account.id)
787
+ const enabledMethods = twoFactorMethods.filter((method) => method.verified)
788
+
789
+ if (enabledMethods.length > 0) {
790
+ // create 2FA challenge
791
+ const challenge = await this.twoFactor.createChallenge(account.id)
792
+
793
+ // set 2FA session state (user NOT logged in yet)
794
+ const expiryDuration = this.config.twoFactor?.tokenExpiry || "5m"
795
+ const expiresAt = new Date(Date.now() + ms(expiryDuration))
796
+
797
+ this.req.session.auth = {
798
+ loggedIn: false,
799
+ accountId: 0,
800
+ userId: "",
801
+ email: "",
802
+ status: 0,
803
+ rolemask: 0,
804
+ remembered: false,
805
+ lastResync: new Date(),
806
+ lastRememberCheck: new Date(),
807
+ forceLogout: 0,
808
+ verified: false,
809
+ hasPassword: false,
810
+ awaitingTwoFactor: {
811
+ accountId: account.id,
812
+ expiresAt,
813
+ remember,
814
+ availableMechanisms: enabledMethods.map((m) => m.mechanism),
815
+ attemptedMechanisms: [],
816
+ originalEmail: account.email,
817
+ selectors: challenge.selectors,
818
+ },
819
+ }
820
+
821
+ await this.activityLogger.logActivity(account.id, AuthActivityAction.TwoFactorFailed, this.req, true, { prompt: true, mechanisms: enabledMethods.map((m) => m.mechanism) })
822
+
823
+ throw new SecondFactorRequiredError(challenge)
824
+ }
825
+ }
826
+
827
+ await this.onLoginSuccessful(account, remember)
828
+ }
829
+
830
+ /**
831
+ * Initiate a password reset for a user, creating a reset token.
832
+ * @param {string} email
833
+ * @param {string|number|null} [expiresAfter=null] Token expiration (default 6h)
834
+ * @param {number|null} [maxOpenRequests=null] Maximum concurrent reset tokens (default 2)
835
+ * @param {TokenCallback} [callback] Called with the reset token
836
+ * @returns {Promise<void>}
837
+ * @throws {EmailNotVerifiedError} Account doesn't exist or email not verified
838
+ * @throws {ResetDisabledError} Account has password reset disabled
839
+ * @throws {TooManyResetsError} Too many active reset requests
840
+ */
841
+ async resetPassword(email, expiresAfter = null, maxOpenRequests = null, callback) {
842
+ validateEmail(email)
843
+
844
+ const expiry = !expiresAfter ? ms("6h") : ms(expiresAfter)
845
+ const maxRequests = maxOpenRequests === null ? 2 : Math.max(1, maxOpenRequests)
846
+
847
+ const account = await this.queries.findAccountByEmail(email)
848
+
849
+ if (!account || !account.verified) {
850
+ throw new EmailNotVerifiedError()
851
+ }
852
+
853
+ if (!account.resettable) {
854
+ throw new ResetDisabledError()
855
+ }
856
+
857
+ const openRequests = await this.queries.countActiveResetTokensForAccount(account.id)
858
+
859
+ if (openRequests >= maxRequests) {
860
+ throw new TooManyResetsError()
861
+ }
862
+
863
+ const token = await hash.encode(email)
864
+ const expires = new Date(Date.now() + expiry)
865
+
866
+ await this.queries.createResetToken({
867
+ accountId: account.id,
868
+ token,
869
+ expires,
870
+ })
871
+
872
+ await this.activityLogger.logActivity(account.id, AuthActivityAction.PasswordResetRequested, this.req, true, { email })
873
+
874
+ if (callback) {
875
+ callback(token)
876
+ }
877
+ }
878
+
879
+ /**
880
+ * Complete a password reset using a reset token.
881
+ * @param {string} token
882
+ * @param {string} password New password (will be hashed)
883
+ * @param {boolean} [logout=true] Whether to force logout all sessions
884
+ * @returns {Promise<void>}
885
+ * @throws {ResetNotFoundError} Token is invalid or doesn't exist
886
+ * @throws {ResetExpiredError} Token has expired
887
+ * @throws {UserNotFoundError} Associated account no longer exists
888
+ * @throws {ResetDisabledError} Account has password reset disabled
889
+ * @throws {InvalidPasswordError} New password doesn't meet requirements
890
+ * @throws {InvalidTokenError} Token format is invalid
891
+ */
892
+ async confirmResetPassword(token, password, logout = true) {
893
+ const reset = await this.queries.findResetToken(token)
894
+
895
+ if (!reset) {
896
+ throw new ResetNotFoundError()
897
+ }
898
+
899
+ if (new Date(reset.expires) < new Date()) {
900
+ throw new ResetExpiredError()
901
+ }
902
+
903
+ const account = await this.queries.findAccountById(reset.account_id)
904
+ if (!account) {
905
+ throw new UserNotFoundError()
906
+ }
907
+
908
+ if (!account.resettable) {
909
+ throw new ResetDisabledError()
910
+ }
911
+
912
+ this.validatePassword(password)
913
+
914
+ if (!(await hash.verify(token, account.email))) {
915
+ throw new InvalidTokenError()
916
+ }
917
+
918
+ await this.queries.updateAccount(account.id, {
919
+ password: await hash.encode(password),
920
+ })
921
+
922
+ if (logout) {
923
+ await this.forceLogoutForAccountById(account.id)
924
+ }
925
+
926
+ await this.queries.deleteResetToken(token)
927
+
928
+ await this.activityLogger.logActivity(account.id, AuthActivityAction.PasswordResetCompleted, this.req, true, { email: account.email })
929
+ }
930
+
931
+ /**
932
+ * Verify if a password matches the current user's password.
933
+ * @param {string} password
934
+ * @returns {Promise<boolean>} true if password matches, false otherwise
935
+ * @throws {UserNotLoggedInError} User is not logged in
936
+ * @throws {UserNotFoundError} Current user account not found
937
+ */
938
+ async verifyPassword(password) {
939
+ if (!this.isLoggedIn()) {
940
+ throw new UserNotLoggedInError()
941
+ }
942
+
943
+ const account = await this.getAuthAccount()
944
+
945
+ if (!account) {
946
+ throw new UserNotFoundError()
947
+ }
948
+
949
+ if (!account.password) {
950
+ return false // OAuth users don't have passwords
951
+ }
952
+
953
+ return await hash.verify(account.password, password)
954
+ }
955
+
956
+ async forceLogoutForAccountById(accountId) {
957
+ await this.queries.deleteRememberTokensForAccount(accountId)
958
+ await this.queries.incrementForceLogout(accountId)
959
+ }
960
+
961
+ /**
962
+ * Force logout all other sessions while keeping the current one active.
963
+ * @returns {Promise<void>}
964
+ */
965
+ async logoutEverywhereElse() {
966
+ if (!this.isLoggedIn()) {
967
+ return
968
+ }
969
+
970
+ const accountId = this.getId()
971
+ if (!accountId) {
972
+ return
973
+ }
974
+
975
+ const account = await this.queries.findAccountById(accountId)
976
+ if (!account) {
977
+ await this.logout()
978
+ return
979
+ }
980
+
981
+ await this.forceLogoutForAccountById(accountId)
982
+
983
+ this.req.session.auth.forceLogout += 1
984
+
985
+ await this.regenerateSession()
986
+ }
987
+
988
+ /**
989
+ * Force logout all sessions including the current one.
990
+ * @returns {Promise<void>}
991
+ */
992
+ async logoutEverywhere() {
993
+ if (!this.isLoggedIn()) {
994
+ return
995
+ }
996
+
997
+ await this.logoutEverywhereElse()
998
+ await this.logout()
999
+ }
1000
+
1001
+ async findAccountByIdentifier(identifier) {
1002
+ if (identifier.accountId !== undefined) {
1003
+ return await this.queries.findAccountById(identifier.accountId)
1004
+ } else if (identifier.email !== undefined) {
1005
+ return await this.queries.findAccountByEmail(identifier.email)
1006
+ } else if (identifier.userId !== undefined) {
1007
+ return await this.queries.findAccountByUserId(identifier.userId)
1008
+ }
1009
+
1010
+ return null
1011
+ }
1012
+
1013
+ // admin/standalone functions (delegated to auth-functions.js due to lack of need for request context)
1014
+
1015
+ /**
1016
+ * Create a user account (admin function).
1017
+ * @param {{ email: string, password: string }} credentials
1018
+ * @param {string|number} [userId]
1019
+ * @param {TokenCallback} [callback]
1020
+ * @returns {Promise<AuthAccount>}
1021
+ */
1022
+ async createUser(credentials, userId, callback) {
1023
+ return authFunctions.createUser(this.config, credentials, userId, callback)
1024
+ }
1025
+
1026
+ /**
1027
+ * Delete a user account by identifier (admin function).
1028
+ * @param {UserIdentifier} identifier
1029
+ * @returns {Promise<void>}
1030
+ */
1031
+ async deleteUserBy(identifier) {
1032
+ return authFunctions.deleteUserBy(this.config, identifier)
1033
+ }
1034
+
1035
+ /**
1036
+ * Add a role for a user by identifier (admin function).
1037
+ * @param {UserIdentifier} identifier
1038
+ * @param {number} role
1039
+ * @returns {Promise<void>}
1040
+ */
1041
+ async addRoleForUserBy(identifier, role) {
1042
+ return authFunctions.addRoleForUserBy(this.config, identifier, role)
1043
+ }
1044
+
1045
+ /**
1046
+ * Remove a role for a user by identifier (admin function).
1047
+ * @param {UserIdentifier} identifier
1048
+ * @param {number} role
1049
+ * @returns {Promise<void>}
1050
+ */
1051
+ async removeRoleForUserBy(identifier, role) {
1052
+ return authFunctions.removeRoleForUserBy(this.config, identifier, role)
1053
+ }
1054
+
1055
+ /**
1056
+ * Check whether a user has a role by identifier (admin function).
1057
+ * @param {UserIdentifier} identifier
1058
+ * @param {number} role
1059
+ * @returns {Promise<boolean>}
1060
+ */
1061
+ async hasRoleForUserBy(identifier, role) {
1062
+ return authFunctions.hasRoleForUserBy(this.config, identifier, role)
1063
+ }
1064
+
1065
+ /**
1066
+ * Change a user's password by identifier (admin function).
1067
+ * @param {UserIdentifier} identifier
1068
+ * @param {string} password
1069
+ * @returns {Promise<void>}
1070
+ */
1071
+ async changePasswordForUserBy(identifier, password) {
1072
+ return authFunctions.changePasswordForUserBy(this.config, identifier, password)
1073
+ }
1074
+
1075
+ /**
1076
+ * Set a user's status by identifier (admin function).
1077
+ * @param {UserIdentifier} identifier
1078
+ * @param {number} status
1079
+ * @returns {Promise<void>}
1080
+ */
1081
+ async setStatusForUserBy(identifier, status) {
1082
+ return authFunctions.setStatusForUserBy(this.config, identifier, status)
1083
+ }
1084
+
1085
+ /**
1086
+ * Initiate a password reset for a user by identifier (admin function).
1087
+ * @param {UserIdentifier} identifier
1088
+ * @param {string|number|null} [expiresAfter]
1089
+ * @param {TokenCallback} [callback]
1090
+ * @returns {Promise<void>}
1091
+ */
1092
+ async initiatePasswordResetForUserBy(identifier, expiresAfter, callback) {
1093
+ return authFunctions.initiatePasswordResetForUserBy(this.config, identifier, expiresAfter, callback)
1094
+ }
1095
+
1096
+ /**
1097
+ * Check whether a user exists by email (admin function).
1098
+ * @param {string} email
1099
+ * @returns {Promise<boolean>}
1100
+ */
1101
+ async userExistsByEmail(email) {
1102
+ return authFunctions.userExistsByEmail(this.config, email)
1103
+ }
1104
+
1105
+ /**
1106
+ * Force logout a user by identifier; flags the current session if it is owned by that user.
1107
+ * @param {UserIdentifier} identifier
1108
+ * @returns {Promise<void>}
1109
+ */
1110
+ async forceLogoutForUserBy(identifier) {
1111
+ const result = await authFunctions.forceLogoutForUserBy(this.config, identifier)
1112
+
1113
+ // the question is "did the owner of this session get force-logged-out?"
1114
+ // when impersonating, the session is owned by the actor, not the effective (target) identity.
1115
+ // force-logging-out the target should NOT kick the admin who is impersonating them.
1116
+ const sessionOwnerId = this.isImpersonating() ? this.getActorId() : this.getId()
1117
+ if (sessionOwnerId === result.accountId) {
1118
+ this.req.session.auth.shouldForceLogout = true
1119
+ }
1120
+ }
1121
+
1122
+ /**
1123
+ * Log in as another user (admin function), replacing the current session.
1124
+ * @param {UserIdentifier} identifier
1125
+ * @returns {Promise<void>}
1126
+ * @throws {UserNotFoundError} No account matches the identifier
1127
+ */
1128
+ async loginAsUserBy(identifier) {
1129
+ const account = await this.findAccountByIdentifier(identifier)
1130
+
1131
+ if (!account) {
1132
+ throw new UserNotFoundError()
1133
+ }
1134
+
1135
+ await this.onLoginSuccessful(account, false)
1136
+ }
1137
+
1138
+ /**
1139
+ * Check whether the current session is impersonating another user.
1140
+ * @returns {boolean}
1141
+ */
1142
+ isImpersonating() {
1143
+ return !!this.req.session?.auth?.actor
1144
+ }
1145
+
1146
+ /**
1147
+ * Get the account id of the original (actor) user when impersonating.
1148
+ * @returns {number|null} Actor account id when impersonating, null otherwise
1149
+ */
1150
+ getActorId() {
1151
+ return this.req.session?.auth?.actor?.accountId ?? null
1152
+ }
1153
+
1154
+ /**
1155
+ * Get the email of the original (actor) user when impersonating.
1156
+ * @returns {string|null} Actor email when impersonating, null otherwise
1157
+ */
1158
+ getActorEmail() {
1159
+ return this.req.session?.auth?.actor?.email ?? null
1160
+ }
1161
+
1162
+ /**
1163
+ * Get a structured summary of the current impersonation session.
1164
+ * @returns {ImpersonationInfo|null} ImpersonationInfo when impersonating, null otherwise
1165
+ */
1166
+ getImpersonationInfo() {
1167
+ const auth = this.req.session?.auth
1168
+ if (!auth?.actor) return null
1169
+
1170
+ return {
1171
+ actor: {
1172
+ accountId: auth.actor.accountId,
1173
+ userId: auth.actor.userId,
1174
+ email: auth.actor.email,
1175
+ rolemask: auth.actor.rolemask,
1176
+ },
1177
+ target: {
1178
+ accountId: auth.accountId,
1179
+ userId: auth.userId,
1180
+ email: auth.email,
1181
+ rolemask: auth.rolemask,
1182
+ },
1183
+ startedAt: new Date(auth.actor.startedAt),
1184
+ expiresAt: auth.actor.expiresAt ? new Date(auth.actor.expiresAt) : undefined,
1185
+ reason: auth.actor.reason,
1186
+ }
1187
+ }
1188
+
1189
+ /**
1190
+ * Begin impersonating another user while preserving the actor identity.
1191
+ * @param {UserIdentifier} identifier
1192
+ * @param {StartImpersonationOptions} [options={}]
1193
+ * @returns {Promise<void>}
1194
+ * @throws {UserNotLoggedInError} No active session
1195
+ * @throws {ImpersonationDisabledError} config.impersonation.enabled is false
1196
+ * @throws {AlreadyImpersonatingError} Another impersonation session is already active
1197
+ * @throws {UserNotFoundError} No account matches the identifier
1198
+ * @throws {ImpersonationNotAllowedError} canImpersonate returned false, or target is the actor
1199
+ */
1200
+ async startImpersonation(identifier, options = {}) {
1201
+ if (!this.isLoggedIn()) {
1202
+ throw new UserNotLoggedInError()
1203
+ }
1204
+
1205
+ if (!this.config.impersonation?.enabled) {
1206
+ throw new ImpersonationDisabledError()
1207
+ }
1208
+
1209
+ if (this.isImpersonating()) {
1210
+ throw new AlreadyImpersonatingError()
1211
+ }
1212
+
1213
+ const actor = await this.getAuthAccount()
1214
+ if (!actor) {
1215
+ throw new UserNotFoundError()
1216
+ }
1217
+
1218
+ const target = await this.findAccountByIdentifier(identifier)
1219
+ if (!target) {
1220
+ throw new UserNotFoundError()
1221
+ }
1222
+
1223
+ if (target.id === actor.id) {
1224
+ await this.activityLogger.logActivity(actor.id, AuthActivityAction.ImpersonationRejected, this.req, false, { reason: "self_impersonation", targetAccountId: target.id })
1225
+ throw new ImpersonationNotAllowedError()
1226
+ }
1227
+
1228
+ // fail closed if the policy hook throws - never silently allow impersonation when the
1229
+ // policy can't be evaluated. preserves the underlying error message in audit metadata.
1230
+ let allowed = false
1231
+ try {
1232
+ allowed = (await this.config.impersonation.canImpersonate?.(actor, target)) ?? false
1233
+ } catch (err) {
1234
+ await this.activityLogger.logActivity(actor.id, AuthActivityAction.ImpersonationRejected, this.req, false, {
1235
+ reason: "policy_error",
1236
+ targetAccountId: target.id,
1237
+ policyError: err?.message ?? String(err),
1238
+ })
1239
+ throw new ImpersonationNotAllowedError()
1240
+ }
1241
+
1242
+ if (!allowed) {
1243
+ await this.activityLogger.logActivity(actor.id, AuthActivityAction.ImpersonationRejected, this.req, false, { reason: "denied", targetAccountId: target.id })
1244
+ throw new ImpersonationNotAllowedError()
1245
+ }
1246
+
1247
+ // resolve ttl: explicit option wins, then config default, capped by config.maxTtl
1248
+ const requestedMs = options.ttl !== undefined ? ms(options.ttl) : this.config.impersonation.defaultTtl ? ms(this.config.impersonation.defaultTtl) : null
1249
+ const maxMs = this.config.impersonation.maxTtl ? ms(this.config.impersonation.maxTtl) : null
1250
+ const effectiveMs = requestedMs !== null && maxMs !== null ? Math.min(requestedMs, maxMs) : (requestedMs ?? maxMs)
1251
+ const expiresAt = effectiveMs !== null ? new Date(Date.now() + effectiveMs) : undefined
1252
+
1253
+ const actorSnapshot = {
1254
+ accountId: actor.id,
1255
+ userId: actor.user_id,
1256
+ email: actor.email,
1257
+ rolemask: actor.rolemask,
1258
+ forceLogout: actor.force_logout,
1259
+ startedAt: new Date(),
1260
+ expiresAt,
1261
+ reason: options.reason,
1262
+ }
1263
+
1264
+ const newSession = {
1265
+ loggedIn: true,
1266
+ accountId: target.id,
1267
+ userId: target.user_id,
1268
+ email: target.email,
1269
+ status: target.status,
1270
+ rolemask: target.rolemask,
1271
+ remembered: false,
1272
+ lastResync: new Date(),
1273
+ lastRememberCheck: new Date(),
1274
+ forceLogout: target.force_logout,
1275
+ verified: target.verified,
1276
+ hasPassword: target.password !== null,
1277
+ shouldForceLogout: false,
1278
+ actor: actorSnapshot,
1279
+ }
1280
+
1281
+ await this.regenerateSessionWith(newSession)
1282
+
1283
+ // logged after session mutation so actor_account_id auto-pickup resolves to the admin
1284
+ await this.activityLogger.logActivity(target.id, AuthActivityAction.ImpersonationStarted, this.req, true, {
1285
+ targetAccountId: target.id,
1286
+ targetEmail: target.email,
1287
+ reason: options.reason,
1288
+ expiresAt: expiresAt?.toISOString(),
1289
+ })
1290
+ }
1291
+
1292
+ /**
1293
+ * Stop the current impersonation session and revert to the actor's identity.
1294
+ * @returns {Promise<void>}
1295
+ * @throws {NotImpersonatingError} No active impersonation session
1296
+ */
1297
+ async stopImpersonation() {
1298
+ if (!this.isImpersonating()) {
1299
+ throw new NotImpersonatingError()
1300
+ }
1301
+ await this.stopImpersonationInternal("manual")
1302
+ }
1303
+
1304
+ // internal stop that also handles auto-revert paths (expiry, target_gone) from resync
1305
+ async stopImpersonationInternal(cause) {
1306
+ const actor = this.req.session.auth.actor
1307
+ const targetAccountId = this.req.session.auth.accountId
1308
+ const targetEmail = this.req.session.auth.email
1309
+
1310
+ // log BEFORE clearing actor so the audit row carries actor_account_id.
1311
+ // when the target has been deleted the account_id FK would fail, so log against null in that case.
1312
+ const action = cause === "expired" ? AuthActivityAction.ImpersonationExpired : AuthActivityAction.ImpersonationStopped
1313
+ const logAccountId = cause === "target_gone" ? null : targetAccountId
1314
+ await this.activityLogger.logActivity(logAccountId, action, this.req, true, {
1315
+ targetAccountId,
1316
+ targetEmail,
1317
+ cause,
1318
+ startedAt: new Date(actor.startedAt).toISOString(),
1319
+ })
1320
+
1321
+ const actorAccount = await this.queries.findAccountById(actor.accountId)
1322
+ if (!actorAccount) {
1323
+ // actor account is gone - nothing to revert to
1324
+ this.req.session.auth = undefined
1325
+ return
1326
+ }
1327
+
1328
+ const newSession = {
1329
+ loggedIn: true,
1330
+ accountId: actorAccount.id,
1331
+ userId: actorAccount.user_id,
1332
+ email: actorAccount.email,
1333
+ status: actorAccount.status,
1334
+ rolemask: actorAccount.rolemask,
1335
+ remembered: false,
1336
+ lastResync: new Date(),
1337
+ lastRememberCheck: new Date(),
1338
+ forceLogout: actorAccount.force_logout,
1339
+ verified: actorAccount.verified,
1340
+ hasPassword: actorAccount.password !== null,
1341
+ shouldForceLogout: false,
1342
+ }
1343
+
1344
+ await this.regenerateSessionWith(newSession)
1345
+ }
1346
+
1347
+ async regenerateSessionWith(newAuth) {
1348
+ return new Promise((resolve, reject) => {
1349
+ if (!this.req.session?.regenerate) {
1350
+ this.req.session.auth = newAuth
1351
+ resolve()
1352
+ return
1353
+ }
1354
+
1355
+ this.req.session.regenerate((err) => {
1356
+ if (err) {
1357
+ reject(err)
1358
+ return
1359
+ }
1360
+ this.req.session.auth = newAuth
1361
+ this.req.session.save((saveErr) => {
1362
+ if (saveErr) {
1363
+ reject(saveErr)
1364
+ return
1365
+ }
1366
+ resolve()
1367
+ })
1368
+ })
1369
+ })
1370
+ }
1371
+ }