@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,215 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest"
2
+ import request from "supertest"
3
+ import { createTestApp, createTestDatabase } from "./test-setup.js"
4
+ import { createAuthContext, createAuthTables, dropAuthTables, ActivityLogger, AuthStatus, AuthRole, AuthActivityAction, TwoFactorMechanism } from "../index.js"
5
+ import { ensureListener, notifyInvalidation, wasInvalidatedSince, closeInvalidationListeners } from "../invalidation.js"
6
+
7
+ /**
8
+ * @param {() => boolean} predicate
9
+ * @param {number} timeoutMs
10
+ */
11
+ async function waitFor(predicate, timeoutMs = 4000) {
12
+ const start = Date.now()
13
+ while (Date.now() - start < timeoutMs) {
14
+ if (predicate()) return true
15
+ await new Promise((r) => setTimeout(r, 50))
16
+ }
17
+ return false
18
+ }
19
+
20
+ describe("optional limiter (duck-typed @prsm/limit)", () => {
21
+ let app
22
+ let cleanup
23
+ let allowed = true
24
+
25
+ beforeAll(async () => {
26
+ // duck-typed limiter using the tokenBucket verb (take); auth should not care
27
+ const limiter = { take: async () => ({ allowed, retryAfter: 1000 }) }
28
+ const ctx = await createTestApp({ tablePrefix: "prsm_lim_", limiter })
29
+ app = ctx.app
30
+ cleanup = ctx.cleanup
31
+
32
+ await request(app).post("/register").send({ email: "lim@example.com", password: "password123" })
33
+ // verify so login can proceed when allowed
34
+ const pool = ctx.pool
35
+ await pool.query(`UPDATE prsm_lim_accounts SET verified = true WHERE email = $1`, ["lim@example.com"])
36
+ })
37
+
38
+ afterAll(async () => {
39
+ await cleanup()
40
+ })
41
+
42
+ it("permits login when the limiter allows it", async () => {
43
+ allowed = true
44
+ const res = await request(app).post("/login").send({ email: "lim@example.com", password: "password123" })
45
+ expect(res.status).toBe(200)
46
+ })
47
+
48
+ it("rejects login with RateLimitedError when the limiter denies it", async () => {
49
+ allowed = false
50
+ const res = await request(app).post("/login").send({ email: "lim@example.com", password: "password123" })
51
+ expect(res.status).toBe(401)
52
+ expect(res.body.errorType).toBe("RateLimitedError")
53
+ })
54
+ })
55
+
56
+ describe("optional tracer (duck-typed @prsm/trace)", () => {
57
+ let app
58
+ let cleanup
59
+ const spans = []
60
+
61
+ beforeAll(async () => {
62
+ const tracer = {
63
+ span: (name, attributes, fn) => {
64
+ spans.push(name)
65
+ return fn()
66
+ },
67
+ }
68
+ const ctx = await createTestApp({ tablePrefix: "prsm_trc_", tracer })
69
+ app = ctx.app
70
+ cleanup = ctx.cleanup
71
+
72
+ await request(app).post("/register").send({ email: "trc@example.com", password: "password123" })
73
+ await ctx.pool.query(`UPDATE prsm_trc_accounts SET verified = true WHERE email = $1`, ["trc@example.com"])
74
+ })
75
+
76
+ afterAll(async () => {
77
+ await cleanup()
78
+ })
79
+
80
+ it("wraps login in a tracing span", async () => {
81
+ await request(app).post("/login").send({ email: "trc@example.com", password: "password123" })
82
+ expect(spans).toContain("auth.login")
83
+ })
84
+ })
85
+
86
+ describe("createAuthContext devtools surface", () => {
87
+ let pool
88
+ let config
89
+ let ctx
90
+
91
+ beforeAll(async () => {
92
+ pool = await createTestDatabase()
93
+ config = { db: pool, tablePrefix: "prsm_ctx_", minPasswordLength: 6 }
94
+ await dropAuthTables(config)
95
+ await createAuthTables(config)
96
+ ctx = createAuthContext(config)
97
+ })
98
+
99
+ afterAll(async () => {
100
+ await closeInvalidationListeners()
101
+ await pool.end()
102
+ })
103
+
104
+ it("lists accounts with a total count", async () => {
105
+ await ctx.createUser({ email: "a@example.com", password: "password123" }, "u-a")
106
+ await ctx.createUser({ email: "b@example.com", password: "password123" }, "u-b")
107
+ const { accounts, total } = await ctx.listAccounts({ limit: 10 })
108
+ expect(total).toBe(2)
109
+ expect(accounts.length).toBe(2)
110
+ })
111
+
112
+ it("searches accounts by email", async () => {
113
+ const { accounts, total } = await ctx.listAccounts({ search: "a@" })
114
+ expect(total).toBe(1)
115
+ expect(accounts[0].email).toBe("a@example.com")
116
+ })
117
+
118
+ it("gets a single account by identifier", async () => {
119
+ const account = await ctx.getAccount({ email: "b@example.com" })
120
+ expect(account.user_id).toBe("u-b")
121
+ })
122
+
123
+ it("throws UserNotFoundError for a missing account", async () => {
124
+ await expect(ctx.getAccount({ email: "nope@example.com" })).rejects.toThrow("User not found")
125
+ })
126
+
127
+ it("exposes the role map", () => {
128
+ expect(ctx.getRoles()).toBe(AuthRole)
129
+ })
130
+
131
+ it("returns table stats", async () => {
132
+ const stats = await ctx.getStats()
133
+ expect(stats.accounts).toBe(2)
134
+ })
135
+
136
+ it("returns recent activity entries", async () => {
137
+ const activity = await ctx.getRecentActivity(10)
138
+ expect(Array.isArray(activity)).toBe(true)
139
+ })
140
+
141
+ // regression: metadata is a JSONB column, so node-pg returns it already
142
+ // parsed. getRecentActivity used to call JSON.parse on it unconditionally,
143
+ // which threw and made the method silently return [] for any row with
144
+ // metadata. it must round-trip metadata as a parsed object and surface the
145
+ // parsed user-agent fields
146
+ it("round-trips activity metadata and parses the user agent", async () => {
147
+ const account = await ctx.getAccount({ email: "a@example.com" })
148
+ const logger = new ActivityLogger(config)
149
+ const req = {
150
+ headers: { "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36" },
151
+ socket: { remoteAddress: "10.0.0.9" },
152
+ }
153
+ await logger.logActivity(account.id, AuthActivityAction.Login, req, true, { email: "a@example.com", remember: true })
154
+
155
+ const activity = await ctx.getRecentActivity(10, account.id)
156
+ expect(activity.length).toBeGreaterThan(0)
157
+ const entry = activity[0]
158
+ expect(entry.action).toBe(AuthActivityAction.Login)
159
+ expect(entry.metadata).toEqual({ email: "a@example.com", remember: true })
160
+ expect(entry.browser).toBe("Chrome")
161
+ })
162
+
163
+ it("exposes the status map", () => {
164
+ expect(ctx.getStatuses()).toBe(AuthStatus)
165
+ })
166
+
167
+ it("exposes the two-factor mechanism map", () => {
168
+ expect(ctx.getMechanisms()).toBe(TwoFactorMechanism)
169
+ })
170
+
171
+ it("reflects role changes through getAccount", async () => {
172
+ await ctx.addRoleForUserBy({ email: "a@example.com" }, AuthRole.Admin)
173
+ const account = await ctx.getAccount({ email: "a@example.com" })
174
+ expect((account.rolemask & AuthRole.Admin) === AuthRole.Admin).toBe(true)
175
+ })
176
+ })
177
+
178
+ describe("cross-instance invalidation (postgres LISTEN/NOTIFY)", () => {
179
+ let pool
180
+ let config
181
+
182
+ beforeAll(async () => {
183
+ pool = await createTestDatabase()
184
+ config = { db: pool, tablePrefix: "prsm_inv_", minPasswordLength: 6, invalidation: { listen: true } }
185
+ await dropAuthTables(config)
186
+ await createAuthTables(config)
187
+ ensureListener(config)
188
+ })
189
+
190
+ afterAll(async () => {
191
+ await closeInvalidationListeners()
192
+ await pool.end()
193
+ })
194
+
195
+ it("delivers an explicit notify to the listener", async () => {
196
+ // give the LISTEN connection a moment to attach, then notify
197
+ await waitFor(() => false, 300)
198
+ await notifyInvalidation(config, 4242)
199
+ const got = await waitFor(() => wasInvalidatedSince(config, 4242, 0))
200
+ expect(got).toBe(true)
201
+ })
202
+
203
+ it("notifies when a security-relevant account field changes", async () => {
204
+ const ctx = createAuthContext(config)
205
+ const account = await ctx.createUser({ email: "inv@example.com", password: "password123" }, "u-inv")
206
+ const since = Date.now() - 1
207
+ await ctx.setStatusForUserBy({ accountId: account.id }, AuthStatus.Banned)
208
+ const got = await waitFor(() => wasInvalidatedSince(config, account.id, since))
209
+ expect(got).toBe(true)
210
+ })
211
+
212
+ it("does not report invalidation for an untouched account", () => {
213
+ expect(wasInvalidatedSince(config, 999999, 0)).toBe(false)
214
+ })
215
+ })
@@ -0,0 +1,385 @@
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 createTestDatabase() {
10
+ const url = process.env.AUTH_TEST_POSTGRES_URL
11
+ // keep test pools small so the whole suite can't exhaust postgres connections
12
+ const poolOptions = { max: 15, idleTimeoutMillis: 1000 }
13
+ const pool = url
14
+ ? new Pool({ connectionString: url, ...poolOptions })
15
+ : new Pool({
16
+ host: process.env.PGHOST || "localhost",
17
+ port: parseInt(process.env.PGPORT || "5432"),
18
+ database: process.env.PGDATABASE || "auth_test",
19
+ user: process.env.PGUSER || "auth",
20
+ password: process.env.PGPASSWORD || "auth_password",
21
+ ...poolOptions,
22
+ })
23
+
24
+ try {
25
+ await pool.query("SELECT NOW()")
26
+ } catch (error) {
27
+ throw new Error("Failed to connect to test database. Make sure to run: make up")
28
+ }
29
+
30
+ return pool
31
+ }
32
+
33
+ /**
34
+ * @param {Partial<import("../index.js").AuthConfig>} [configOverrides]
35
+ */
36
+ export async function createTestApp(configOverrides) {
37
+ const pool = await createTestDatabase()
38
+
39
+ const app = express()
40
+
41
+ app.use(express.json())
42
+ app.use(cookieParser())
43
+ app.use(
44
+ session({
45
+ secret: "test-secret-key-for-testing-only",
46
+ resave: false,
47
+ saveUninitialized: false,
48
+ cookie: { secure: false, httpOnly: true },
49
+ }),
50
+ )
51
+
52
+ const authConfig = {
53
+ db: pool,
54
+ tablePrefix: "test_",
55
+ minPasswordLength: 6,
56
+ maxPasswordLength: 50,
57
+ rememberDuration: "7d",
58
+ rememberCookieName: "test_remember_token",
59
+ resyncInterval: "30s",
60
+ ...configOverrides,
61
+ }
62
+
63
+ await dropAuthTables(authConfig)
64
+ await createAuthTables(authConfig)
65
+
66
+ app.use(createAuthMiddleware(authConfig))
67
+
68
+ app.post("/register", async (req, res) => {
69
+ try {
70
+ const { email, password, userId, requireConfirmation } = req.body
71
+ let confirmationToken
72
+
73
+ const account = await req.auth.register(
74
+ email,
75
+ password,
76
+ userId || undefined,
77
+ requireConfirmation
78
+ ? (token) => {
79
+ confirmationToken = token
80
+ }
81
+ : undefined,
82
+ )
83
+
84
+ res.json({
85
+ success: true,
86
+ account: {
87
+ id: account.id,
88
+ email: account.email,
89
+ verified: account.verified,
90
+ status: account.status,
91
+ user_id: account.user_id,
92
+ },
93
+ confirmationToken,
94
+ })
95
+ } catch (error) {
96
+ res.status(400).json({ error: error.message, errorType: error.constructor.name })
97
+ }
98
+ })
99
+
100
+ app.post("/login", async (req, res) => {
101
+ try {
102
+ const { email, password, remember } = req.body
103
+ await req.auth.login(email, password, remember)
104
+ res.json({ success: true })
105
+ } catch (error) {
106
+ res.status(401).json({ error: error.message, errorType: error.constructor.name })
107
+ }
108
+ })
109
+
110
+ app.post("/logout", async (req, res) => {
111
+ try {
112
+ await req.auth.logout()
113
+ res.json({ success: true })
114
+ } catch (error) {
115
+ res.status(500).json({ error: error.message })
116
+ }
117
+ })
118
+
119
+ app.get("/profile", async (req, res) => {
120
+ if (!req.auth.isLoggedIn()) {
121
+ return res.status(401).json({ error: "Not logged in" })
122
+ }
123
+
124
+ res.json({
125
+ id: req.auth.getId(),
126
+ email: req.auth.getEmail(),
127
+ status: req.auth.getStatus(),
128
+ statusName: req.auth.getStatusName(),
129
+ verified: req.auth.getVerified(),
130
+ hasPassword: req.auth.hasPassword(),
131
+ roles: req.auth.getRoleNames(),
132
+ remembered: req.auth.isRemembered(),
133
+ isAdmin: await req.auth.isAdmin(),
134
+ hasRole: req.query.role ? await req.auth.hasRole(parseInt(req.query.role)) : undefined,
135
+ })
136
+ })
137
+
138
+ app.post("/confirm-email", async (req, res) => {
139
+ try {
140
+ const { token, autoLogin } = req.body
141
+ if (autoLogin) {
142
+ await req.auth.confirmEmailAndLogin(token)
143
+ } else {
144
+ const email = await req.auth.confirmEmail(token)
145
+ res.json({ success: true, email })
146
+ return
147
+ }
148
+ res.json({ success: true })
149
+ } catch (error) {
150
+ res.status(400).json({ error: error.message, errorType: error.constructor.name })
151
+ }
152
+ })
153
+
154
+ app.post("/reset-password", async (req, res) => {
155
+ try {
156
+ const { email, expiresAfter, maxRequests } = req.body
157
+ let resetToken
158
+
159
+ await req.auth.resetPassword(email, expiresAfter || "1h", maxRequests || 3, (token) => {
160
+ resetToken = token
161
+ })
162
+
163
+ res.json({ success: true, resetToken })
164
+ } catch (error) {
165
+ res.status(400).json({ error: error.message, errorType: error.constructor.name })
166
+ }
167
+ })
168
+
169
+ app.post("/confirm-reset", async (req, res) => {
170
+ try {
171
+ const { token, password, logout } = req.body
172
+ await req.auth.confirmResetPassword(token, password, logout)
173
+ res.json({ success: true })
174
+ } catch (error) {
175
+ res.status(400).json({ error: error.message, errorType: error.constructor.name })
176
+ }
177
+ })
178
+
179
+ app.post("/change-email", async (req, res) => {
180
+ try {
181
+ const { newEmail } = req.body
182
+ let confirmationToken
183
+
184
+ await req.auth.changeEmail(newEmail, (token) => {
185
+ confirmationToken = token
186
+ })
187
+
188
+ res.json({ success: true, confirmationToken })
189
+ } catch (error) {
190
+ res.status(400).json({ error: error.message, errorType: error.constructor.name })
191
+ }
192
+ })
193
+
194
+ app.post("/verify-password", async (req, res) => {
195
+ try {
196
+ const { password } = req.body
197
+ const isValid = await req.auth.verifyPassword(password)
198
+ res.json({ success: true, isValid })
199
+ } catch (error) {
200
+ res.status(400).json({ error: error.message, errorType: error.constructor.name })
201
+ }
202
+ })
203
+
204
+ app.post("/logout-everywhere", async (req, res) => {
205
+ try {
206
+ await req.auth.logoutEverywhere()
207
+ res.json({ success: true })
208
+ } catch (error) {
209
+ res.status(500).json({ error: error.message })
210
+ }
211
+ })
212
+
213
+ app.post("/logout-everywhere-else", async (req, res) => {
214
+ try {
215
+ await req.auth.logoutEverywhereElse()
216
+ res.json({ success: true })
217
+ } catch (error) {
218
+ res.status(500).json({ error: error.message })
219
+ }
220
+ })
221
+
222
+ // admin routes
223
+ app.post("/admin/create-user", async (req, res) => {
224
+ try {
225
+ const { email, password, userId, requireConfirmation } = req.body
226
+ let confirmationToken
227
+
228
+ const account = await req.auth.createUser(
229
+ { email, password },
230
+ userId || "test-admin-user-456",
231
+ requireConfirmation
232
+ ? (token) => {
233
+ confirmationToken = token
234
+ }
235
+ : undefined,
236
+ )
237
+
238
+ res.json({
239
+ success: true,
240
+ account: {
241
+ id: account.id,
242
+ email: account.email,
243
+ verified: account.verified,
244
+ },
245
+ confirmationToken,
246
+ })
247
+ } catch (error) {
248
+ res.status(400).json({ error: error.message, errorType: error.constructor.name })
249
+ }
250
+ })
251
+
252
+ app.post("/admin/login-as", async (req, res) => {
253
+ try {
254
+ const identifier = req.body
255
+ await req.auth.loginAsUserBy(identifier)
256
+ res.json({ success: true })
257
+ } catch (error) {
258
+ res.status(400).json({ error: error.message, errorType: error.constructor.name })
259
+ }
260
+ })
261
+
262
+ app.post("/admin/add-role", async (req, res) => {
263
+ try {
264
+ const { identifier, role } = req.body
265
+ await req.auth.addRoleForUserBy(identifier, role)
266
+ res.json({ success: true })
267
+ } catch (error) {
268
+ res.status(400).json({ error: error.message, errorType: error.constructor.name })
269
+ }
270
+ })
271
+
272
+ app.post("/admin/remove-role", async (req, res) => {
273
+ try {
274
+ const { identifier, role } = req.body
275
+ await req.auth.removeRoleForUserBy(identifier, role)
276
+ res.json({ success: true })
277
+ } catch (error) {
278
+ res.status(400).json({ error: error.message, errorType: error.constructor.name })
279
+ }
280
+ })
281
+
282
+ app.post("/admin/has-role", async (req, res) => {
283
+ try {
284
+ const { identifier, role } = req.body
285
+ const hasRole = await req.auth.hasRoleForUserBy(identifier, role)
286
+ res.json({ success: true, hasRole })
287
+ } catch (error) {
288
+ res.status(400).json({ error: error.message, errorType: error.constructor.name })
289
+ }
290
+ })
291
+
292
+ app.post("/admin/change-password", async (req, res) => {
293
+ try {
294
+ const { identifier, password } = req.body
295
+ await req.auth.changePasswordForUserBy(identifier, password)
296
+ res.json({ success: true })
297
+ } catch (error) {
298
+ res.status(400).json({ error: error.message, errorType: error.constructor.name })
299
+ }
300
+ })
301
+
302
+ app.post("/admin/set-status", async (req, res) => {
303
+ try {
304
+ const { identifier, status } = req.body
305
+ await req.auth.setStatusForUserBy(identifier, status)
306
+ res.json({ success: true })
307
+ } catch (error) {
308
+ res.status(400).json({ error: error.message, errorType: error.constructor.name })
309
+ }
310
+ })
311
+
312
+ app.post("/admin/initiate-reset", async (req, res) => {
313
+ try {
314
+ const { identifier, expiresAfter } = req.body
315
+ let resetToken
316
+ await req.auth.initiatePasswordResetForUserBy(identifier, expiresAfter, (token) => {
317
+ resetToken = token
318
+ })
319
+ res.json({ success: true, resetToken })
320
+ } catch (error) {
321
+ res.status(400).json({ error: error.message, errorType: error.constructor.name })
322
+ }
323
+ })
324
+
325
+ app.post("/admin/user-exists", async (req, res) => {
326
+ try {
327
+ const { email } = req.body
328
+ const exists = await req.auth.userExistsByEmail(email)
329
+ res.json({ success: true, exists })
330
+ } catch (error) {
331
+ res.status(400).json({ error: error.message, errorType: error.constructor.name })
332
+ }
333
+ })
334
+
335
+ app.post("/admin/delete-user", async (req, res) => {
336
+ try {
337
+ const identifier = req.body
338
+ await req.auth.deleteUserBy(identifier)
339
+ res.json({ success: true })
340
+ } catch (error) {
341
+ res.status(400).json({ error: error.message, errorType: error.constructor.name })
342
+ }
343
+ })
344
+
345
+ app.post("/admin/force-logout-user", async (req, res) => {
346
+ try {
347
+ const identifier = req.body
348
+ await req.auth.forceLogoutForUserBy(identifier)
349
+ res.json({ success: true })
350
+ } catch (error) {
351
+ res.status(400).json({ error: error.message, errorType: error.constructor.name })
352
+ }
353
+ })
354
+
355
+ // utility routes
356
+ app.post("/set-user-session", (req, res) => {
357
+ req.session.userId = req.body.userId || "test-user-123"
358
+ req.session.save((err) => {
359
+ if (err) {
360
+ return res.status(500).json({ error: "Failed to save session" })
361
+ }
362
+ res.json({ success: true })
363
+ })
364
+ })
365
+
366
+ app.get("/session-info", (req, res) => {
367
+ res.json({
368
+ sessionId: req.sessionID,
369
+ userId: req.session.userId,
370
+ auth: req.session.auth || null,
371
+ })
372
+ })
373
+
374
+ return {
375
+ app,
376
+ pool,
377
+ authConfig,
378
+ cleanup: async () => {
379
+ await closeInvalidationListeners()
380
+ await pool.end()
381
+ },
382
+ }
383
+ }
384
+
385
+ export { AuthRole }