@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,396 @@
1
+ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"
2
+ import request from "supertest"
3
+ import Otp from "../totp.js"
4
+ import { createTwoFactorTestApp } from "./two-factor-test-setup.js"
5
+ import { TwoFactorMechanism } from "../index.js"
6
+
7
+ describe("Two-Factor Authentication Integration Tests", () => {
8
+ let app
9
+ let pool
10
+ let cleanup
11
+
12
+ beforeAll(async () => {
13
+ const testApp = await createTwoFactorTestApp()
14
+ app = testApp.app
15
+ pool = testApp.pool
16
+ cleanup = testApp.cleanup
17
+ })
18
+
19
+ afterAll(async () => {
20
+ await cleanup()
21
+ })
22
+
23
+ beforeEach(async () => {
24
+ await pool.query("DELETE FROM mfa_test_2fa_tokens")
25
+ await pool.query("DELETE FROM mfa_test_2fa_methods")
26
+ await pool.query("DELETE FROM mfa_test_resets")
27
+ await pool.query("DELETE FROM mfa_test_remembers")
28
+ await pool.query("DELETE FROM mfa_test_confirmations")
29
+ await pool.query("DELETE FROM mfa_test_activity_log")
30
+ await pool.query("DELETE FROM mfa_test_accounts")
31
+ })
32
+
33
+ async function registerAndLogin(agent, email) {
34
+ await agent.post("/set-user-session").send({ userId: `user-${email}` }).expect(200)
35
+ await agent.post("/register").send({ email, password: "password123" }).expect(200)
36
+ await agent.post("/login").send({ email, password: "password123" }).expect(200)
37
+ }
38
+
39
+ // ---- status queries ----
40
+
41
+ describe("2FA status queries", () => {
42
+ it("should report no 2FA initially", async () => {
43
+ const agent = request.agent(app)
44
+ await registerAndLogin(agent, "status@example.com")
45
+
46
+ const status = await agent.get("/2fa/is-enabled").expect(200)
47
+ expect(status.body.enabled).toBe(false)
48
+ expect(status.body.totp).toBe(false)
49
+ expect(status.body.email).toBe(false)
50
+ expect(status.body.sms).toBe(false)
51
+
52
+ const methods = await agent.get("/2fa/methods").expect(200)
53
+ expect(methods.body.methods).toEqual([])
54
+ })
55
+
56
+ it("should report enabled methods after setup", async () => {
57
+ const agent = request.agent(app)
58
+ await registerAndLogin(agent, "enabled@example.com")
59
+
60
+ await agent.post("/2fa/setup-totp").expect(200)
61
+ await agent.post("/2fa/setup-email").expect(200)
62
+
63
+ const status = await agent.get("/2fa/is-enabled").expect(200)
64
+ expect(status.body.enabled).toBe(true)
65
+ expect(status.body.totp).toBe(true)
66
+ expect(status.body.email).toBe(true)
67
+ expect(status.body.sms).toBe(false)
68
+
69
+ const methods = await agent.get("/2fa/methods").expect(200)
70
+ expect(methods.body.methods).toContain(TwoFactorMechanism.TOTP)
71
+ expect(methods.body.methods).toContain(TwoFactorMechanism.EMAIL)
72
+ })
73
+ })
74
+
75
+ // ---- basic setup ----
76
+
77
+ describe("Basic 2FA Setup", () => {
78
+ it("should setup TOTP without verification", async () => {
79
+ const agent = request.agent(app)
80
+ await registerAndLogin(agent, "totp@example.com")
81
+
82
+ const response = await agent.post("/2fa/setup-totp").expect(200)
83
+
84
+ expect(response.body.secret).toBeDefined()
85
+ expect(response.body.qrCode).toBeDefined()
86
+ expect(Array.isArray(response.body.backupCodes)).toBe(true)
87
+ expect(response.body.backupCodes).toHaveLength(10)
88
+
89
+ const db = await pool.query("SELECT * FROM mfa_test_2fa_methods WHERE mechanism = 1")
90
+ expect(db.rows).toHaveLength(1)
91
+ expect(db.rows[0].verified).toBe(true)
92
+ })
93
+
94
+ it("should setup TOTP with verification required", async () => {
95
+ const agent = request.agent(app)
96
+ await registerAndLogin(agent, "totp-verify@example.com")
97
+
98
+ const response = await agent.post("/2fa/setup-totp").send({ requireVerification: true }).expect(200)
99
+
100
+ expect(response.body.secret).toBeDefined()
101
+ expect(response.body.backupCodes).toBeUndefined()
102
+
103
+ const db = await pool.query("SELECT * FROM mfa_test_2fa_methods WHERE mechanism = 1")
104
+ expect(db.rows[0].verified).toBe(false)
105
+ })
106
+
107
+ it("should complete TOTP setup with valid code", async () => {
108
+ const agent = request.agent(app)
109
+ await registerAndLogin(agent, "totp-complete@example.com")
110
+
111
+ const setup = await agent.post("/2fa/setup-totp").send({ requireVerification: true }).expect(200)
112
+
113
+ const validCode = Otp.generateTotp(setup.body.secret)
114
+
115
+ const complete = await agent.post("/2fa/verify-totp-setup").send({ code: validCode }).expect(200)
116
+
117
+ expect(complete.body.backupCodes).toBeDefined()
118
+ expect(complete.body.backupCodes).toHaveLength(10)
119
+
120
+ const db = await pool.query("SELECT * FROM mfa_test_2fa_methods WHERE mechanism = 1")
121
+ expect(db.rows[0].verified).toBe(true)
122
+ })
123
+
124
+ it("should reject TOTP completion with invalid code", async () => {
125
+ const agent = request.agent(app)
126
+ await registerAndLogin(agent, "totp-invalid@example.com")
127
+
128
+ await agent.post("/2fa/setup-totp").send({ requireVerification: true }).expect(200)
129
+
130
+ const response = await agent.post("/2fa/verify-totp-setup").send({ code: "000000" })
131
+ expect(response.status).toBe(400)
132
+ expect(response.body.error).toContain("Invalid two-factor")
133
+ })
134
+
135
+ it("should reject duplicate TOTP setup", async () => {
136
+ const agent = request.agent(app)
137
+ await registerAndLogin(agent, "totp-dup@example.com")
138
+
139
+ await agent.post("/2fa/setup-totp").expect(200)
140
+
141
+ const response = await agent.post("/2fa/setup-totp")
142
+ expect(response.status).toBe(400)
143
+ expect(response.body.error).toContain("already enabled")
144
+ })
145
+
146
+ it("should setup email 2FA", async () => {
147
+ const agent = request.agent(app)
148
+ await registerAndLogin(agent, "email2fa@example.com")
149
+
150
+ await agent.post("/2fa/setup-email").expect(200)
151
+
152
+ const db = await pool.query("SELECT * FROM mfa_test_2fa_methods WHERE mechanism = 2")
153
+ expect(db.rows).toHaveLength(1)
154
+ expect(db.rows[0].verified).toBe(true)
155
+ })
156
+
157
+ it("should setup SMS 2FA", async () => {
158
+ const agent = request.agent(app)
159
+ await registerAndLogin(agent, "sms@example.com")
160
+
161
+ await agent.post("/2fa/setup-sms").send({ phoneNumber: "+1234567890", requireVerification: false }).expect(200)
162
+
163
+ const db = await pool.query("SELECT * FROM mfa_test_2fa_methods WHERE mechanism = 3")
164
+ expect(db.rows).toHaveLength(1)
165
+ expect(db.rows[0].secret).toBe("+1234567890")
166
+ })
167
+ })
168
+
169
+ // ---- login flow with 2FA ----
170
+
171
+ describe("2FA Login Flow", () => {
172
+ it("should require 2FA during login and allow completion with email OTP", async () => {
173
+ const agent = request.agent(app)
174
+ await registerAndLogin(agent, "login2fa@example.com")
175
+
176
+ await agent.post("/2fa/setup-email").expect(200)
177
+ await agent.post("/logout").expect(200)
178
+
179
+ const loginResponse = await agent.post("/login").send({ email: "login2fa@example.com", password: "password123" }).expect(202)
180
+
181
+ expect(loginResponse.body.requiresTwoFactor).toBe(true)
182
+ expect(loginResponse.body.availableMethods.email).toBeDefined()
183
+
184
+ // not logged in yet
185
+ await agent.get("/profile").expect(401)
186
+
187
+ // get the OTP that was captured during login
188
+ const otpResponse = await agent.get("/test-otp")
189
+ const otpCode = otpResponse.body.otp
190
+ expect(otpCode).toBeDefined()
191
+
192
+ await agent.post("/verify-2fa").send({ code: otpCode }).expect(200)
193
+
194
+ const profile = await agent.get("/profile").expect(200)
195
+ expect(profile.body.email).toBe("login2fa@example.com")
196
+ })
197
+
198
+ it("should require 2FA during login and allow completion with TOTP", async () => {
199
+ const agent = request.agent(app)
200
+ await registerAndLogin(agent, "logintotp@example.com")
201
+
202
+ const setup = await agent.post("/2fa/setup-totp").expect(200)
203
+ const secret = setup.body.secret
204
+
205
+ await agent.post("/logout").expect(200)
206
+
207
+ const loginResponse = await agent.post("/login").send({ email: "logintotp@example.com", password: "password123" }).expect(202)
208
+
209
+ expect(loginResponse.body.requiresTwoFactor).toBe(true)
210
+ expect(loginResponse.body.availableMethods.totp).toBe(true)
211
+
212
+ const validCode = Otp.generateTotp(secret)
213
+
214
+ await agent.post("/verify-2fa-totp").send({ code: validCode }).expect(200)
215
+
216
+ const profile = await agent.get("/profile").expect(200)
217
+ expect(profile.body.email).toBe("logintotp@example.com")
218
+ })
219
+
220
+ it("should allow login with backup code", async () => {
221
+ const agent = request.agent(app)
222
+ await registerAndLogin(agent, "loginbackup@example.com")
223
+
224
+ const setup = await agent.post("/2fa/setup-totp").expect(200)
225
+ const backupCode = setup.body.backupCodes[0]
226
+
227
+ await agent.post("/logout").expect(200)
228
+
229
+ await agent.post("/login").send({ email: "loginbackup@example.com", password: "password123" }).expect(202)
230
+
231
+ await agent.post("/verify-2fa-backup").send({ code: backupCode }).expect(200)
232
+
233
+ const profile = await agent.get("/profile").expect(200)
234
+ expect(profile.body.email).toBe("loginbackup@example.com")
235
+ })
236
+
237
+ it("should reject invalid backup code", async () => {
238
+ const agent = request.agent(app)
239
+ await registerAndLogin(agent, "badbackup@example.com")
240
+
241
+ await agent.post("/2fa/setup-totp").expect(200)
242
+ await agent.post("/logout").expect(200)
243
+
244
+ await agent.post("/login").send({ email: "badbackup@example.com", password: "password123" }).expect(202)
245
+
246
+ const response = await agent.post("/verify-2fa-backup").send({ code: "INVALID1" })
247
+ expect(response.status).toBe(400)
248
+ expect(response.body.errorType).toBe("InvalidBackupCodeError")
249
+ })
250
+
251
+ it("should consume backup code (single use)", async () => {
252
+ const agent = request.agent(app)
253
+ await registerAndLogin(agent, "consumebackup@example.com")
254
+
255
+ const setup = await agent.post("/2fa/setup-totp").expect(200)
256
+ const backupCode = setup.body.backupCodes[0]
257
+
258
+ await agent.post("/logout").expect(200)
259
+
260
+ // use backup code first time
261
+ await agent.post("/login").send({ email: "consumebackup@example.com", password: "password123" }).expect(202)
262
+ await agent.post("/verify-2fa-backup").send({ code: backupCode }).expect(200)
263
+
264
+ await agent.post("/logout").expect(200)
265
+
266
+ // try to use same backup code again
267
+ await agent.post("/login").send({ email: "consumebackup@example.com", password: "password123" }).expect(202)
268
+ const response = await agent.post("/verify-2fa-backup").send({ code: backupCode })
269
+ expect(response.status).toBe(400)
270
+ })
271
+
272
+ it("should reject invalid TOTP code during login", async () => {
273
+ const agent = request.agent(app)
274
+ await registerAndLogin(agent, "badtotp@example.com")
275
+
276
+ await agent.post("/2fa/setup-totp").expect(200)
277
+ await agent.post("/logout").expect(200)
278
+
279
+ await agent.post("/login").send({ email: "badtotp@example.com", password: "password123" }).expect(202)
280
+
281
+ const response = await agent.post("/verify-2fa-totp").send({ code: "000000" })
282
+ expect(response.status).toBe(400)
283
+ expect(response.body.errorType).toBe("InvalidTwoFactorCodeError")
284
+ })
285
+ })
286
+
287
+ // ---- 2FA management ----
288
+
289
+ describe("2FA Management", () => {
290
+ it("should disable TOTP method", async () => {
291
+ const agent = request.agent(app)
292
+ await registerAndLogin(agent, "disable@example.com")
293
+
294
+ await agent.post("/2fa/setup-totp").expect(200)
295
+
296
+ let methods = await agent.get("/2fa/methods").expect(200)
297
+ expect(methods.body.methods).toContain(TwoFactorMechanism.TOTP)
298
+
299
+ await agent.delete("/2fa/method/1").expect(200)
300
+
301
+ methods = await agent.get("/2fa/methods").expect(200)
302
+ expect(methods.body.methods).not.toContain(TwoFactorMechanism.TOTP)
303
+ })
304
+
305
+ it("should reject disabling non-existent method", async () => {
306
+ const agent = request.agent(app)
307
+ await registerAndLogin(agent, "nodisable@example.com")
308
+
309
+ const response = await agent.delete("/2fa/method/1")
310
+ expect(response.status).toBe(400)
311
+ })
312
+
313
+ it("should regenerate backup codes", async () => {
314
+ const agent = request.agent(app)
315
+ await registerAndLogin(agent, "regen@example.com")
316
+
317
+ const setup = await agent.post("/2fa/setup-totp").expect(200)
318
+ const originalCodes = setup.body.backupCodes
319
+
320
+ const regen = await agent.post("/2fa/regenerate-backup-codes").expect(200)
321
+ expect(regen.body.codes).toHaveLength(10)
322
+ expect(regen.body.codes).not.toEqual(originalCodes)
323
+ })
324
+
325
+ it("should reject backup code regeneration without TOTP setup", async () => {
326
+ const agent = request.agent(app)
327
+ await registerAndLogin(agent, "noregen@example.com")
328
+
329
+ const response = await agent.post("/2fa/regenerate-backup-codes")
330
+ expect(response.status).toBe(400)
331
+ })
332
+
333
+ it("should get contact info for email and SMS", async () => {
334
+ const agent = request.agent(app)
335
+ await registerAndLogin(agent, "contact@example.com")
336
+
337
+ await agent.post("/2fa/setup-email").expect(200)
338
+ await agent.post("/2fa/setup-sms").send({ phoneNumber: "+15551234567", requireVerification: false }).expect(200)
339
+
340
+ const emailContact = await agent.get(`/2fa/contact/${TwoFactorMechanism.EMAIL}`).expect(200)
341
+ expect(emailContact.body.contact).toBe("contact@example.com")
342
+
343
+ const smsContact = await agent.get(`/2fa/contact/${TwoFactorMechanism.SMS}`).expect(200)
344
+ expect(smsContact.body.contact).toBe("+15551234567")
345
+ })
346
+
347
+ it("should get TOTP URI", async () => {
348
+ const agent = request.agent(app)
349
+ await registerAndLogin(agent, "uri@example.com")
350
+
351
+ await agent.post("/2fa/setup-totp").expect(200)
352
+
353
+ const uriResponse = await agent.get("/2fa/totp-uri").expect(200)
354
+ expect(uriResponse.body.uri).toContain("otpauth://totp/")
355
+ expect(uriResponse.body.uri).toContain("EasyAccess")
356
+ })
357
+
358
+ it("should return null TOTP URI when not setup", async () => {
359
+ const agent = request.agent(app)
360
+ await registerAndLogin(agent, "nouri@example.com")
361
+
362
+ const uriResponse = await agent.get("/2fa/totp-uri").expect(200)
363
+ expect(uriResponse.body.uri).toBeNull()
364
+ })
365
+ })
366
+
367
+ // ---- edge cases ----
368
+
369
+ describe("2FA Edge Cases", () => {
370
+ it("should not require 2FA when no methods are enabled", async () => {
371
+ const agent = request.agent(app)
372
+ await registerAndLogin(agent, "no2fa@example.com")
373
+
374
+ await agent.post("/logout").expect(200)
375
+
376
+ // login should succeed without 2FA prompt
377
+ const response = await agent.post("/login").send({ email: "no2fa@example.com", password: "password123" })
378
+ expect(response.status).toBe(200)
379
+ })
380
+
381
+ it("should handle multiple 2FA methods available", async () => {
382
+ const agent = request.agent(app)
383
+ await registerAndLogin(agent, "multi@example.com")
384
+
385
+ await agent.post("/2fa/setup-totp").expect(200)
386
+ await agent.post("/2fa/setup-email").expect(200)
387
+
388
+ await agent.post("/logout").expect(200)
389
+
390
+ const loginResponse = await agent.post("/login").send({ email: "multi@example.com", password: "password123" }).expect(202)
391
+
392
+ expect(loginResponse.body.availableMethods.totp).toBe(true)
393
+ expect(loginResponse.body.availableMethods.email).toBeDefined()
394
+ })
395
+ })
396
+ })
@@ -0,0 +1,228 @@
1
+ import Bowser from "bowser"
2
+
3
+ /**
4
+ * @typedef {import("./types.js").AuthConfig} AuthConfig
5
+ * @typedef {import("./types.js").AuthActivity} AuthActivity
6
+ * @typedef {import("./types.js").AuthActivityActionType} AuthActivityActionType
7
+ * @typedef {import("express").Request} Request
8
+ */
9
+
10
+ export class ActivityLogger {
11
+ /**
12
+ * @param {AuthConfig} config
13
+ */
14
+ constructor(config) {
15
+ this.config = config
16
+ this.enabled = config.activityLog?.enabled !== false // default true
17
+ this.maxEntries = config.activityLog?.maxEntries || 10000
18
+ this.allowedActions = config.activityLog?.actions || null // null means all actions
19
+ this.tablePrefix = config.tablePrefix || "user_"
20
+ }
21
+
22
+ get activityTable() {
23
+ return `${this.tablePrefix}activity_log`
24
+ }
25
+
26
+ /**
27
+ * @param {string | null} userAgent
28
+ * @returns {{ browser: string | null, os: string | null, device: string | null }}
29
+ */
30
+ parseUserAgent(userAgent) {
31
+ if (!userAgent) {
32
+ return { browser: null, os: null, device: null }
33
+ }
34
+
35
+ try {
36
+ const browser = Bowser.getParser(userAgent)
37
+ const result = browser.getResult()
38
+
39
+ return {
40
+ browser: result.browser.name || null,
41
+ os: result.os.name || null,
42
+ device: result.platform.type || "desktop",
43
+ }
44
+ } catch (error) {
45
+ // fallback to simple parsing if bowser fails
46
+ return this.parseUserAgentSimple(userAgent)
47
+ }
48
+ }
49
+
50
+ /**
51
+ * @param {string} userAgent
52
+ * @returns {{ browser: string | null, os: string | null, device: string | null }}
53
+ */
54
+ parseUserAgentSimple(userAgent) {
55
+ let browser = null
56
+ if (userAgent.includes("Chrome")) browser = "Chrome"
57
+ else if (userAgent.includes("Firefox")) browser = "Firefox"
58
+ else if (userAgent.includes("Safari")) browser = "Safari"
59
+ else if (userAgent.includes("Edge")) browser = "Edge"
60
+
61
+ let os = null
62
+ if (userAgent.includes("Windows")) os = "Windows"
63
+ else if (userAgent.includes("Mac OS")) os = "macOS"
64
+ else if (userAgent.includes("Linux")) os = "Linux"
65
+ else if (userAgent.includes("Android")) os = "Android"
66
+ else if (userAgent.includes("iOS")) os = "iOS"
67
+
68
+ let device = "desktop"
69
+ if (userAgent.includes("Mobile")) device = "mobile"
70
+ else if (userAgent.includes("Tablet")) device = "tablet"
71
+
72
+ return { browser, os, device }
73
+ }
74
+
75
+ /**
76
+ * @param {Request} req
77
+ * @returns {string | null}
78
+ */
79
+ getIpAddress(req) {
80
+ return req.ip || req.connection?.remoteAddress || req.socket?.remoteAddress || req.connection?.socket?.remoteAddress || null
81
+ }
82
+
83
+ /**
84
+ * @param {number | null} accountId
85
+ * @param {AuthActivityActionType} action
86
+ * @param {Request} req
87
+ * @param {boolean} [success]
88
+ * @param {Record<string, any>} [metadata]
89
+ * @returns {Promise<void>}
90
+ */
91
+ async logActivity(accountId, action, req, success = true, metadata = {}) {
92
+ if (!this.enabled) return
93
+
94
+ // check if this action is allowed
95
+ if (this.allowedActions && !this.allowedActions.includes(action)) {
96
+ return
97
+ }
98
+
99
+ const userAgent = (typeof req.get === "function" ? req.get("User-Agent") : req.headers?.["user-agent"]) || null
100
+ const ip = this.getIpAddress(req)
101
+ const parsed = this.parseUserAgent(userAgent)
102
+
103
+ // auto-pick up actor when an impersonation session is active so every existing
104
+ // log call site is impersonation-aware without changes
105
+ const actorAccountId = req.session?.auth?.actor?.accountId ?? null
106
+
107
+ try {
108
+ // insert new activity log entry
109
+ await this.config.db.query(
110
+ `
111
+ INSERT INTO ${this.activityTable}
112
+ (account_id, actor_account_id, action, ip_address, user_agent, browser, os, device, success, metadata)
113
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
114
+ `,
115
+ [accountId, actorAccountId, action, ip, userAgent, parsed.browser, parsed.os, parsed.device, success, Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : null],
116
+ )
117
+
118
+ // occasionally cleanup old entries (1% chance per insert to avoid performance impact)
119
+ if (Math.random() < 0.01) {
120
+ await this.cleanup()
121
+ }
122
+ } catch (error) {
123
+ // don't throw on logging errors - just log to console
124
+ console.error("ActivityLogger: Failed to log activity:", error)
125
+ }
126
+ }
127
+
128
+ /**
129
+ * @returns {Promise<void>}
130
+ */
131
+ async cleanup() {
132
+ if (!this.enabled) return
133
+
134
+ try {
135
+ // delete entries beyond maxEntries limit
136
+ await this.config.db.query(
137
+ `
138
+ DELETE FROM ${this.activityTable}
139
+ WHERE id NOT IN (
140
+ SELECT id FROM ${this.activityTable}
141
+ ORDER BY created_at DESC
142
+ LIMIT $1
143
+ )
144
+ `,
145
+ [this.maxEntries],
146
+ )
147
+ } catch (error) {
148
+ console.error("ActivityLogger: Failed to cleanup old entries:", error)
149
+ }
150
+ }
151
+
152
+ /**
153
+ * @param {number} [limit]
154
+ * @param {number} [accountId]
155
+ * @returns {Promise<AuthActivity[]>}
156
+ */
157
+ async getRecentActivity(limit = 100, accountId) {
158
+ if (!this.enabled) return []
159
+
160
+ try {
161
+ let sql = `
162
+ SELECT
163
+ al.*,
164
+ a.email
165
+ FROM ${this.activityTable} al
166
+ LEFT JOIN ${this.tablePrefix}accounts a ON al.account_id = a.id
167
+ `
168
+ const params = []
169
+
170
+ if (accountId !== undefined) {
171
+ sql += " WHERE al.account_id = $1"
172
+ params.push(accountId)
173
+ }
174
+
175
+ sql += ` ORDER BY al.created_at DESC LIMIT $${params.length + 1}`
176
+ params.push(Math.min(limit, 1000)) // cap at 1000 entries max
177
+
178
+ const result = await this.config.db.query(sql, params)
179
+ return result.rows.map((row) => ({
180
+ ...row,
181
+ // metadata is a JSONB column, so node-pg already returns it parsed.
182
+ // only parse when a driver hands it back as a raw string
183
+ metadata: typeof row.metadata === "string" ? JSON.parse(row.metadata) : (row.metadata ?? null),
184
+ }))
185
+ } catch (error) {
186
+ console.error("ActivityLogger: Failed to get recent activity:", error)
187
+ return []
188
+ }
189
+ }
190
+
191
+ /**
192
+ * @returns {Promise<{ totalEntries: number, uniqueUsers: number, recentLogins: number, failedAttempts: number }>}
193
+ */
194
+ async getActivityStats() {
195
+ if (!this.enabled) {
196
+ return {
197
+ totalEntries: 0,
198
+ uniqueUsers: 0,
199
+ recentLogins: 0,
200
+ failedAttempts: 0,
201
+ }
202
+ }
203
+
204
+ try {
205
+ const [total, unique, recent, failed] = await Promise.all([
206
+ this.config.db.query(`SELECT COUNT(*) as count FROM ${this.activityTable}`),
207
+ this.config.db.query(`SELECT COUNT(DISTINCT account_id) as count FROM ${this.activityTable} WHERE account_id IS NOT NULL`),
208
+ this.config.db.query(`SELECT COUNT(*) as count FROM ${this.activityTable} WHERE action = 'login' AND created_at > NOW() - INTERVAL '24 hours'`),
209
+ this.config.db.query(`SELECT COUNT(*) as count FROM ${this.activityTable} WHERE success = false AND created_at > NOW() - INTERVAL '24 hours'`),
210
+ ])
211
+
212
+ return {
213
+ totalEntries: parseInt(total.rows[0]?.count || "0"),
214
+ uniqueUsers: parseInt(unique.rows[0]?.count || "0"),
215
+ recentLogins: parseInt(recent.rows[0]?.count || "0"),
216
+ failedAttempts: parseInt(failed.rows[0]?.count || "0"),
217
+ }
218
+ } catch (error) {
219
+ console.error("ActivityLogger: Failed to get activity stats:", error)
220
+ return {
221
+ totalEntries: 0,
222
+ uniqueUsers: 0,
223
+ recentLogins: 0,
224
+ failedAttempts: 0,
225
+ }
226
+ }
227
+ }
228
+ }