@furystack/rest-service 11.0.7 → 12.1.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 (104) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +3 -2
  3. package/esm/actions/index.d.ts +1 -0
  4. package/esm/actions/index.d.ts.map +1 -1
  5. package/esm/actions/index.js +1 -0
  6. package/esm/actions/index.js.map +1 -1
  7. package/esm/actions/login.d.ts +7 -3
  8. package/esm/actions/login.d.ts.map +1 -1
  9. package/esm/actions/login.js +11 -5
  10. package/esm/actions/login.js.map +1 -1
  11. package/esm/actions/password-login-action.d.ts +24 -0
  12. package/esm/actions/password-login-action.d.ts.map +1 -0
  13. package/esm/actions/password-login-action.js +31 -0
  14. package/esm/actions/password-login-action.js.map +1 -0
  15. package/esm/actions/password-login-action.spec.d.ts +2 -0
  16. package/esm/actions/password-login-action.spec.d.ts.map +1 -0
  17. package/esm/actions/password-login-action.spec.js +105 -0
  18. package/esm/actions/password-login-action.spec.js.map +1 -0
  19. package/esm/authenticate.d.ts.map +1 -1
  20. package/esm/authenticate.js +4 -1
  21. package/esm/authenticate.js.map +1 -1
  22. package/esm/authenticate.spec.js +6 -4
  23. package/esm/authenticate.spec.js.map +1 -1
  24. package/esm/authentication-providers/authentication-provider.d.ts +25 -0
  25. package/esm/authentication-providers/authentication-provider.d.ts.map +1 -0
  26. package/esm/authentication-providers/authentication-provider.js +2 -0
  27. package/esm/authentication-providers/authentication-provider.js.map +1 -0
  28. package/esm/authentication-providers/basic-auth-provider.d.ts +8 -0
  29. package/esm/authentication-providers/basic-auth-provider.d.ts.map +1 -0
  30. package/esm/authentication-providers/basic-auth-provider.js +18 -0
  31. package/esm/authentication-providers/basic-auth-provider.js.map +1 -0
  32. package/esm/authentication-providers/cookie-auth-provider.d.ts +11 -0
  33. package/esm/authentication-providers/cookie-auth-provider.d.ts.map +1 -0
  34. package/esm/authentication-providers/cookie-auth-provider.js +21 -0
  35. package/esm/authentication-providers/cookie-auth-provider.js.map +1 -0
  36. package/esm/authentication-providers/helpers.d.ts +11 -0
  37. package/esm/authentication-providers/helpers.d.ts.map +1 -0
  38. package/esm/authentication-providers/helpers.js +47 -0
  39. package/esm/authentication-providers/helpers.js.map +1 -0
  40. package/esm/authentication-providers/index.d.ts +5 -0
  41. package/esm/authentication-providers/index.d.ts.map +1 -0
  42. package/esm/authentication-providers/index.js +5 -0
  43. package/esm/authentication-providers/index.js.map +1 -0
  44. package/esm/endpoint-generators/utils.d.ts.map +1 -1
  45. package/esm/endpoint-generators/utils.js +4 -1
  46. package/esm/endpoint-generators/utils.js.map +1 -1
  47. package/esm/helpers.d.ts +5 -2
  48. package/esm/helpers.d.ts.map +1 -1
  49. package/esm/helpers.js +27 -3
  50. package/esm/helpers.js.map +1 -1
  51. package/esm/helpers.spec.js +37 -0
  52. package/esm/helpers.spec.js.map +1 -1
  53. package/esm/http-authentication-settings.d.ts +11 -4
  54. package/esm/http-authentication-settings.d.ts.map +1 -1
  55. package/esm/http-authentication-settings.js +9 -2
  56. package/esm/http-authentication-settings.js.map +1 -1
  57. package/esm/http-user-context.d.ts +9 -4
  58. package/esm/http-user-context.d.ts.map +1 -1
  59. package/esm/http-user-context.js +28 -55
  60. package/esm/http-user-context.js.map +1 -1
  61. package/esm/http-user-context.spec.d.ts +3 -1
  62. package/esm/http-user-context.spec.d.ts.map +1 -1
  63. package/esm/http-user-context.spec.js +103 -45
  64. package/esm/http-user-context.spec.js.map +1 -1
  65. package/esm/index.d.ts +2 -0
  66. package/esm/index.d.ts.map +1 -1
  67. package/esm/index.js +2 -0
  68. package/esm/index.js.map +1 -1
  69. package/esm/login-response-strategy.d.ts +28 -0
  70. package/esm/login-response-strategy.d.ts.map +1 -0
  71. package/esm/login-response-strategy.js +28 -0
  72. package/esm/login-response-strategy.js.map +1 -0
  73. package/esm/login-response-strategy.spec.d.ts +2 -0
  74. package/esm/login-response-strategy.spec.d.ts.map +1 -0
  75. package/esm/login-response-strategy.spec.js +78 -0
  76. package/esm/login-response-strategy.spec.js.map +1 -0
  77. package/esm/rest-service.integration.spec.d.ts.map +1 -1
  78. package/esm/rest-service.integration.spec.js +5 -4
  79. package/esm/rest-service.integration.spec.js.map +1 -1
  80. package/esm/validate.integration.spec.js +5 -0
  81. package/esm/validate.integration.spec.js.map +1 -1
  82. package/package.json +7 -7
  83. package/src/actions/index.ts +1 -0
  84. package/src/actions/login.ts +12 -6
  85. package/src/actions/password-login-action.spec.ts +122 -0
  86. package/src/actions/password-login-action.ts +35 -0
  87. package/src/authenticate.spec.ts +6 -4
  88. package/src/authenticate.ts +4 -1
  89. package/src/authentication-providers/authentication-provider.ts +25 -0
  90. package/src/authentication-providers/basic-auth-provider.ts +21 -0
  91. package/src/authentication-providers/cookie-auth-provider.ts +26 -0
  92. package/src/authentication-providers/helpers.ts +73 -0
  93. package/src/authentication-providers/index.ts +4 -0
  94. package/src/endpoint-generators/utils.ts +4 -1
  95. package/src/helpers.spec.ts +40 -0
  96. package/src/helpers.ts +48 -3
  97. package/src/http-authentication-settings.ts +30 -21
  98. package/src/http-user-context.spec.ts +462 -394
  99. package/src/http-user-context.ts +164 -194
  100. package/src/index.ts +2 -0
  101. package/src/login-response-strategy.spec.ts +90 -0
  102. package/src/login-response-strategy.ts +48 -0
  103. package/src/rest-service.integration.spec.ts +5 -4
  104. package/src/validate.integration.spec.ts +5 -0
@@ -1,394 +1,462 @@
1
- /* eslint-disable @typescript-eslint/ban-ts-comment */
2
- import { InMemoryStore, StoreManager, User, addStore } from '@furystack/core'
3
- import { Injector } from '@furystack/inject'
4
- import { PasswordAuthenticator, PasswordCredential, UnauthenticatedError } from '@furystack/security'
5
- import { usingAsync } from '@furystack/utils'
6
- import type { IncomingMessage, ServerResponse } from 'http'
7
- import { describe, expect, it, vi } from 'vitest'
8
- import { useHttpAuthentication } from './helpers.js'
9
- import { HttpUserContext } from './http-user-context.js'
10
- import { DefaultSession } from './models/default-session.js'
11
-
12
- export const prepareInjector = async (i: Injector) => {
13
- addStore(i, new InMemoryStore({ model: User, primaryKey: 'username' }))
14
- .addStore(new InMemoryStore({ model: DefaultSession, primaryKey: 'sessionId' }))
15
- .addStore(new InMemoryStore({ model: PasswordCredential, primaryKey: 'userName' }))
16
-
17
- useHttpAuthentication(i)
18
- }
19
-
20
- const setupUser = async (i: Injector, userName: string, password: string) => {
21
- const sm = i.getInstance(StoreManager)
22
- const pw = i.getInstance(PasswordAuthenticator)
23
- const cred = await pw.hasher.createCredential(userName, password)
24
- await sm.getStoreFor(PasswordCredential, 'userName').add(cred)
25
- await sm.getStoreFor(User, 'username').add({ username: userName, roles: [] })
26
- }
27
-
28
- describe('HttpUserContext', () => {
29
- const request = { headers: {} } as IncomingMessage
30
- const response = {} as any as ServerResponse
31
-
32
- const testUser: User = { username: 'testUser', roles: ['grantedRole1', 'grantedRole2'] }
33
-
34
- it('Should be constructed with the extension method', async () => {
35
- await usingAsync(new Injector(), async (i) => {
36
- await prepareInjector(i)
37
- const ctx = i.getInstance(HttpUserContext)
38
- expect(ctx).toBeInstanceOf(HttpUserContext)
39
- })
40
- })
41
-
42
- describe('isAuthenticated', () => {
43
- it('Should return true for authenticated users', async () => {
44
- await usingAsync(new Injector(), async (i) => {
45
- await prepareInjector(i)
46
- const ctx = i.getInstance(HttpUserContext)
47
- ctx.getCurrentUser = vi.fn(async () => testUser)
48
- const value = await ctx.isAuthenticated(request)
49
- expect(value).toBe(true)
50
- expect(ctx.getCurrentUser).toBeCalled()
51
- })
52
- })
53
-
54
- it('Should return false for unauthenticated users', async () => {
55
- await usingAsync(new Injector(), async (i) => {
56
- await prepareInjector(i)
57
- const ctx = i.getInstance(HttpUserContext)
58
- ctx.getCurrentUser = vi.fn(async () => {
59
- throw Error(':(')
60
- })
61
- await expect(ctx.isAuthenticated(request)).resolves.toEqual(false)
62
- expect(ctx.getCurrentUser).toBeCalled()
63
- })
64
- })
65
- })
66
-
67
- describe('isAuthorized', () => {
68
- it('Should return true if all roles are authorized', async () => {
69
- await usingAsync(new Injector(), async (i) => {
70
- await prepareInjector(i)
71
- const ctx = i.getInstance(HttpUserContext)
72
- ctx.getCurrentUser = vi.fn(async () => testUser)
73
- const value = await ctx.isAuthorized(request, 'grantedRole1', 'grantedRole2')
74
- expect(value).toBe(true)
75
- expect(ctx.getCurrentUser).toBeCalled()
76
- })
77
- })
78
-
79
- it('Should return false if not all roles are authorized', async () => {
80
- await usingAsync(new Injector(), async (i) => {
81
- await prepareInjector(i)
82
- const ctx = i.getInstance(HttpUserContext)
83
- ctx.getCurrentUser = vi.fn(async () => testUser)
84
- const value = await ctx.isAuthorized(request, 'grantedRole1', 'nonGrantedRole2')
85
- expect(value).toBe(false)
86
- expect(ctx.getCurrentUser).toBeCalled()
87
- })
88
- })
89
- })
90
-
91
- describe('authenticateUser', () => {
92
- it('Should fail when the store is empty', async () => {
93
- await usingAsync(new Injector(), async (i) => {
94
- await prepareInjector(i)
95
- const ctx = i.getInstance(HttpUserContext)
96
- await expect(ctx.authenticateUser('user', 'password')).rejects.toThrowError(UnauthenticatedError)
97
- })
98
- })
99
-
100
- it('Should fail when the password not equals', async () => {
101
- await usingAsync(new Injector(), async (i) => {
102
- await prepareInjector(i)
103
- await setupUser(i, 'user', 'pass123')
104
- await expect(i.getInstance(HttpUserContext).authenticateUser('user', 'pass321')).rejects.toThrowError(
105
- UnauthenticatedError,
106
- )
107
- })
108
- })
109
-
110
- it('Should fail when the username not equals', async () => {
111
- await usingAsync(new Injector(), async (i) => {
112
- await prepareInjector(i)
113
- await setupUser(i, 'otherUser', 'pass123')
114
- await expect(i.getInstance(HttpUserContext).authenticateUser('user', 'pass123')).rejects.toThrowError(
115
- UnauthenticatedError,
116
- )
117
- })
118
- })
119
-
120
- it('Should fail when password not provided', async () => {
121
- await usingAsync(new Injector(), async (i) => {
122
- await prepareInjector(i)
123
- await setupUser(i, 'user', 'pass123')
124
- await expect(i.getInstance(HttpUserContext).authenticateUser('user', '')).rejects.toThrowError(
125
- UnauthenticatedError,
126
- )
127
- })
128
- })
129
-
130
- it('Should fail when the user is not in the user store', async () => {
131
- await usingAsync(new Injector(), async (i) => {
132
- await prepareInjector(i)
133
- await setupUser(i, 'user', 'pass123')
134
- await i.getInstance(StoreManager).getStoreFor(User, 'username').remove('user')
135
- await expect(i.getInstance(HttpUserContext).authenticateUser('user', 'pass123')).rejects.toThrowError(
136
- UnauthenticatedError,
137
- )
138
- })
139
- })
140
-
141
- it('Should return the user when the username and password matches', async () => {
142
- await usingAsync(new Injector(), async (i) => {
143
- await prepareInjector(i)
144
- await setupUser(i, 'user', 'pass123')
145
- const ctx = i.getInstance(HttpUserContext)
146
- const value = await ctx.authenticateUser('user', 'pass123')
147
- expect(value).toEqual({ username: 'user', roles: [] })
148
- })
149
- })
150
- })
151
-
152
- describe('getSessionIdFromRequest', () => {
153
- it('Should return null if no headers present', async () => {
154
- await usingAsync(new Injector(), async (i) => {
155
- await prepareInjector(i)
156
- const ctx = i.getInstance(HttpUserContext)
157
- const sid = ctx.getSessionIdFromRequest(request)
158
- expect(sid).toBeNull()
159
- })
160
- })
161
-
162
- it('Should return null if no session ID cookie present', async () => {
163
- await usingAsync(new Injector(), async (i) => {
164
- await prepareInjector(i)
165
- const requestWithCookie = { ...request, cookie: 'a=2;b=3;c=4;' } as unknown as IncomingMessage
166
- const ctx = i.getInstance(HttpUserContext)
167
- const sid = ctx.getSessionIdFromRequest(requestWithCookie)
168
- expect(sid).toBeNull()
169
- })
170
- })
171
- it('Should return the Session ID value if session ID cookie present', async () => {
172
- await usingAsync(new Injector(), async (i) => {
173
- await prepareInjector(i)
174
- const ctx = i.getInstance(HttpUserContext)
175
- const requestWithAuthCookie = {
176
- ...request,
177
- headers: { cookie: `a=2;b=3;${ctx.authentication.cookieName}=666;c=4;` },
178
- } as unknown as IncomingMessage
179
-
180
- const sid = ctx.getSessionIdFromRequest(requestWithAuthCookie)
181
- expect(sid).toBe('666')
182
- })
183
- })
184
- })
185
-
186
- describe('authenticateRequest', () => {
187
- it('Should try to authenticate with Basic, if enabled', async () => {
188
- await usingAsync(new Injector(), async (i) => {
189
- await prepareInjector(i)
190
- const ctx = i.getInstance(HttpUserContext)
191
- ctx.authenticateUser = vi.fn(async () => testUser)
192
- const result = await ctx.authenticateRequest({
193
- headers: { authorization: `Basic dGVzdHVzZXI6cGFzc3dvcmQ=` },
194
- } as IncomingMessage)
195
- expect(ctx.authenticateUser).toBeCalledWith('testuser', 'password')
196
- expect(result).toBe(testUser)
197
- })
198
- })
199
-
200
- it('Should NOT try to authenticate with Basic, if disabled', async () => {
201
- await usingAsync(new Injector(), async (i) => {
202
- await prepareInjector(i)
203
- const ctx = i.getInstance(HttpUserContext)
204
- ctx.authentication.enableBasicAuth = false
205
- ctx.authenticateUser = vi.fn(async () => testUser)
206
- await expect(
207
- ctx.authenticateRequest({
208
- headers: { authorization: `Basic dGVzdHVzZXI6cGFzc3dvcmQ=` },
209
- } as IncomingMessage),
210
- ).rejects.toThrowError(UnauthenticatedError)
211
- expect(ctx.authenticateUser).not.toBeCalled()
212
- })
213
- })
214
-
215
- it('Should fail with no session in the store', async () => {
216
- await usingAsync(new Injector(), async (i) => {
217
- await prepareInjector(i)
218
- const ctx = i.getInstance(HttpUserContext)
219
- await expect(
220
- ctx.authenticateRequest({
221
- headers: { cookie: `${ctx.authentication.cookieName}=666;a=3` },
222
- } as IncomingMessage),
223
- ).rejects.toThrowError(UnauthenticatedError)
224
- })
225
- })
226
-
227
- it('Should fail with valid session Id but no user', async () => {
228
- await usingAsync(new Injector(), async (i) => {
229
- await prepareInjector(i)
230
- const ctx = i.getInstance(HttpUserContext)
231
- await ctx.authentication
232
- .getSessionStore(i.getInstance(StoreManager))
233
- .add({ sessionId: '666', username: testUser.username })
234
- await expect(
235
- ctx.authenticateRequest({
236
- headers: { cookie: `${ctx.authentication.cookieName}=666;a=3` },
237
- } as IncomingMessage),
238
- ).rejects.toThrowError(UnauthenticatedError)
239
- })
240
- })
241
-
242
- it('Should authenticate with cookie, if the session IDs matches', async () => {
243
- await usingAsync(new Injector(), async (i) => {
244
- await prepareInjector(i)
245
-
246
- const ctx = i.getInstance(HttpUserContext)
247
- await ctx.authentication
248
- .getSessionStore(i.getInstance(StoreManager))
249
- .add({ sessionId: '666', username: testUser.username })
250
-
251
- await ctx.authentication.getUserStore(i.getInstance(StoreManager)).add({ ...testUser })
252
-
253
- const result = await ctx.authenticateRequest({
254
- headers: { cookie: `${ctx.authentication.cookieName}=666;a=3` },
255
- } as IncomingMessage)
256
-
257
- expect(result).toEqual(testUser)
258
- })
259
- })
260
- })
261
-
262
- describe('getCurrentUser', () => {
263
- it('Should return the current user from authenticateRequest() once per request', async () => {
264
- await usingAsync(new Injector(), async (i) => {
265
- await prepareInjector(i)
266
- const ctx = i.getInstance(HttpUserContext)
267
- ctx.authenticateRequest = vi.fn(async () => testUser)
268
- const result = await ctx.getCurrentUser(request)
269
- const result2 = await ctx.getCurrentUser(request)
270
- expect(ctx.authenticateRequest).toBeCalledTimes(1)
271
- expect(result).toBe(testUser)
272
- expect(result2).toBe(testUser)
273
- })
274
- })
275
- })
276
-
277
- describe('cookieLogin', () => {
278
- it('Should return the current user from authenticateRequest() once per request', async () => {
279
- await usingAsync(new Injector(), async (i) => {
280
- await prepareInjector(i)
281
- const ctx = i.getInstance(HttpUserContext)
282
- const setHeader = vi.fn()
283
- // @ts-expect-error
284
- ctx.getSessionStore().add = vi.fn(async () => {
285
- return {}
286
- })
287
- const authResult = await ctx.cookieLogin(testUser, { setHeader })
288
- expect(authResult).toBe(testUser)
289
- expect(setHeader).toBeCalled()
290
- expect(ctx.getSessionStore().add).toBeCalled()
291
- })
292
- })
293
- })
294
-
295
- describe('cookieLogout', () => {
296
- it('Should invalidate the current session id cookie', async () => {
297
- await usingAsync(new Injector(), async (i) => {
298
- await prepareInjector(i)
299
- const ctx = i.getInstance(HttpUserContext)
300
- const setHeader = vi.fn()
301
- // @ts-expect-error
302
- ctx.getSessionStore().add = vi.fn(async () => {
303
- return {}
304
- })
305
- ctx.authenticateRequest = vi.fn(async () => testUser)
306
- ctx.getSessionStore().remove = vi.fn(async () => undefined)
307
- ctx.getSessionIdFromRequest = () => 'example-session-id'
308
- response.setHeader = vi.fn(() => response)
309
- await ctx.cookieLogin(testUser, { setHeader })
310
- await ctx.cookieLogout(request, response)
311
- expect(response.setHeader).toBeCalledWith('Set-Cookie', 'fss=; Path=/; HttpOnly')
312
- expect(ctx.getSessionStore().remove).toBeCalled()
313
- })
314
- })
315
- })
316
-
317
- describe('Changes in the store during the context lifetime', () => {
318
- it('Should update user roles', () => {
319
- return usingAsync(new Injector(), async (i) => {
320
- await prepareInjector(i)
321
- const ctx = i.getInstance(HttpUserContext)
322
- const userStore = i.getInstance(StoreManager).getStoreFor(User, 'username')
323
- await userStore.add(testUser)
324
-
325
- const pw = await i.getInstance(PasswordAuthenticator).hasher.createCredential(testUser.username, 'test')
326
- await i.getInstance(StoreManager).getStoreFor(PasswordCredential, 'userName').add(pw)
327
-
328
- await ctx.cookieLogin(testUser, { setHeader: vi.fn() })
329
-
330
- const originalUser = await ctx.getCurrentUser(request)
331
- expect(originalUser).toEqual(testUser)
332
-
333
- const updatedUser = { ...testUser, roles: ['newFancyRole'] }
334
- await userStore.update(testUser.username, updatedUser)
335
- const updatedUserFromContext = await ctx.getCurrentUser(request)
336
- expect(updatedUserFromContext.roles).toEqual(['newFancyRole'])
337
-
338
- await userStore.update(testUser.username, { ...updatedUser, roles: [] })
339
- const reloadedUserFromContext = await ctx.getCurrentUser(request)
340
- expect(reloadedUserFromContext.roles).toEqual([])
341
- })
342
- })
343
-
344
- it('Should remove current user when the user is removed from the store', () => {
345
- return usingAsync(new Injector(), async (i) => {
346
- await prepareInjector(i)
347
- const ctx = i.getInstance(HttpUserContext)
348
- const userStore = i.getInstance(StoreManager).getStoreFor(User, 'username')
349
- await userStore.add(testUser)
350
-
351
- const pw = await i.getInstance(PasswordAuthenticator).hasher.createCredential(testUser.username, 'test')
352
- await i.getInstance(StoreManager).getStoreFor(PasswordCredential, 'userName').add(pw)
353
-
354
- await ctx.cookieLogin(testUser, { setHeader: vi.fn() })
355
-
356
- const originalUser = await ctx.getCurrentUser(request)
357
- expect(originalUser).toEqual(testUser)
358
-
359
- await userStore.remove(testUser.username)
360
-
361
- await expect(() => ctx.getCurrentUser(request)).rejects.toThrowError(UnauthenticatedError)
362
- })
363
- })
364
-
365
- it('Should remove current user when the session is removed from the store', () => {
366
- return usingAsync(new Injector(), async (i) => {
367
- await prepareInjector(i)
368
- const ctx = i.getInstance(HttpUserContext)
369
- const userStore = i.getInstance(StoreManager).getStoreFor(User, 'username')
370
- await userStore.add(testUser)
371
-
372
- let sessionId = ''
373
-
374
- const pw = await i.getInstance(PasswordAuthenticator).hasher.createCredential(testUser.username, 'test')
375
- await i.getInstance(StoreManager).getStoreFor(PasswordCredential, 'userName').add(pw)
376
-
377
- await ctx.cookieLogin(testUser, {
378
- setHeader: (_headerName, headerValue) => {
379
- sessionId = headerValue
380
- return {} as ServerResponse
381
- },
382
- })
383
-
384
- const originalUser = await ctx.getCurrentUser(request)
385
- expect(originalUser).toEqual(testUser)
386
-
387
- const sessionStore = ctx.getSessionStore()
388
- await sessionStore.remove(sessionId)
389
-
390
- await expect(() => ctx.getCurrentUser(request)).rejects.toThrowError(UnauthenticatedError)
391
- })
392
- })
393
- })
394
- })
1
+ /* eslint-disable @typescript-eslint/ban-ts-comment */
2
+ import { InMemoryStore, StoreManager, User, addStore, useSystemIdentityContext } from '@furystack/core'
3
+ import { Injector } from '@furystack/inject'
4
+ import { getDataSetFor, getRepository } from '@furystack/repository'
5
+ import {
6
+ PasswordAuthenticator,
7
+ PasswordCredential,
8
+ PasswordResetToken,
9
+ UnauthenticatedError,
10
+ usePasswordPolicy,
11
+ } from '@furystack/security'
12
+ import { usingAsync } from '@furystack/utils'
13
+ import type { IncomingMessage, ServerResponse } from 'http'
14
+ import { describe, expect, it, vi } from 'vitest'
15
+ import { useHttpAuthentication } from './helpers.js'
16
+ import { HttpUserContext } from './http-user-context.js'
17
+ import { DefaultSession } from './models/default-session.js'
18
+
19
+ export const prepareInjector = async (i: Injector, options?: { enableBasicAuth?: boolean }) => {
20
+ addStore(i, new InMemoryStore({ model: User, primaryKey: 'username' }))
21
+ .addStore(new InMemoryStore({ model: DefaultSession, primaryKey: 'sessionId' }))
22
+ .addStore(new InMemoryStore({ model: PasswordCredential, primaryKey: 'userName' }))
23
+ .addStore(new InMemoryStore({ model: PasswordResetToken, primaryKey: 'token' }))
24
+
25
+ const repo = getRepository(i)
26
+ repo.createDataSet(User, 'username')
27
+ repo.createDataSet(DefaultSession, 'sessionId')
28
+ repo.createDataSet(PasswordCredential, 'userName')
29
+ repo.createDataSet(PasswordResetToken, 'token')
30
+
31
+ usePasswordPolicy(i)
32
+ useHttpAuthentication(i, { enableBasicAuth: options?.enableBasicAuth ?? true })
33
+ }
34
+
35
+ const setupUser = async (i: Injector, userName: string, password: string) => {
36
+ const sm = i.getInstance(StoreManager)
37
+ const pw = i.getInstance(PasswordAuthenticator)
38
+ const cred = await pw.hasher.createCredential(userName, password)
39
+ await sm.getStoreFor(PasswordCredential, 'userName').add(cred)
40
+ await sm.getStoreFor(User, 'username').add({ username: userName, roles: [] })
41
+ }
42
+
43
+ describe('HttpUserContext', () => {
44
+ const request = { headers: {} } as IncomingMessage
45
+ const response = {} as any as ServerResponse
46
+
47
+ const testUser: User = { username: 'testUser', roles: ['grantedRole1', 'grantedRole2'] }
48
+
49
+ it('Should be constructed with the extension method', async () => {
50
+ await usingAsync(new Injector(), async (i) => {
51
+ await prepareInjector(i)
52
+ const ctx = i.getInstance(HttpUserContext)
53
+ expect(ctx).toBeInstanceOf(HttpUserContext)
54
+ })
55
+ })
56
+
57
+ describe('isAuthenticated', () => {
58
+ it('Should return true for authenticated users', async () => {
59
+ await usingAsync(new Injector(), async (i) => {
60
+ await prepareInjector(i)
61
+ const ctx = i.getInstance(HttpUserContext)
62
+ ctx.getCurrentUser = vi.fn(async () => testUser)
63
+ const value = await ctx.isAuthenticated(request)
64
+ expect(value).toBe(true)
65
+ expect(ctx.getCurrentUser).toBeCalled()
66
+ })
67
+ })
68
+
69
+ it('Should return false for unauthenticated users', async () => {
70
+ await usingAsync(new Injector(), async (i) => {
71
+ await prepareInjector(i)
72
+ const ctx = i.getInstance(HttpUserContext)
73
+ ctx.getCurrentUser = vi.fn(async () => {
74
+ throw Error(':(')
75
+ })
76
+ await expect(ctx.isAuthenticated(request)).resolves.toEqual(false)
77
+ expect(ctx.getCurrentUser).toBeCalled()
78
+ })
79
+ })
80
+ })
81
+
82
+ describe('isAuthorized', () => {
83
+ it('Should return true if all roles are authorized', async () => {
84
+ await usingAsync(new Injector(), async (i) => {
85
+ await prepareInjector(i)
86
+ const ctx = i.getInstance(HttpUserContext)
87
+ ctx.getCurrentUser = vi.fn(async () => testUser)
88
+ const value = await ctx.isAuthorized(request, 'grantedRole1', 'grantedRole2')
89
+ expect(value).toBe(true)
90
+ expect(ctx.getCurrentUser).toBeCalled()
91
+ })
92
+ })
93
+
94
+ it('Should return false if not all roles are authorized', async () => {
95
+ await usingAsync(new Injector(), async (i) => {
96
+ await prepareInjector(i)
97
+ const ctx = i.getInstance(HttpUserContext)
98
+ ctx.getCurrentUser = vi.fn(async () => testUser)
99
+ const value = await ctx.isAuthorized(request, 'grantedRole1', 'nonGrantedRole2')
100
+ expect(value).toBe(false)
101
+ expect(ctx.getCurrentUser).toBeCalled()
102
+ })
103
+ })
104
+ })
105
+
106
+ describe('authenticateUser', () => {
107
+ it('Should fail when the store is empty', async () => {
108
+ await usingAsync(new Injector(), async (i) => {
109
+ await prepareInjector(i)
110
+ const ctx = i.getInstance(HttpUserContext)
111
+ await expect(ctx.authenticateUser('user', 'password')).rejects.toThrowError(UnauthenticatedError)
112
+ })
113
+ })
114
+
115
+ it('Should fail when the password not equals', async () => {
116
+ await usingAsync(new Injector(), async (i) => {
117
+ await prepareInjector(i)
118
+ await setupUser(i, 'user', 'pass123')
119
+ await expect(i.getInstance(HttpUserContext).authenticateUser('user', 'pass321')).rejects.toThrowError(
120
+ UnauthenticatedError,
121
+ )
122
+ })
123
+ })
124
+
125
+ it('Should fail when the username not equals', async () => {
126
+ await usingAsync(new Injector(), async (i) => {
127
+ await prepareInjector(i)
128
+ await setupUser(i, 'otherUser', 'pass123')
129
+ await expect(i.getInstance(HttpUserContext).authenticateUser('user', 'pass123')).rejects.toThrowError(
130
+ UnauthenticatedError,
131
+ )
132
+ })
133
+ })
134
+
135
+ it('Should fail when password not provided', async () => {
136
+ await usingAsync(new Injector(), async (i) => {
137
+ await prepareInjector(i)
138
+ await setupUser(i, 'user', 'pass123')
139
+ await expect(i.getInstance(HttpUserContext).authenticateUser('user', '')).rejects.toThrowError(
140
+ UnauthenticatedError,
141
+ )
142
+ })
143
+ })
144
+
145
+ it('Should fail when the user is not in the user store', async () => {
146
+ await usingAsync(new Injector(), async (i) => {
147
+ await prepareInjector(i)
148
+ await setupUser(i, 'user', 'pass123')
149
+ await i.getInstance(StoreManager).getStoreFor(User, 'username').remove('user')
150
+ await expect(i.getInstance(HttpUserContext).authenticateUser('user', 'pass123')).rejects.toThrowError(
151
+ UnauthenticatedError,
152
+ )
153
+ })
154
+ })
155
+
156
+ it('Should return the user when the username and password matches', async () => {
157
+ await usingAsync(new Injector(), async (i) => {
158
+ await prepareInjector(i)
159
+ await setupUser(i, 'user', 'pass123')
160
+ const ctx = i.getInstance(HttpUserContext)
161
+ const value = await ctx.authenticateUser('user', 'pass123')
162
+ expect(value).toEqual({ username: 'user', roles: [] })
163
+ })
164
+ })
165
+ })
166
+
167
+ describe('getSessionIdFromRequest', () => {
168
+ it('Should return null if no headers present', async () => {
169
+ await usingAsync(new Injector(), async (i) => {
170
+ await prepareInjector(i)
171
+ const ctx = i.getInstance(HttpUserContext)
172
+ const sid = ctx.getSessionIdFromRequest(request)
173
+ expect(sid).toBeNull()
174
+ })
175
+ })
176
+
177
+ it('Should return null if no session ID cookie present', async () => {
178
+ await usingAsync(new Injector(), async (i) => {
179
+ await prepareInjector(i)
180
+ const requestWithCookie = { ...request, cookie: 'a=2;b=3;c=4;' } as unknown as IncomingMessage
181
+ const ctx = i.getInstance(HttpUserContext)
182
+ const sid = ctx.getSessionIdFromRequest(requestWithCookie)
183
+ expect(sid).toBeNull()
184
+ })
185
+ })
186
+ it('Should return the Session ID value if session ID cookie present', async () => {
187
+ await usingAsync(new Injector(), async (i) => {
188
+ await prepareInjector(i)
189
+ const ctx = i.getInstance(HttpUserContext)
190
+ const requestWithAuthCookie = {
191
+ ...request,
192
+ headers: { cookie: `a=2;b=3;${ctx.authentication.cookieName}=666;c=4;` },
193
+ } as unknown as IncomingMessage
194
+
195
+ const sid = ctx.getSessionIdFromRequest(requestWithAuthCookie)
196
+ expect(sid).toBe('666')
197
+ })
198
+ })
199
+ })
200
+
201
+ describe('authenticateRequest', () => {
202
+ it('Should authenticate with Basic Auth when enabled and valid credentials provided', async () => {
203
+ await usingAsync(new Injector(), async (i) => {
204
+ await prepareInjector(i)
205
+ await setupUser(i, 'testuser', 'password')
206
+ const ctx = i.getInstance(HttpUserContext)
207
+ const result = await ctx.authenticateRequest({
208
+ headers: { authorization: `Basic dGVzdHVzZXI6cGFzc3dvcmQ=` },
209
+ } as IncomingMessage)
210
+ expect(result.username).toBe('testuser')
211
+ })
212
+ })
213
+
214
+ it('Should NOT try to authenticate with Basic when disabled', async () => {
215
+ await usingAsync(new Injector(), async (i) => {
216
+ await prepareInjector(i, { enableBasicAuth: false })
217
+ await setupUser(i, 'testuser', 'password')
218
+ const ctx = i.getInstance(HttpUserContext)
219
+ await expect(
220
+ ctx.authenticateRequest({
221
+ headers: { authorization: `Basic dGVzdHVzZXI6cGFzc3dvcmQ=` },
222
+ } as IncomingMessage),
223
+ ).rejects.toThrowError(UnauthenticatedError)
224
+ })
225
+ })
226
+
227
+ it('Should fail with no session in the store', async () => {
228
+ await usingAsync(new Injector(), async (i) => {
229
+ await prepareInjector(i)
230
+ const ctx = i.getInstance(HttpUserContext)
231
+ await expect(
232
+ ctx.authenticateRequest({
233
+ headers: { cookie: `${ctx.authentication.cookieName}=666;a=3` },
234
+ } as IncomingMessage),
235
+ ).rejects.toThrowError(UnauthenticatedError)
236
+ })
237
+ })
238
+
239
+ it('Should fail with valid session Id but no user', async () => {
240
+ await usingAsync(new Injector(), async (i) => {
241
+ await prepareInjector(i)
242
+ const ctx = i.getInstance(HttpUserContext)
243
+ await i.getInstance(StoreManager).getStoreFor(DefaultSession, 'sessionId').add({
244
+ sessionId: '666',
245
+ username: testUser.username,
246
+ })
247
+ await expect(
248
+ ctx.authenticateRequest({
249
+ headers: { cookie: `${ctx.authentication.cookieName}=666;a=3` },
250
+ } as IncomingMessage),
251
+ ).rejects.toThrowError(UnauthenticatedError)
252
+ })
253
+ })
254
+
255
+ it('Should authenticate with cookie, if the session IDs matches', async () => {
256
+ await usingAsync(new Injector(), async (i) => {
257
+ await prepareInjector(i)
258
+
259
+ const ctx = i.getInstance(HttpUserContext)
260
+ await i.getInstance(StoreManager).getStoreFor(DefaultSession, 'sessionId').add({
261
+ sessionId: '666',
262
+ username: testUser.username,
263
+ })
264
+
265
+ await i
266
+ .getInstance(StoreManager)
267
+ .getStoreFor(User, 'username')
268
+ .add({ ...testUser })
269
+
270
+ const result = await ctx.authenticateRequest({
271
+ headers: { cookie: `${ctx.authentication.cookieName}=666;a=3` },
272
+ } as IncomingMessage)
273
+
274
+ expect(result).toEqual(testUser)
275
+ })
276
+ })
277
+
278
+ it('Should iterate providers and return null results pass to next', async () => {
279
+ await usingAsync(new Injector(), async (i) => {
280
+ await prepareInjector(i)
281
+ const ctx = i.getInstance(HttpUserContext)
282
+ const provider1 = vi.fn(async () => null)
283
+ const provider2 = vi.fn(async () => testUser)
284
+ ctx.authentication.authenticationProviders = [
285
+ { name: 'test-1', authenticate: provider1 },
286
+ { name: 'test-2', authenticate: provider2 },
287
+ ]
288
+ const result = await ctx.authenticateRequest(request)
289
+ expect(provider1).toHaveBeenCalledOnce()
290
+ expect(provider2).toHaveBeenCalledOnce()
291
+ expect(result).toBe(testUser)
292
+ })
293
+ })
294
+
295
+ it('Should throw if provider throws (skipping remaining providers)', async () => {
296
+ await usingAsync(new Injector(), async (i) => {
297
+ await prepareInjector(i)
298
+ const ctx = i.getInstance(HttpUserContext)
299
+ const provider1 = vi.fn(async () => {
300
+ throw new UnauthenticatedError()
301
+ })
302
+ const provider2 = vi.fn(async () => testUser)
303
+ ctx.authentication.authenticationProviders = [
304
+ { name: 'test-1', authenticate: provider1 },
305
+ { name: 'test-2', authenticate: provider2 },
306
+ ]
307
+ await expect(ctx.authenticateRequest(request)).rejects.toThrowError(UnauthenticatedError)
308
+ expect(provider2).not.toHaveBeenCalled()
309
+ })
310
+ })
311
+
312
+ it('Should throw UnauthenticatedError if no provider returns a user', async () => {
313
+ await usingAsync(new Injector(), async (i) => {
314
+ await prepareInjector(i)
315
+ const ctx = i.getInstance(HttpUserContext)
316
+ ctx.authentication.authenticationProviders = [{ name: 'test-1', authenticate: async () => null }]
317
+ await expect(ctx.authenticateRequest(request)).rejects.toThrowError(UnauthenticatedError)
318
+ })
319
+ })
320
+ })
321
+
322
+ describe('getCurrentUser', () => {
323
+ it('Should return the current user from authenticateRequest() once per request', async () => {
324
+ await usingAsync(new Injector(), async (i) => {
325
+ await prepareInjector(i)
326
+ const ctx = i.getInstance(HttpUserContext)
327
+ ctx.authenticateRequest = vi.fn(async () => testUser)
328
+ const result = await ctx.getCurrentUser(request)
329
+ const result2 = await ctx.getCurrentUser(request)
330
+ expect(ctx.authenticateRequest).toBeCalledTimes(1)
331
+ expect(result).toBe(testUser)
332
+ expect(result2).toBe(testUser)
333
+ })
334
+ })
335
+ })
336
+
337
+ describe('cookieLogin', () => {
338
+ it('Should return the current user from authenticateRequest() once per request', async () => {
339
+ await usingAsync(new Injector(), async (i) => {
340
+ await prepareInjector(i)
341
+ const ctx = i.getInstance(HttpUserContext)
342
+ const setHeader = vi.fn()
343
+ const addMock = vi.fn(async () => ({}))
344
+ // @ts-expect-error
345
+ ctx.getSessionDataSet = vi.fn(() => ({ add: addMock }))
346
+ const authResult = await ctx.cookieLogin(testUser, { setHeader })
347
+ expect(authResult).toBe(testUser)
348
+ expect(setHeader).toBeCalled()
349
+ expect(addMock).toBeCalled()
350
+ })
351
+ })
352
+ })
353
+
354
+ describe('cookieLogout', () => {
355
+ it('Should invalidate the current session id cookie', async () => {
356
+ await usingAsync(new Injector(), async (i) => {
357
+ await prepareInjector(i)
358
+ const ctx = i.getInstance(HttpUserContext)
359
+ const setHeader = vi.fn()
360
+ const removeMock = vi.fn(async () => undefined)
361
+ const sessionDataSetMock = {
362
+ add: vi.fn(async () => ({})),
363
+ find: vi.fn(async () => [{ sessionId: 'example-session-id' }]),
364
+ remove: removeMock,
365
+ primaryKey: 'sessionId' as const,
366
+ }
367
+ // @ts-expect-error
368
+ ctx.getSessionDataSet = vi.fn(() => sessionDataSetMock)
369
+ ctx.authenticateRequest = vi.fn(async () => testUser)
370
+ ctx.getSessionIdFromRequest = () => 'example-session-id'
371
+ response.setHeader = vi.fn(() => response)
372
+ await ctx.cookieLogin(testUser, { setHeader })
373
+ await ctx.cookieLogout(request, response)
374
+ expect(response.setHeader).toBeCalledWith('Set-Cookie', 'fss=; Path=/; HttpOnly')
375
+ expect(removeMock).toBeCalled()
376
+ })
377
+ })
378
+ })
379
+
380
+ describe('Changes in the store during the context lifetime', () => {
381
+ it('Should update user roles', () => {
382
+ return usingAsync(new Injector(), async (i) => {
383
+ await prepareInjector(i)
384
+ const ctx = i.getInstance(HttpUserContext)
385
+ const sm = i.getInstance(StoreManager)
386
+ await sm.getStoreFor(User, 'username').add(testUser)
387
+
388
+ const pw = await i.getInstance(PasswordAuthenticator).hasher.createCredential(testUser.username, 'test')
389
+ await sm.getStoreFor(PasswordCredential, 'userName').add(pw)
390
+
391
+ await ctx.cookieLogin(testUser, { setHeader: vi.fn() })
392
+
393
+ const originalUser = await ctx.getCurrentUser(request)
394
+ expect(originalUser).toEqual(testUser)
395
+
396
+ const systemInjector = useSystemIdentityContext({ injector: i, username: 'test' })
397
+ const userDataSet = getDataSetFor(systemInjector, User, 'username')
398
+ const updatedUser = { ...testUser, roles: ['newFancyRole'] }
399
+ await userDataSet.update(systemInjector, testUser.username, updatedUser)
400
+ const updatedUserFromContext = await ctx.getCurrentUser(request)
401
+ expect(updatedUserFromContext.roles).toEqual(['newFancyRole'])
402
+
403
+ await userDataSet.update(systemInjector, testUser.username, { ...updatedUser, roles: [] })
404
+ const reloadedUserFromContext = await ctx.getCurrentUser(request)
405
+ expect(reloadedUserFromContext.roles).toEqual([])
406
+ })
407
+ })
408
+
409
+ it('Should remove current user when the user is removed from the store', () => {
410
+ return usingAsync(new Injector(), async (i) => {
411
+ await prepareInjector(i)
412
+ const ctx = i.getInstance(HttpUserContext)
413
+ const sm = i.getInstance(StoreManager)
414
+ await sm.getStoreFor(User, 'username').add(testUser)
415
+
416
+ const pw = await i.getInstance(PasswordAuthenticator).hasher.createCredential(testUser.username, 'test')
417
+ await sm.getStoreFor(PasswordCredential, 'userName').add(pw)
418
+
419
+ await ctx.cookieLogin(testUser, { setHeader: vi.fn() })
420
+
421
+ const originalUser = await ctx.getCurrentUser(request)
422
+ expect(originalUser).toEqual(testUser)
423
+
424
+ const systemInjector = useSystemIdentityContext({ injector: i, username: 'test' })
425
+ const userDataSet = getDataSetFor(systemInjector, User, 'username')
426
+ await userDataSet.remove(systemInjector, testUser.username)
427
+
428
+ await expect(() => ctx.getCurrentUser(request)).rejects.toThrowError(UnauthenticatedError)
429
+ })
430
+ })
431
+
432
+ it('Should remove current user when the session is removed from the store', () => {
433
+ return usingAsync(new Injector(), async (i) => {
434
+ await prepareInjector(i)
435
+ const ctx = i.getInstance(HttpUserContext)
436
+ const sm = i.getInstance(StoreManager)
437
+ await sm.getStoreFor(User, 'username').add(testUser)
438
+
439
+ let sessionId = ''
440
+
441
+ const pw = await i.getInstance(PasswordAuthenticator).hasher.createCredential(testUser.username, 'test')
442
+ await sm.getStoreFor(PasswordCredential, 'userName').add(pw)
443
+
444
+ await ctx.cookieLogin(testUser, {
445
+ setHeader: (_headerName, headerValue) => {
446
+ sessionId = headerValue.split('=')[1].split(';')[0]
447
+ return {} as ServerResponse
448
+ },
449
+ })
450
+
451
+ const originalUser = await ctx.getCurrentUser(request)
452
+ expect(originalUser).toEqual(testUser)
453
+
454
+ const systemInjector = useSystemIdentityContext({ injector: i, username: 'test' })
455
+ const sessionDataSet = getDataSetFor(systemInjector, DefaultSession, 'sessionId')
456
+ await sessionDataSet.remove(systemInjector, sessionId)
457
+
458
+ await expect(() => ctx.getCurrentUser(request)).rejects.toThrowError(UnauthenticatedError)
459
+ })
460
+ })
461
+ })
462
+ })