@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,208 @@
1
+ import express from "express"
2
+ import session from "express-session"
3
+ import cookieParser from "cookie-parser"
4
+ import pg from "pg"
5
+ import { createAuthMiddleware, createAuthTables, dropAuthTables, AuthRole, closeInvalidationListeners } from "../index.js"
6
+
7
+ const { Pool } = pg
8
+
9
+ export async function createImpersonationTestApp(overrides = {}) {
10
+ const url = process.env.AUTH_TEST_POSTGRES_URL
11
+ const poolOptions = { max: 15, idleTimeoutMillis: 1000 }
12
+ const pool = url
13
+ ? new Pool({ connectionString: url, ...poolOptions })
14
+ : new Pool({
15
+ host: process.env.PGHOST || "localhost",
16
+ port: parseInt(process.env.PGPORT || "5432"),
17
+ database: process.env.PGDATABASE || "auth_test",
18
+ user: process.env.PGUSER || "auth",
19
+ password: process.env.PGPASSWORD || "auth_password",
20
+ ...poolOptions,
21
+ })
22
+
23
+ try {
24
+ await pool.query("SELECT NOW()")
25
+ } catch (error) {
26
+ throw new Error("Failed to connect to test database. Make sure to run: make up")
27
+ }
28
+
29
+ const app = express()
30
+ app.use(express.json())
31
+ app.use(cookieParser())
32
+ app.use(
33
+ session({
34
+ secret: "test-secret-key-for-testing-only",
35
+ resave: false,
36
+ saveUninitialized: false,
37
+ cookie: { secure: false, httpOnly: true },
38
+ }),
39
+ )
40
+
41
+ const enabled = overrides.enabled !== false
42
+
43
+ const authConfig = {
44
+ db: pool,
45
+ tablePrefix: "imp_",
46
+ minPasswordLength: 6,
47
+ maxPasswordLength: 50,
48
+ resyncInterval: "30s",
49
+ impersonation: {
50
+ enabled,
51
+ defaultTtl: overrides.defaultTtl,
52
+ maxTtl: overrides.maxTtl,
53
+ canImpersonate: overrides.canImpersonate ?? (async (actor, _target) => (actor.rolemask & AuthRole.Admin) === AuthRole.Admin),
54
+ },
55
+ }
56
+
57
+ await dropAuthTables(authConfig)
58
+ await createAuthTables(authConfig)
59
+
60
+ app.use(createAuthMiddleware(authConfig))
61
+
62
+ app.post("/register", async (req, res) => {
63
+ try {
64
+ const { email, password } = req.body
65
+ const account = await req.auth.register(email, password)
66
+ res.json({ success: true, account: { id: account.id, email: account.email } })
67
+ } catch (e) {
68
+ res.status(400).json({ error: e.message, errorType: e.constructor.name })
69
+ }
70
+ })
71
+
72
+ app.post("/login", async (req, res) => {
73
+ try {
74
+ const { email, password } = req.body
75
+ await req.auth.login(email, password)
76
+ res.json({ success: true })
77
+ } catch (e) {
78
+ res.status(401).json({ error: e.message, errorType: e.constructor.name })
79
+ }
80
+ })
81
+
82
+ app.post("/logout", async (req, res) => {
83
+ await req.auth.logout()
84
+ res.json({ success: true })
85
+ })
86
+
87
+ app.post("/admin/add-role", async (req, res) => {
88
+ try {
89
+ const { identifier, role } = req.body
90
+ await req.auth.addRoleForUserBy(identifier, role)
91
+ res.json({ success: true })
92
+ } catch (e) {
93
+ res.status(400).json({ error: e.message, errorType: e.constructor.name })
94
+ }
95
+ })
96
+
97
+ app.post("/admin/set-status", async (req, res) => {
98
+ try {
99
+ const { identifier, status } = req.body
100
+ await req.auth.setStatusForUserBy(identifier, status)
101
+ res.json({ success: true })
102
+ } catch (e) {
103
+ res.status(400).json({ error: e.message, errorType: e.constructor.name })
104
+ }
105
+ })
106
+
107
+ app.post("/admin/force-logout", async (req, res) => {
108
+ try {
109
+ await req.auth.forceLogoutForUserBy(req.body)
110
+ res.json({ success: true })
111
+ } catch (e) {
112
+ res.status(400).json({ error: e.message, errorType: e.constructor.name })
113
+ }
114
+ })
115
+
116
+ app.post("/admin/delete-user", async (req, res) => {
117
+ try {
118
+ await req.auth.deleteUserBy(req.body)
119
+ res.json({ success: true })
120
+ } catch (e) {
121
+ res.status(400).json({ error: e.message, errorType: e.constructor.name })
122
+ }
123
+ })
124
+
125
+ app.post("/change-email", async (req, res) => {
126
+ try {
127
+ const { newEmail } = req.body
128
+ let confirmationToken
129
+ await req.auth.changeEmail(newEmail, (t) => {
130
+ confirmationToken = t
131
+ })
132
+ res.json({ success: true, confirmationToken })
133
+ } catch (e) {
134
+ res.status(400).json({ error: e.message, errorType: e.constructor.name })
135
+ }
136
+ })
137
+
138
+ app.post("/confirm-email", async (req, res) => {
139
+ try {
140
+ const email = await req.auth.confirmEmail(req.body.token)
141
+ res.json({ success: true, email })
142
+ } catch (e) {
143
+ res.status(400).json({ error: e.message, errorType: e.constructor.name })
144
+ }
145
+ })
146
+
147
+ app.post("/impersonate/start", async (req, res) => {
148
+ try {
149
+ const { identifier, reason, ttl } = req.body
150
+ await req.auth.startImpersonation(identifier, { reason, ttl })
151
+ res.json({ success: true })
152
+ } catch (e) {
153
+ res.status(400).json({ error: e.message, errorType: e.constructor.name })
154
+ }
155
+ })
156
+
157
+ app.post("/impersonate/stop", async (req, res) => {
158
+ try {
159
+ await req.auth.stopImpersonation()
160
+ res.json({ success: true })
161
+ } catch (e) {
162
+ res.status(400).json({ error: e.message, errorType: e.constructor.name })
163
+ }
164
+ })
165
+
166
+ app.get("/me", (req, res) => {
167
+ if (!req.auth.isLoggedIn()) {
168
+ return res.status(401).json({ error: "Not logged in" })
169
+ }
170
+ res.json({
171
+ id: req.auth.getId(),
172
+ email: req.auth.getEmail(),
173
+ status: req.auth.getStatus(),
174
+ roles: req.auth.getRoleNames(),
175
+ isImpersonating: req.auth.isImpersonating(),
176
+ actorId: req.auth.getActorId(),
177
+ actorEmail: req.auth.getActorEmail(),
178
+ impersonation: req.auth.getImpersonationInfo(),
179
+ })
180
+ })
181
+
182
+ // forces resync regardless of interval
183
+ app.post("/force-resync", async (req, res) => {
184
+ await req.auth.resyncSession(true)
185
+ res.json({ success: true })
186
+ })
187
+
188
+ // utility: directly read activity log rows (test only)
189
+ app.get("/activity/:action", async (req, res) => {
190
+ const result = await pool.query(`SELECT account_id, actor_account_id, action, success, metadata FROM imp_activity_log WHERE action = $1 ORDER BY id DESC`, [req.params.action])
191
+ res.json(
192
+ result.rows.map((r) => ({
193
+ ...r,
194
+ metadata: typeof r.metadata === "string" ? JSON.parse(r.metadata) : r.metadata,
195
+ })),
196
+ )
197
+ })
198
+
199
+ return {
200
+ app,
201
+ pool,
202
+ authConfig,
203
+ cleanup: async () => {
204
+ await closeInvalidationListeners()
205
+ await pool.end()
206
+ },
207
+ }
208
+ }
@@ -0,0 +1,473 @@
1
+ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"
2
+ import request from "supertest"
3
+ import { createImpersonationTestApp } from "./impersonation-test-setup.js"
4
+ import { AuthRole, AuthStatus } from "../index.js"
5
+
6
+ describe("Impersonation", () => {
7
+ describe("with default admin-only policy", () => {
8
+ let app
9
+ let pool
10
+ let cleanup
11
+
12
+ beforeAll(async () => {
13
+ const t = await createImpersonationTestApp()
14
+ app = t.app
15
+ pool = t.pool
16
+ cleanup = t.cleanup
17
+ })
18
+
19
+ afterAll(async () => {
20
+ await cleanup()
21
+ })
22
+
23
+ beforeEach(async () => {
24
+ await pool.query("DELETE FROM imp_activity_log")
25
+ await pool.query("DELETE FROM imp_accounts")
26
+ })
27
+
28
+ async function setupAdminAndTarget(agent) {
29
+ // create an admin
30
+ await request(app).post("/register").send({ email: "admin@example.com", password: "password123" })
31
+ const adminRow = (await pool.query(`SELECT id FROM imp_accounts WHERE email = $1`, ["admin@example.com"])).rows[0]
32
+ await pool.query(`UPDATE imp_accounts SET rolemask = $1 WHERE id = $2`, [AuthRole.Admin, adminRow.id])
33
+
34
+ // create a target
35
+ await request(app).post("/register").send({ email: "target@example.com", password: "password123" })
36
+ const targetRow = (await pool.query(`SELECT id FROM imp_accounts WHERE email = $1`, ["target@example.com"])).rows[0]
37
+
38
+ await agent.post("/login").send({ email: "admin@example.com", password: "password123" }).expect(200)
39
+ return { adminId: adminRow.id, targetId: targetRow.id }
40
+ }
41
+
42
+ it("starts impersonation, swaps effective identity, preserves actor", async () => {
43
+ const agent = request.agent(app)
44
+ const { adminId, targetId } = await setupAdminAndTarget(agent)
45
+
46
+ const me1 = await agent.get("/me")
47
+ expect(me1.body.email).toBe("admin@example.com")
48
+ expect(me1.body.isImpersonating).toBe(false)
49
+
50
+ const res = await agent.post("/impersonate/start").send({ identifier: { email: "target@example.com" }, reason: "support ticket #42" })
51
+ expect(res.status).toBe(200)
52
+
53
+ const me2 = await agent.get("/me")
54
+ expect(me2.body.id).toBe(targetId)
55
+ expect(me2.body.email).toBe("target@example.com")
56
+ expect(me2.body.isImpersonating).toBe(true)
57
+ expect(me2.body.actorId).toBe(adminId)
58
+ expect(me2.body.actorEmail).toBe("admin@example.com")
59
+ expect(me2.body.impersonation.reason).toBe("support ticket #42")
60
+ expect(me2.body.impersonation.actor.email).toBe("admin@example.com")
61
+ expect(me2.body.impersonation.target.email).toBe("target@example.com")
62
+ })
63
+
64
+ it("regenerates session id on start and on stop", async () => {
65
+ const agent = request.agent(app)
66
+ await setupAdminAndTarget(agent)
67
+
68
+ const beforeStart = agent.jar.getCookie("connect.sid", { path: "/", domain: "127.0.0.1", script: false })?.value
69
+ await agent.post("/impersonate/start").send({ identifier: { email: "target@example.com" } }).expect(200)
70
+ const afterStart = agent.jar.getCookie("connect.sid", { path: "/", domain: "127.0.0.1", script: false })?.value
71
+ expect(afterStart).toBeTruthy()
72
+ expect(afterStart).not.toBe(beforeStart)
73
+
74
+ await agent.post("/impersonate/stop").expect(200)
75
+ const afterStop = agent.jar.getCookie("connect.sid", { path: "/", domain: "127.0.0.1", script: false })?.value
76
+ expect(afterStop).toBeTruthy()
77
+ expect(afterStop).not.toBe(afterStart)
78
+ })
79
+
80
+ it("stops impersonation and reverts to actor", async () => {
81
+ const agent = request.agent(app)
82
+ const { adminId } = await setupAdminAndTarget(agent)
83
+ await agent.post("/impersonate/start").send({ identifier: { email: "target@example.com" } })
84
+
85
+ const stop = await agent.post("/impersonate/stop")
86
+ expect(stop.status).toBe(200)
87
+
88
+ const me = await agent.get("/me")
89
+ expect(me.body.id).toBe(adminId)
90
+ expect(me.body.email).toBe("admin@example.com")
91
+ expect(me.body.isImpersonating).toBe(false)
92
+ expect(me.body.actorId).toBeNull()
93
+ })
94
+
95
+ it("rejects nested impersonation", async () => {
96
+ const agent = request.agent(app)
97
+ await setupAdminAndTarget(agent)
98
+ await request(app).post("/register").send({ email: "second@example.com", password: "password123" })
99
+
100
+ await agent.post("/impersonate/start").send({ identifier: { email: "target@example.com" } }).expect(200)
101
+ const res = await agent.post("/impersonate/start").send({ identifier: { email: "second@example.com" } })
102
+ expect(res.status).toBe(400)
103
+ expect(res.body.errorType).toBe("AlreadyImpersonatingError")
104
+ })
105
+
106
+ it("rejects self-impersonation", async () => {
107
+ const agent = request.agent(app)
108
+ await setupAdminAndTarget(agent)
109
+ const res = await agent.post("/impersonate/start").send({ identifier: { email: "admin@example.com" } })
110
+ expect(res.status).toBe(400)
111
+ expect(res.body.errorType).toBe("ImpersonationNotAllowedError")
112
+ })
113
+
114
+ it("rejects when policy denies", async () => {
115
+ // unauthenticated user can be logged in as non-admin and try to impersonate
116
+ await request(app).post("/register").send({ email: "nobody@example.com", password: "password123" })
117
+ const agent = request.agent(app)
118
+ await agent.post("/login").send({ email: "nobody@example.com", password: "password123" }).expect(200)
119
+ await request(app).post("/register").send({ email: "victim@example.com", password: "password123" })
120
+
121
+ const res = await agent.post("/impersonate/start").send({ identifier: { email: "victim@example.com" } })
122
+ expect(res.status).toBe(400)
123
+ expect(res.body.errorType).toBe("ImpersonationNotAllowedError")
124
+
125
+ const rejected = await request(app).get("/activity/impersonation_rejected")
126
+ expect(rejected.body.length).toBeGreaterThan(0)
127
+ expect(rejected.body[0].success).toBe(false)
128
+ })
129
+
130
+ it("requires login", async () => {
131
+ const agent = request.agent(app)
132
+ await request(app).post("/register").send({ email: "anon@example.com", password: "password123" })
133
+ const res = await agent.post("/impersonate/start").send({ identifier: { email: "anon@example.com" } })
134
+ expect(res.status).toBe(400)
135
+ expect(res.body.errorType).toBe("UserNotLoggedInError")
136
+ })
137
+
138
+ it("requires the target to exist", async () => {
139
+ const agent = request.agent(app)
140
+ await setupAdminAndTarget(agent)
141
+ const res = await agent.post("/impersonate/start").send({ identifier: { email: "ghost@example.com" } })
142
+ expect(res.status).toBe(400)
143
+ expect(res.body.errorType).toBe("UserNotFoundError")
144
+ })
145
+
146
+ it("stop fails when not impersonating", async () => {
147
+ const agent = request.agent(app)
148
+ await setupAdminAndTarget(agent)
149
+ const res = await agent.post("/impersonate/stop")
150
+ expect(res.status).toBe(400)
151
+ expect(res.body.errorType).toBe("NotImpersonatingError")
152
+ })
153
+
154
+ it("logs ImpersonationStarted with actor_account_id = admin and account_id = target", async () => {
155
+ const agent = request.agent(app)
156
+ const { adminId, targetId } = await setupAdminAndTarget(agent)
157
+ await agent.post("/impersonate/start").send({ identifier: { email: "target@example.com" }, reason: "audit" })
158
+
159
+ const rows = (await request(app).get("/activity/impersonation_started")).body
160
+ expect(rows.length).toBe(1)
161
+ expect(rows[0].account_id).toBe(targetId)
162
+ expect(rows[0].actor_account_id).toBe(adminId)
163
+ expect(rows[0].metadata.reason).toBe("audit")
164
+ })
165
+
166
+ it("all activity emitted during impersonation carries actor_account_id automatically", async () => {
167
+ const agent = request.agent(app)
168
+ const { adminId, targetId } = await setupAdminAndTarget(agent)
169
+ await agent.post("/impersonate/start").send({ identifier: { email: "target@example.com" } })
170
+
171
+ // perform an action that emits activity through the normal logActivity path
172
+ await agent.post("/logout") // this logs a Logout activity for target
173
+
174
+ const logoutRows = (await request(app).get("/activity/logout")).body
175
+ expect(logoutRows.length).toBe(1)
176
+ expect(logoutRows[0].account_id).toBe(targetId)
177
+ expect(logoutRows[0].actor_account_id).toBe(adminId)
178
+ })
179
+
180
+ it("logs ImpersonationStopped on manual stop", async () => {
181
+ const agent = request.agent(app)
182
+ const { adminId, targetId } = await setupAdminAndTarget(agent)
183
+ await agent.post("/impersonate/start").send({ identifier: { email: "target@example.com" } })
184
+ await agent.post("/impersonate/stop")
185
+
186
+ const rows = (await request(app).get("/activity/impersonation_stopped")).body
187
+ expect(rows.length).toBe(1)
188
+ expect(rows[0].account_id).toBe(targetId)
189
+ expect(rows[0].actor_account_id).toBe(adminId)
190
+ expect(rows[0].metadata.cause).toBe("manual")
191
+ })
192
+
193
+ it("target force-logout does not kick the impersonator", async () => {
194
+ const agent = request.agent(app)
195
+ await setupAdminAndTarget(agent)
196
+ await agent.post("/impersonate/start").send({ identifier: { email: "target@example.com" } })
197
+
198
+ // increment target's force_logout via raw SQL (simulates another admin doing it)
199
+ await pool.query(`UPDATE imp_accounts SET force_logout = force_logout + 1 WHERE email = $1`, ["target@example.com"])
200
+
201
+ await agent.post("/force-resync").expect(200)
202
+
203
+ const me = await agent.get("/me")
204
+ expect(me.status).toBe(200)
205
+ expect(me.body.isImpersonating).toBe(true)
206
+ expect(me.body.email).toBe("target@example.com")
207
+ })
208
+
209
+ it("actor force-logout terminates the impersonation session entirely", async () => {
210
+ const agent = request.agent(app)
211
+ await setupAdminAndTarget(agent)
212
+ await agent.post("/impersonate/start").send({ identifier: { email: "target@example.com" } })
213
+
214
+ await pool.query(`UPDATE imp_accounts SET force_logout = force_logout + 1 WHERE email = $1`, ["admin@example.com"])
215
+ await agent.post("/force-resync").expect(200)
216
+
217
+ const me = await agent.get("/me")
218
+ expect(me.status).toBe(401)
219
+ })
220
+
221
+ it("actor account deletion mid-impersonation logs out entirely", async () => {
222
+ const agent = request.agent(app)
223
+ const { adminId } = await setupAdminAndTarget(agent)
224
+ await agent.post("/impersonate/start").send({ identifier: { email: "target@example.com" } })
225
+
226
+ await pool.query(`DELETE FROM imp_accounts WHERE id = $1`, [adminId])
227
+ await agent.post("/force-resync").expect(200)
228
+
229
+ const me = await agent.get("/me")
230
+ expect(me.status).toBe(401)
231
+ })
232
+
233
+ it("target deleted mid-impersonation reverts to actor", async () => {
234
+ const agent = request.agent(app)
235
+ const { adminId, targetId } = await setupAdminAndTarget(agent)
236
+ await agent.post("/impersonate/start").send({ identifier: { email: "target@example.com" } })
237
+
238
+ await pool.query(`DELETE FROM imp_accounts WHERE id = $1`, [targetId])
239
+ await agent.post("/force-resync").expect(200)
240
+
241
+ const me = await agent.get("/me")
242
+ expect(me.status).toBe(200)
243
+ expect(me.body.id).toBe(adminId)
244
+ expect(me.body.isImpersonating).toBe(false)
245
+ })
246
+
247
+ it("target status changes are reflected in effective identity", async () => {
248
+ const agent = request.agent(app)
249
+ await setupAdminAndTarget(agent)
250
+ await agent.post("/impersonate/start").send({ identifier: { email: "target@example.com" } })
251
+
252
+ await pool.query(`UPDATE imp_accounts SET status = $1 WHERE email = $2`, [AuthStatus.Suspended, "target@example.com"])
253
+ await agent.post("/force-resync").expect(200)
254
+
255
+ const me = await agent.get("/me")
256
+ expect(me.body.status).toBe(AuthStatus.Suspended)
257
+ expect(me.body.isImpersonating).toBe(true)
258
+ })
259
+
260
+ it("force-logging-out the target while impersonating does not kick the impersonator", async () => {
261
+ const agent = request.agent(app)
262
+ await setupAdminAndTarget(agent)
263
+ await agent.post("/impersonate/start").send({ identifier: { email: "target@example.com" } })
264
+
265
+ // admin force-logs-out the target as part of normal admin work while impersonating.
266
+ // the impersonation session is owned by the actor and must survive this.
267
+ await agent.post("/admin/force-logout").send({ email: "target@example.com" }).expect(200)
268
+ await agent.post("/force-resync").expect(200)
269
+
270
+ const me = await agent.get("/me")
271
+ expect(me.status).toBe(200)
272
+ expect(me.body.isImpersonating).toBe(true)
273
+ expect(me.body.email).toBe("target@example.com")
274
+ })
275
+
276
+ it("force-logging-out the actor while impersonating kills the session", async () => {
277
+ const agent = request.agent(app)
278
+ await setupAdminAndTarget(agent)
279
+ await agent.post("/impersonate/start").send({ identifier: { email: "target@example.com" } })
280
+
281
+ // the admin force-logs-out themselves (e.g. via another admin tool, or revoking their own token)
282
+ // this SHOULD propagate. shouldForceLogout is set, next resync logs out fully.
283
+ await agent.post("/admin/force-logout").send({ email: "admin@example.com" }).expect(200)
284
+ await agent.post("/force-resync").expect(200)
285
+
286
+ const me = await agent.get("/me")
287
+ expect(me.status).toBe(401)
288
+ })
289
+
290
+ it("on-behalf-of: changeEmail during impersonation operates on the target", async () => {
291
+ const agent = request.agent(app)
292
+ await setupAdminAndTarget(agent)
293
+ await agent.post("/impersonate/start").send({ identifier: { email: "target@example.com" } })
294
+
295
+ // admin acting on the user's behalf to start an email change.
296
+ // confirmation token is issued for the target.
297
+ const res = await agent.post("/change-email").send({ newEmail: "target-new@example.com" })
298
+ expect(res.status).toBe(200)
299
+ expect(res.body.confirmationToken).toBeTruthy()
300
+
301
+ // confirm the token while impersonating - target's email should change, NOT admin's
302
+ await agent.post("/confirm-email").send({ token: res.body.confirmationToken }).expect(200)
303
+
304
+ const admin = (await pool.query(`SELECT email FROM imp_accounts WHERE id = $1`, [(await pool.query(`SELECT id FROM imp_accounts WHERE email = $1`, ["admin@example.com"])).rows[0].id])).rows[0]
305
+ const target = (await pool.query(`SELECT email FROM imp_accounts WHERE email = $1`, ["target-new@example.com"])).rows[0]
306
+ expect(admin.email).toBe("admin@example.com")
307
+ expect(target).toBeTruthy()
308
+ })
309
+
310
+ it("does not create a remember token while impersonating", async () => {
311
+ const agent = request.agent(app)
312
+ await setupAdminAndTarget(agent)
313
+ await agent.post("/impersonate/start").send({ identifier: { email: "target@example.com" } })
314
+
315
+ const remembers = await pool.query(`SELECT * FROM imp_remembers`)
316
+ expect(remembers.rows.length).toBe(0)
317
+ })
318
+ })
319
+
320
+ describe("ttl-based expiry", () => {
321
+ let app
322
+ let pool
323
+ let cleanup
324
+
325
+ beforeAll(async () => {
326
+ const t = await createImpersonationTestApp({
327
+ defaultTtl: "10s",
328
+ canImpersonate: async () => true,
329
+ })
330
+ app = t.app
331
+ pool = t.pool
332
+ cleanup = t.cleanup
333
+ })
334
+
335
+ afterAll(async () => {
336
+ await cleanup()
337
+ })
338
+
339
+ beforeEach(async () => {
340
+ await pool.query("DELETE FROM imp_activity_log")
341
+ await pool.query("DELETE FROM imp_accounts")
342
+ })
343
+
344
+ it("auto-reverts to actor on resync when expiresAt has passed", async () => {
345
+ await request(app).post("/register").send({ email: "a@example.com", password: "password123" })
346
+ await request(app).post("/register").send({ email: "b@example.com", password: "password123" })
347
+ const agent = request.agent(app)
348
+ await agent.post("/login").send({ email: "a@example.com", password: "password123" })
349
+
350
+ await agent.post("/impersonate/start").send({ identifier: { email: "b@example.com" }, ttl: "50ms" }).expect(200)
351
+
352
+ const me1 = await agent.get("/me")
353
+ expect(me1.body.isImpersonating).toBe(true)
354
+
355
+ await new Promise((r) => setTimeout(r, 100))
356
+ await agent.post("/force-resync").expect(200)
357
+
358
+ const me2 = await agent.get("/me")
359
+ expect(me2.body.isImpersonating).toBe(false)
360
+ expect(me2.body.email).toBe("a@example.com")
361
+
362
+ const expired = (await request(app).get("/activity/impersonation_expired")).body
363
+ expect(expired.length).toBe(1)
364
+ expect(expired[0].metadata.cause).toBe("expired")
365
+ })
366
+
367
+ it("maxTtl caps a longer caller-provided ttl", async () => {
368
+ await cleanup()
369
+ const t = await createImpersonationTestApp({
370
+ maxTtl: "100ms",
371
+ canImpersonate: async () => true,
372
+ })
373
+ app = t.app
374
+ pool = t.pool
375
+ cleanup = t.cleanup
376
+
377
+ await request(app).post("/register").send({ email: "a@example.com", password: "password123" })
378
+ await request(app).post("/register").send({ email: "b@example.com", password: "password123" })
379
+ const agent = request.agent(app)
380
+ await agent.post("/login").send({ email: "a@example.com", password: "password123" })
381
+
382
+ // ask for 10s, will be capped to 100ms
383
+ await agent.post("/impersonate/start").send({ identifier: { email: "b@example.com" }, ttl: "10s" }).expect(200)
384
+
385
+ await new Promise((r) => setTimeout(r, 200))
386
+ await agent.post("/force-resync")
387
+ const me = await agent.get("/me")
388
+ expect(me.body.isImpersonating).toBe(false)
389
+ })
390
+ })
391
+
392
+ describe("canImpersonate hook errors", () => {
393
+ let app
394
+ let pool
395
+ let cleanup
396
+
397
+ beforeAll(async () => {
398
+ const t = await createImpersonationTestApp({
399
+ canImpersonate: async () => {
400
+ throw new Error("tenant lookup failed")
401
+ },
402
+ })
403
+ app = t.app
404
+ pool = t.pool
405
+ cleanup = t.cleanup
406
+ })
407
+
408
+ afterAll(async () => {
409
+ await cleanup()
410
+ })
411
+
412
+ beforeEach(async () => {
413
+ await pool.query("DELETE FROM imp_activity_log")
414
+ await pool.query("DELETE FROM imp_accounts")
415
+ })
416
+
417
+ it("fails closed when the policy hook throws", async () => {
418
+ await request(app).post("/register").send({ email: "a@example.com", password: "password123" })
419
+ await request(app).post("/register").send({ email: "b@example.com", password: "password123" })
420
+ const agent = request.agent(app)
421
+ await agent.post("/login").send({ email: "a@example.com", password: "password123" })
422
+
423
+ const res = await agent.post("/impersonate/start").send({ identifier: { email: "b@example.com" } })
424
+ expect(res.status).toBe(400)
425
+ expect(res.body.errorType).toBe("ImpersonationNotAllowedError")
426
+
427
+ // session is unchanged
428
+ const me = await agent.get("/me")
429
+ expect(me.body.email).toBe("a@example.com")
430
+ expect(me.body.isImpersonating).toBe(false)
431
+
432
+ // the failure is recorded with the underlying error message for diagnosis
433
+ const rejected = (await request(app).get("/activity/impersonation_rejected")).body
434
+ expect(rejected.length).toBe(1)
435
+ expect(rejected[0].success).toBe(false)
436
+ expect(rejected[0].metadata.reason).toBe("policy_error")
437
+ expect(rejected[0].metadata.policyError).toBe("tenant lookup failed")
438
+ })
439
+ })
440
+
441
+ describe("when impersonation is disabled", () => {
442
+ let app
443
+ let pool
444
+ let cleanup
445
+
446
+ beforeAll(async () => {
447
+ const t = await createImpersonationTestApp({ enabled: false })
448
+ app = t.app
449
+ pool = t.pool
450
+ cleanup = t.cleanup
451
+ })
452
+
453
+ afterAll(async () => {
454
+ await cleanup()
455
+ })
456
+
457
+ beforeEach(async () => {
458
+ await pool.query("DELETE FROM imp_activity_log")
459
+ await pool.query("DELETE FROM imp_accounts")
460
+ })
461
+
462
+ it("rejects startImpersonation entirely", async () => {
463
+ await request(app).post("/register").send({ email: "a@example.com", password: "password123" })
464
+ await request(app).post("/register").send({ email: "b@example.com", password: "password123" })
465
+ const agent = request.agent(app)
466
+ await agent.post("/login").send({ email: "a@example.com", password: "password123" })
467
+
468
+ const res = await agent.post("/impersonate/start").send({ identifier: { email: "b@example.com" } })
469
+ expect(res.status).toBe(400)
470
+ expect(res.body.errorType).toBe("ImpersonationDisabledError")
471
+ })
472
+ })
473
+ })