@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,136 @@
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, 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
+ 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
+ return pool
30
+ }
31
+
32
+ export async function createOAuthTestApp() {
33
+ const pool = await createTestDatabase()
34
+
35
+ const app = express()
36
+
37
+ app.use(express.json())
38
+ app.use(cookieParser())
39
+ app.use(
40
+ session({
41
+ secret: "oauth-test-secret-key-for-testing-only",
42
+ resave: false,
43
+ saveUninitialized: false,
44
+ cookie: { secure: false, httpOnly: true },
45
+ }),
46
+ )
47
+
48
+ const authConfig = {
49
+ db: pool,
50
+ createUser: async (userData) => {
51
+ // simulate creating user in app's user table
52
+ return `oauth-user-${userData.id}`
53
+ },
54
+ tablePrefix: "oauth_test_",
55
+ minPasswordLength: 6,
56
+ maxPasswordLength: 50,
57
+ rememberDuration: "7d",
58
+ rememberCookieName: "oauth_test_remember_token",
59
+ resyncInterval: "30s",
60
+ }
61
+
62
+ await dropAuthTables(authConfig)
63
+ await createAuthTables(authConfig)
64
+
65
+ app.use(createAuthMiddleware(authConfig))
66
+
67
+ // standard auth routes for testing
68
+ app.post("/register", async (req, res) => {
69
+ try {
70
+ const { email, password, requireConfirmation } = req.body
71
+ let confirmationToken
72
+
73
+ const account = await req.auth.register(
74
+ email,
75
+ password,
76
+ "oauth-test-user-123",
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
+ },
92
+ confirmationToken,
93
+ })
94
+ } catch (error) {
95
+ res.status(400).json({ error: error.message })
96
+ }
97
+ })
98
+
99
+ app.get("/profile", async (req, res) => {
100
+ if (!req.auth.isLoggedIn()) {
101
+ return res.status(401).json({ error: "Not logged in" })
102
+ }
103
+
104
+ res.json({
105
+ id: req.auth.getId(),
106
+ email: req.auth.getEmail(),
107
+ status: req.auth.getStatus(),
108
+ statusName: req.auth.getStatusName(),
109
+ verified: req.auth.getVerified(),
110
+ roles: req.auth.getRoleNames(),
111
+ remembered: req.auth.isRemembered(),
112
+ isAdmin: await req.auth.isAdmin(),
113
+ })
114
+ })
115
+
116
+ // utility routes for OAuth tests
117
+ app.post("/set-user-session", (req, res) => {
118
+ req.session.userId = req.body.userId || "oauth-test-user-123"
119
+ req.session.save((err) => {
120
+ if (err) {
121
+ return res.status(500).json({ error: "Failed to save session" })
122
+ }
123
+ res.json({ success: true })
124
+ })
125
+ })
126
+
127
+ return {
128
+ app,
129
+ pool,
130
+ authConfig,
131
+ cleanup: async () => {
132
+ await closeInvalidationListeners()
133
+ await pool.end()
134
+ },
135
+ }
136
+ }
@@ -0,0 +1,400 @@
1
+ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"
2
+ import request from "supertest"
3
+ import { createOAuthTestApp } from "./oauth-test-setup.js"
4
+
5
+ // mock fetch for OAuth calls
6
+ const mockFetch = vi.fn()
7
+ global.fetch = mockFetch
8
+
9
+ describe("OAuth Integration Tests", () => {
10
+ let app
11
+ let pool
12
+ let cleanup
13
+ let userIdCounter = 1
14
+
15
+ // mock user database (simulates the app's user table)
16
+ const mockUsers = []
17
+
18
+ beforeAll(async () => {
19
+ const testApp = await createOAuthTestApp()
20
+ app = testApp.app
21
+ pool = testApp.pool
22
+ cleanup = testApp.cleanup
23
+
24
+ // configure OAuth providers and createUser function
25
+ const originalConfig = testApp.authConfig
26
+ originalConfig.providers = {
27
+ github: {
28
+ clientId: "test-github-client-id",
29
+ clientSecret: "test-github-client-secret",
30
+ redirectUri: "http://localhost:3000/auth/github/callback",
31
+ },
32
+ google: {
33
+ clientId: "test-google-client-id",
34
+ clientSecret: "test-google-client-secret",
35
+ redirectUri: "http://localhost:3000/auth/google/callback",
36
+ },
37
+ }
38
+
39
+ originalConfig.createUser = async (userData) => {
40
+ const userId = userIdCounter++
41
+ mockUsers.push({
42
+ id: userId,
43
+ name: userData.name || userData.username,
44
+ email: userData.email,
45
+ })
46
+ return userId.toString()
47
+ }
48
+
49
+ app.get("/auth/github", (req, res) => {
50
+ if (!req.auth.providers.github) {
51
+ return res.status(400).json({ error: "GitHub provider not configured" })
52
+ }
53
+ const authUrl = req.auth.providers.github.getAuthUrl()
54
+ res.json({ authUrl })
55
+ })
56
+
57
+ app.get("/auth/github/callback", async (req, res) => {
58
+ try {
59
+ if (!req.auth.providers.github) {
60
+ return res.status(400).json({ error: "GitHub provider not configured" })
61
+ }
62
+ await req.auth.providers.github.handleCallback(req)
63
+ res.json({ success: true })
64
+ } catch (error) {
65
+ res.status(400).json({ error: error.message })
66
+ }
67
+ })
68
+
69
+ app.get("/auth/google", (req, res) => {
70
+ if (!req.auth.providers.google) {
71
+ return res.status(400).json({ error: "Google provider not configured" })
72
+ }
73
+ const authUrl = req.auth.providers.google.getAuthUrl()
74
+ res.json({ authUrl })
75
+ })
76
+
77
+ app.get("/auth/google/callback", async (req, res) => {
78
+ try {
79
+ if (!req.auth.providers.google) {
80
+ return res.status(400).json({ error: "Google provider not configured" })
81
+ }
82
+ await req.auth.providers.google.handleCallback(req)
83
+ res.json({ success: true })
84
+ } catch (error) {
85
+ res.status(400).json({ error: error.message })
86
+ }
87
+ })
88
+ })
89
+
90
+ afterAll(async () => {
91
+ await cleanup()
92
+ })
93
+
94
+ beforeEach(async () => {
95
+ await pool.query("DELETE FROM oauth_test_providers")
96
+ await pool.query("DELETE FROM oauth_test_resets")
97
+ await pool.query("DELETE FROM oauth_test_remembers")
98
+ await pool.query("DELETE FROM oauth_test_confirmations")
99
+ await pool.query("DELETE FROM oauth_test_accounts")
100
+ mockUsers.length = 0
101
+ userIdCounter = 1
102
+ vi.clearAllMocks()
103
+ })
104
+
105
+ describe("GitHub OAuth", () => {
106
+ it("should generate GitHub auth URL", async () => {
107
+ const response = await request(app).get("/auth/github").expect(200)
108
+
109
+ expect(response.body.authUrl).toMatch(/^https:\/\/github\.com\/login\/oauth\/authorize/)
110
+ expect(response.body.authUrl).toContain("client_id=test-github-client-id")
111
+ expect(response.body.authUrl).toContain("scope=user%3Aemail")
112
+ })
113
+
114
+ it("should handle GitHub OAuth callback for new user", async () => {
115
+ mockFetch.mockResolvedValueOnce({
116
+ ok: true,
117
+ json: () =>
118
+ Promise.resolve({
119
+ access_token: "github-access-token",
120
+ }),
121
+ })
122
+
123
+ mockFetch
124
+ .mockResolvedValueOnce({
125
+ ok: true,
126
+ json: () =>
127
+ Promise.resolve({
128
+ id: 12345,
129
+ login: "testuser",
130
+ name: "Test User",
131
+ avatar_url: "https://github.com/avatar.jpg",
132
+ }),
133
+ })
134
+ .mockResolvedValueOnce({
135
+ ok: true,
136
+ json: () =>
137
+ Promise.resolve([
138
+ {
139
+ email: "testuser@example.com",
140
+ primary: true,
141
+ verified: true,
142
+ },
143
+ ]),
144
+ })
145
+
146
+ const agent = request.agent(app)
147
+
148
+ const response = await agent.get("/auth/github/callback").query({ code: "github-auth-code" }).expect(200)
149
+
150
+ expect(response.body.success).toBe(true)
151
+
152
+ expect(mockUsers).toHaveLength(1)
153
+ expect(mockUsers[0]).toEqual({
154
+ id: 1,
155
+ name: "Test User",
156
+ email: "testuser@example.com",
157
+ })
158
+
159
+ const accounts = await pool.query("SELECT * FROM oauth_test_accounts")
160
+ expect(accounts.rows).toHaveLength(1)
161
+ expect(accounts.rows[0].email).toBe("testuser@example.com")
162
+ expect(accounts.rows[0].password).toBeNull()
163
+ expect(accounts.rows[0].verified).toBe(true)
164
+
165
+ const providers = await pool.query("SELECT * FROM oauth_test_providers")
166
+ expect(providers.rows).toHaveLength(1)
167
+ expect(providers.rows[0].provider).toBe("github")
168
+ expect(providers.rows[0].provider_id).toBe("12345")
169
+ expect(providers.rows[0].provider_email).toBe("testuser@example.com")
170
+ expect(providers.rows[0].provider_username).toBe("testuser")
171
+
172
+ const profileResponse = await agent.get("/profile").expect(200)
173
+ expect(profileResponse.body.email).toBe("testuser@example.com")
174
+ })
175
+
176
+ it("should handle GitHub OAuth callback for existing OAuth user", async () => {
177
+ const agent = request.agent(app)
178
+ await agent.post("/set-user-session").send({ userId: "existing-user" })
179
+
180
+ const existingUser = {
181
+ id: 999,
182
+ name: "Existing User",
183
+ email: "existing@example.com",
184
+ }
185
+ mockUsers.push(existingUser)
186
+
187
+ const account = await pool.query(
188
+ `INSERT INTO oauth_test_accounts (user_id, email, password, verified, status, rolemask)
189
+ VALUES ($1, $2, NULL, true, 0, 0) RETURNING *`,
190
+ [existingUser.id, existingUser.email],
191
+ )
192
+
193
+ await pool.query(
194
+ `INSERT INTO oauth_test_providers (account_id, provider, provider_id, provider_email, provider_username)
195
+ VALUES ($1, 'github', '12345', $2, 'existinguser')`,
196
+ [account.rows[0].id, existingUser.email],
197
+ )
198
+
199
+ mockFetch.mockResolvedValueOnce({
200
+ ok: true,
201
+ json: () => Promise.resolve({ access_token: "github-access-token" }),
202
+ })
203
+
204
+ mockFetch
205
+ .mockResolvedValueOnce({
206
+ ok: true,
207
+ json: () =>
208
+ Promise.resolve({
209
+ id: 12345,
210
+ login: "existinguser",
211
+ name: "Existing User",
212
+ avatar_url: "https://github.com/avatar.jpg",
213
+ }),
214
+ })
215
+ .mockResolvedValueOnce({
216
+ ok: true,
217
+ json: () => Promise.resolve([{ email: "existing@example.com", primary: true, verified: true }]),
218
+ })
219
+
220
+ const response = await agent.get("/auth/github/callback").query({ code: "github-auth-code" }).expect(200)
221
+
222
+ expect(response.body.success).toBe(true)
223
+
224
+ expect(mockUsers).toHaveLength(1)
225
+
226
+ const profileResponse = await agent.get("/profile").expect(200)
227
+ expect(profileResponse.body.email).toBe("existing@example.com")
228
+ })
229
+
230
+ it("should reject GitHub OAuth when email already exists with different provider", async () => {
231
+ const agent = request.agent(app)
232
+ await agent.post("/set-user-session").send({ userId: "test-user-123" })
233
+
234
+ await agent.post("/register").send({
235
+ email: "conflict@example.com",
236
+ password: "password123",
237
+ })
238
+
239
+ mockFetch.mockResolvedValueOnce({
240
+ ok: true,
241
+ json: () => Promise.resolve({ access_token: "github-access-token" }),
242
+ })
243
+
244
+ mockFetch
245
+ .mockResolvedValueOnce({
246
+ ok: true,
247
+ json: () =>
248
+ Promise.resolve({
249
+ id: 54321,
250
+ login: "conflictuser",
251
+ name: "Conflict User",
252
+ avatar_url: "https://github.com/avatar.jpg",
253
+ }),
254
+ })
255
+ .mockResolvedValueOnce({
256
+ ok: true,
257
+ json: () => Promise.resolve([{ email: "conflict@example.com", primary: true, verified: true }]),
258
+ })
259
+
260
+ const response = await agent.get("/auth/github/callback").query({ code: "github-auth-code" }).expect(400)
261
+
262
+ expect(response.body.error).toContain("already have an account")
263
+ })
264
+
265
+ it("should handle GitHub API errors gracefully", async () => {
266
+ mockFetch.mockResolvedValueOnce({
267
+ ok: false,
268
+ status: 400,
269
+ statusText: "Bad Request",
270
+ })
271
+
272
+ const response = await request(app).get("/auth/github/callback").query({ code: "invalid-code" }).expect(400)
273
+
274
+ expect(response.body.error).toContain("OAuth token exchange failed")
275
+ })
276
+ })
277
+
278
+ describe("Google OAuth", () => {
279
+ it("should generate Google auth URL", async () => {
280
+ const response = await request(app).get("/auth/google").expect(200)
281
+
282
+ expect(response.body.authUrl).toMatch(/^https:\/\/accounts\.google\.com\/o\/oauth2\/v2\/auth/)
283
+ expect(response.body.authUrl).toContain("client_id=test-google-client-id")
284
+ expect(response.body.authUrl).toContain("scope=openid+profile+email")
285
+ })
286
+
287
+ it("should handle Google OAuth callback for new user", async () => {
288
+ mockFetch.mockResolvedValueOnce({
289
+ ok: true,
290
+ json: () =>
291
+ Promise.resolve({
292
+ access_token: "google-access-token",
293
+ }),
294
+ })
295
+
296
+ mockFetch.mockResolvedValueOnce({
297
+ ok: true,
298
+ json: () =>
299
+ Promise.resolve({
300
+ id: "google-user-123",
301
+ email: "googleuser@example.com",
302
+ name: "Google User",
303
+ picture: "https://google.com/avatar.jpg",
304
+ }),
305
+ })
306
+
307
+ const agent = request.agent(app)
308
+
309
+ const response = await agent.get("/auth/google/callback").query({ code: "google-auth-code" }).expect(200)
310
+
311
+ expect(response.body.success).toBe(true)
312
+
313
+ expect(mockUsers).toHaveLength(1)
314
+ expect(mockUsers[0].email).toBe("googleuser@example.com")
315
+
316
+ const providers = await pool.query("SELECT * FROM oauth_test_providers")
317
+ expect(providers.rows).toHaveLength(1)
318
+ expect(providers.rows[0].provider).toBe("google")
319
+ expect(providers.rows[0].provider_id).toBe("google-user-123")
320
+ })
321
+ })
322
+
323
+ describe("OAuth Error Handling", () => {
324
+ it("should handle missing authorization code", async () => {
325
+ const response = await request(app).get("/auth/github/callback").expect(400)
326
+
327
+ expect(response.body.error).toContain("No authorization code provided")
328
+ })
329
+
330
+ it("should handle missing createUser function by auto-generating UUID", async () => {
331
+ const testAppWithoutCreateUser = await createOAuthTestApp()
332
+ const appWithoutCreateUser = testAppWithoutCreateUser.app
333
+
334
+ const configWithoutCreateUser = testAppWithoutCreateUser.authConfig
335
+ // Remove createUser to test auto-UUID generation
336
+ delete configWithoutCreateUser.createUser
337
+ configWithoutCreateUser.providers = {
338
+ github: {
339
+ clientId: "test-github-client-id",
340
+ clientSecret: "test-github-client-secret",
341
+ redirectUri: "http://localhost:3000/auth/github/callback",
342
+ },
343
+ }
344
+
345
+ appWithoutCreateUser.get("/auth/github/callback", async (req, res) => {
346
+ try {
347
+ if (!req.auth.providers.github) {
348
+ return res.status(400).json({ error: "GitHub provider not configured" })
349
+ }
350
+ await req.auth.providers.github.handleCallback(req)
351
+ res.json({ success: true })
352
+ } catch (error) {
353
+ res.status(400).json({ error: error.message })
354
+ }
355
+ })
356
+
357
+ mockFetch.mockResolvedValueOnce({
358
+ ok: true,
359
+ json: () => Promise.resolve({ access_token: "github-access-token" }),
360
+ })
361
+
362
+ mockFetch
363
+ .mockResolvedValueOnce({
364
+ ok: true,
365
+ json: () =>
366
+ Promise.resolve({
367
+ id: 99999,
368
+ login: "newuser",
369
+ name: "New User",
370
+ }),
371
+ })
372
+ .mockResolvedValueOnce({
373
+ ok: true,
374
+ json: () => Promise.resolve([{ email: "newuser@example.com", primary: true, verified: true }]),
375
+ })
376
+
377
+ const response = await request(appWithoutCreateUser).get("/auth/github/callback").query({ code: "github-auth-code" }).expect(200)
378
+
379
+ expect(response.body.success).toBe(true)
380
+
381
+ await testAppWithoutCreateUser.cleanup()
382
+ })
383
+ })
384
+
385
+ describe("Provider Integration", () => {
386
+ it("should have providers available on auth object", async () => {
387
+ const agent = request.agent(app)
388
+
389
+ // this is tested indirectly by the successful OAuth flows above,
390
+ // but we can verify the providers are initialized
391
+ const githubResponse = await agent.get("/auth/github")
392
+ expect(githubResponse.status).toBe(200)
393
+ expect(githubResponse.body.authUrl).toBeDefined()
394
+
395
+ const googleResponse = await agent.get("/auth/google")
396
+ expect(googleResponse.status).toBe(200)
397
+ expect(googleResponse.body.authUrl).toBeDefined()
398
+ })
399
+ })
400
+ })