@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.
- package/README.md +226 -0
- package/index.d.ts +19 -0
- package/package.json +76 -0
- package/src/__tests__/auth.test.js +1171 -0
- package/src/__tests__/impersonation-test-setup.js +208 -0
- package/src/__tests__/impersonation.test.js +473 -0
- package/src/__tests__/oauth-test-setup.js +136 -0
- package/src/__tests__/oauth.test.js +400 -0
- package/src/__tests__/prsm.test.js +215 -0
- package/src/__tests__/test-setup.js +385 -0
- package/src/__tests__/totp.test.js +158 -0
- package/src/__tests__/two-factor-test-setup.js +331 -0
- package/src/__tests__/two-factor.test.js +396 -0
- package/src/activity-logger.js +228 -0
- package/src/auth-context.js +120 -0
- package/src/auth-functions.js +520 -0
- package/src/auth-manager.js +1371 -0
- package/src/errors.js +173 -0
- package/src/hooks.js +41 -0
- package/src/index.js +23 -0
- package/src/invalidation.js +166 -0
- package/src/middleware.js +33 -0
- package/src/providers/azure-provider.js +114 -0
- package/src/providers/base-provider.js +152 -0
- package/src/providers/github-provider.js +86 -0
- package/src/providers/google-provider.js +76 -0
- package/src/providers/index.js +4 -0
- package/src/queries.js +543 -0
- package/src/schema.js +261 -0
- package/src/totp.js +221 -0
- package/src/two-factor/index.js +3 -0
- package/src/two-factor/otp-provider.js +128 -0
- package/src/two-factor/totp-provider.js +98 -0
- package/src/two-factor/two-factor-manager.js +676 -0
- package/src/types.js +399 -0
- package/src/user-roles.js +128 -0
- package/src/util.js +32 -0
- package/types/activity-logger.d.ts +73 -0
- package/types/auth-context.d.ts +88 -0
- package/types/auth-functions.d.ts +151 -0
- package/types/auth-manager.d.ts +365 -0
- package/types/errors.d.ts +108 -0
- package/types/hooks.d.ts +30 -0
- package/types/index.d.ts +13 -0
- package/types/invalidation.d.ts +40 -0
- package/types/middleware.d.ts +11 -0
- package/types/providers/azure-provider.d.ts +35 -0
- package/types/providers/base-provider.d.ts +52 -0
- package/types/providers/github-provider.d.ts +29 -0
- package/types/providers/google-provider.d.ts +29 -0
- package/types/providers/index.d.ts +4 -0
- package/types/queries.d.ts +287 -0
- package/types/schema.d.ts +37 -0
- package/types/totp.d.ts +72 -0
- package/types/two-factor/index.d.ts +3 -0
- package/types/two-factor/otp-provider.d.ts +57 -0
- package/types/two-factor/totp-provider.d.ts +58 -0
- package/types/two-factor/two-factor-manager.d.ts +191 -0
- package/types/types.d.ts +688 -0
- package/types/user-roles.d.ts +47 -0
- 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
|
+
})
|