@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,194 +1,164 @@
1
- import type { User } from '@furystack/core'
2
- import { StoreManager } from '@furystack/core'
3
- import { Injectable, Injected } from '@furystack/inject'
4
- import { PasswordAuthenticator, UnauthenticatedError } from '@furystack/security'
5
- import { randomBytes } from 'crypto'
6
- import type { IncomingMessage } from 'http'
7
- import { HttpAuthenticationSettings } from './http-authentication-settings.js'
8
- import type { DefaultSession } from './models/default-session.js'
9
-
10
- /**
11
- * Injectable UserContext for FuryStack HTTP Api
12
- */
13
- @Injectable({ lifetime: 'scoped' })
14
- export class HttpUserContext {
15
- public getUserStore = () => this.authentication.getUserStore(this.storeManager)
16
-
17
- public getSessionStore = () => this.authentication.getSessionStore(this.storeManager)
18
-
19
- private getUserByName = async (userName: string) => {
20
- const userStore = this.getUserStore()
21
- const users = await userStore.find({ filter: { username: { $eq: userName } }, top: 2 })
22
- if (users.length !== 1) {
23
- throw new UnauthenticatedError()
24
- }
25
- return users[0]
26
- }
27
-
28
- private getSessionById = async (sessionId: string) => {
29
- const sessionStore = this.getSessionStore()
30
- const sessions = await sessionStore.find({ filter: { sessionId: { $eq: sessionId } }, top: 2 })
31
- if (sessions.length !== 1) {
32
- throw new UnauthenticatedError()
33
- }
34
- return sessions[0]
35
- }
36
-
37
- private user?: User
38
-
39
- /**
40
- * @param request The request to be authenticated
41
- * @returns whether the current user is authenticated
42
- */
43
- public async isAuthenticated(request: IncomingMessage) {
44
- try {
45
- const currentUser = await this.getCurrentUser(request)
46
- return currentUser !== null
47
- } catch (error) {
48
- return false
49
- }
50
- }
51
-
52
- /**
53
- * Returns whether the current user can be authorized with ALL of the specified roles
54
- * @param request The request to be authenticated
55
- * @param roles The list of roles to authorize
56
- * @returns a boolean value that indicates whether the user is authorized
57
- */
58
- public async isAuthorized(request: IncomingMessage, ...roles: string[]): Promise<boolean> {
59
- const currentUser = await this.getCurrentUser(request)
60
- for (const role of roles) {
61
- if (!currentUser || !currentUser.roles.some((c) => c === role)) {
62
- return false
63
- }
64
- }
65
- return true
66
- }
67
-
68
- /**
69
- * Checks if the system contains a user with the provided name and password, throws an error otherwise
70
- * @param userName The username
71
- * @param password The password
72
- * @returns the authenticated User
73
- */
74
- public async authenticateUser(userName: string, password: string) {
75
- const result = await this.authenticator.checkPasswordForUser(userName, password)
76
-
77
- if (!result.isValid) {
78
- throw new UnauthenticatedError()
79
- }
80
- const user = await this.getUserByName(userName)
81
- if (!user) {
82
- throw new UnauthenticatedError()
83
- }
84
- return user
85
- }
86
-
87
- public async getCurrentUser(request: Pick<IncomingMessage, 'headers'>) {
88
- if (!this.user) {
89
- this.user = await this.authenticateRequest(request)
90
- return this.user
91
- }
92
- return this.user
93
- }
94
-
95
- public getSessionIdFromRequest(request: Pick<IncomingMessage, 'headers'>): string | null {
96
- if (request.headers.cookie) {
97
- const cookies = request.headers.cookie
98
- .toString()
99
- .split(';')
100
- .filter((val) => val.length > 0)
101
- .map((val) => {
102
- const [name, value] = val.split('=')
103
- return { name: name?.trim(), value: value?.trim() }
104
- })
105
- const sessionCookie = cookies.find((c) => c.name === this.authentication.cookieName)
106
- if (sessionCookie) {
107
- return sessionCookie.value
108
- }
109
- }
110
- return null
111
- }
112
-
113
- public async authenticateRequest(request: Pick<IncomingMessage, 'headers'>): Promise<User> {
114
- // Basic auth
115
- if (this.authentication.enableBasicAuth && request.headers.authorization) {
116
- const authData = Buffer.from(request.headers.authorization.toString().split(' ')[1], 'base64')
117
- const [userName, password] = authData.toString().split(':')
118
- return await this.authenticateUser(userName, password)
119
- }
120
-
121
- // Cookie auth
122
- const sessionId = this.getSessionIdFromRequest(request)
123
- if (sessionId) {
124
- const session = await this.getSessionById(sessionId)
125
- if (session) {
126
- const user = await this.getUserByName(session.username)
127
- if (user) {
128
- return user
129
- }
130
- }
131
- }
132
-
133
- throw new UnauthenticatedError()
134
- }
135
-
136
- /**
137
- * Creates and sets up a cookie-based session for the provided user
138
- * @param user The user to create a session for
139
- * @param serverResponse A serverResponse to set the cookie
140
- * @returns the current User
141
- */
142
- public async cookieLogin(
143
- user: User,
144
- serverResponse: { setHeader: (header: string, value: string) => void },
145
- ): Promise<User> {
146
- const sessionId = randomBytes(32).toString('hex')
147
- await this.getSessionStore().add({ sessionId, username: user.username })
148
- serverResponse.setHeader('Set-Cookie', `${this.authentication.cookieName}=${sessionId}; Path=/; HttpOnly`)
149
- this.user = user
150
- return user
151
- }
152
-
153
- public async cookieLogout(
154
- request: Pick<IncomingMessage, 'headers'>,
155
- response: { setHeader: (header: string, value: string) => void },
156
- ) {
157
- this.user = undefined
158
- const sessionId = this.getSessionIdFromRequest(request)
159
- response.setHeader('Set-Cookie', `${this.authentication.cookieName}=; Path=/; HttpOnly`)
160
-
161
- if (sessionId) {
162
- const sessionStore = this.getSessionStore()
163
- const sessions = await sessionStore.find({ filter: { sessionId: { $eq: sessionId } } })
164
- await this.getSessionStore().remove(...sessions.map((s) => s[sessionStore.primaryKey]))
165
- }
166
- }
167
-
168
- @Injected(HttpAuthenticationSettings)
169
- declare public readonly authentication: HttpAuthenticationSettings<User, DefaultSession>
170
-
171
- @Injected(StoreManager)
172
- declare private readonly storeManager: StoreManager
173
-
174
- @Injected(PasswordAuthenticator)
175
- declare private readonly authenticator: PasswordAuthenticator
176
-
177
- public init() {
178
- this.getUserStore().addListener('onEntityUpdated', ({ id, change }) => {
179
- if (this.user?.username === id) {
180
- this.user = { ...this.user, ...change }
181
- }
182
- })
183
-
184
- this.getUserStore().addListener('onEntityRemoved', ({ key }) => {
185
- if (this.user?.username === key) {
186
- this.user = undefined
187
- }
188
- })
189
-
190
- this.getSessionStore().addListener('onEntityRemoved', () => {
191
- this.user = undefined // as user cannot be determined by the session id anymore
192
- })
193
- }
194
- }
1
+ import type { User } from '@furystack/core'
2
+ import { useSystemIdentityContext } from '@furystack/core'
3
+ import type { Injector } from '@furystack/inject'
4
+ import { Injectable, Injected } from '@furystack/inject'
5
+ import { PasswordAuthenticator, UnauthenticatedError } from '@furystack/security'
6
+ import { randomBytes } from 'crypto'
7
+ import type { IncomingMessage } from 'http'
8
+ import { extractSessionIdFromCookies } from './authentication-providers/helpers.js'
9
+ import { HttpAuthenticationSettings } from './http-authentication-settings.js'
10
+ import type { DefaultSession } from './models/default-session.js'
11
+
12
+ /**
13
+ * Injectable UserContext for FuryStack HTTP Api
14
+ */
15
+ @Injectable({ lifetime: 'scoped' })
16
+ export class HttpUserContext {
17
+ public getUserDataSet = () => this.authentication.getUserDataSet(this.systemInjector)
18
+
19
+ public getSessionDataSet = () => this.authentication.getSessionDataSet(this.systemInjector)
20
+
21
+ private getUserByName = async (userName: string) => {
22
+ const userDataSet = this.getUserDataSet()
23
+ const users = await userDataSet.find(this.systemInjector, { filter: { username: { $eq: userName } }, top: 2 })
24
+ if (users.length !== 1) {
25
+ throw new UnauthenticatedError()
26
+ }
27
+ return users[0]
28
+ }
29
+
30
+ private user?: User
31
+
32
+ /**
33
+ * @param request The request to be authenticated
34
+ * @returns whether the current user is authenticated
35
+ */
36
+ public async isAuthenticated(request: IncomingMessage) {
37
+ try {
38
+ const currentUser = await this.getCurrentUser(request)
39
+ return currentUser !== null
40
+ } catch (error) {
41
+ return false
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Returns whether the current user can be authorized with ALL of the specified roles
47
+ * @param request The request to be authenticated
48
+ * @param roles The list of roles to authorize
49
+ * @returns a boolean value that indicates whether the user is authorized
50
+ */
51
+ public async isAuthorized(request: IncomingMessage, ...roles: string[]): Promise<boolean> {
52
+ const currentUser = await this.getCurrentUser(request)
53
+ for (const role of roles) {
54
+ if (!currentUser || !currentUser.roles.some((c) => c === role)) {
55
+ return false
56
+ }
57
+ }
58
+ return true
59
+ }
60
+
61
+ /**
62
+ * Checks if the system contains a user with the provided name and password, throws an error otherwise
63
+ * @param userName The username
64
+ * @param password The password
65
+ * @returns the authenticated User
66
+ */
67
+ public async authenticateUser(userName: string, password: string) {
68
+ const result = await this.authenticator.checkPasswordForUser(userName, password)
69
+
70
+ if (!result.isValid) {
71
+ throw new UnauthenticatedError()
72
+ }
73
+ const user = await this.getUserByName(userName)
74
+ if (!user) {
75
+ throw new UnauthenticatedError()
76
+ }
77
+ return user
78
+ }
79
+
80
+ public async getCurrentUser(request: Pick<IncomingMessage, 'headers'>) {
81
+ if (!this.user) {
82
+ this.user = await this.authenticateRequest(request)
83
+ return this.user
84
+ }
85
+ return this.user
86
+ }
87
+
88
+ public getSessionIdFromRequest(request: Pick<IncomingMessage, 'headers'>): string | null {
89
+ return extractSessionIdFromCookies(request, this.authentication.cookieName)
90
+ }
91
+
92
+ /**
93
+ * Iterates registered authentication providers in order.
94
+ * - A provider returning `User` means authentication succeeded.
95
+ * - A provider returning `null` means it does not apply; try the next one.
96
+ * - A provider throwing means it owns the request but auth failed; propagate the error.
97
+ */
98
+ public async authenticateRequest(request: Pick<IncomingMessage, 'headers'>): Promise<User> {
99
+ for (const provider of this.authentication.authenticationProviders) {
100
+ const user = await provider.authenticate(request)
101
+ if (user) return user
102
+ }
103
+ throw new UnauthenticatedError()
104
+ }
105
+
106
+ /**
107
+ * Creates and sets up a cookie-based session for the provided user
108
+ * @param user The user to create a session for
109
+ * @param serverResponse A serverResponse to set the cookie
110
+ * @returns the current User
111
+ */
112
+ public async cookieLogin(
113
+ user: User,
114
+ serverResponse: { setHeader: (header: string, value: string) => void },
115
+ ): Promise<User> {
116
+ const sessionId = randomBytes(32).toString('hex')
117
+ await this.getSessionDataSet().add(this.systemInjector, { sessionId, username: user.username })
118
+ serverResponse.setHeader('Set-Cookie', `${this.authentication.cookieName}=${sessionId}; Path=/; HttpOnly`)
119
+ this.user = user
120
+ return user
121
+ }
122
+
123
+ public async cookieLogout(
124
+ request: Pick<IncomingMessage, 'headers'>,
125
+ response: { setHeader: (header: string, value: string) => void },
126
+ ) {
127
+ this.user = undefined
128
+ const sessionId = this.getSessionIdFromRequest(request)
129
+ response.setHeader('Set-Cookie', `${this.authentication.cookieName}=; Path=/; HttpOnly`)
130
+
131
+ if (sessionId) {
132
+ const sessionDataSet = this.getSessionDataSet()
133
+ const sessions = await sessionDataSet.find(this.systemInjector, { filter: { sessionId: { $eq: sessionId } } })
134
+ await sessionDataSet.remove(this.systemInjector, ...sessions.map((s) => s[sessionDataSet.primaryKey]))
135
+ }
136
+ }
137
+
138
+ @Injected(HttpAuthenticationSettings)
139
+ declare public readonly authentication: HttpAuthenticationSettings<User, DefaultSession>
140
+
141
+ @Injected((injector: Injector) => useSystemIdentityContext({ injector, username: 'HttpUserContext' }))
142
+ declare private readonly systemInjector: Injector
143
+
144
+ @Injected(PasswordAuthenticator)
145
+ declare private readonly authenticator: PasswordAuthenticator
146
+
147
+ public init() {
148
+ this.getUserDataSet().addListener('onEntityUpdated', ({ id, change }) => {
149
+ if (this.user?.username === id) {
150
+ this.user = { ...this.user, ...change }
151
+ }
152
+ })
153
+
154
+ this.getUserDataSet().addListener('onEntityRemoved', ({ key }) => {
155
+ if (this.user?.username === key) {
156
+ this.user = undefined
157
+ }
158
+ })
159
+
160
+ this.getSessionDataSet().addListener('onEntityRemoved', () => {
161
+ this.user = undefined
162
+ })
163
+ }
164
+ }
package/src/index.ts CHANGED
@@ -2,12 +2,14 @@ export * from './actions/index.js'
2
2
  export * from './add-cors-header.js'
3
3
  export * from './api-manager.js'
4
4
  export * from './authenticate.js'
5
+ export * from './authentication-providers/index.js'
5
6
  export * from './authorize.js'
6
7
  export * from './endpoint-generators/index.js'
7
8
  export * from './get-schema-from-api.js'
8
9
  export * from './helpers.js'
9
10
  export * from './http-authentication-settings.js'
10
11
  export * from './http-user-context.js'
12
+ export * from './login-response-strategy.js'
11
13
  export * from './mime-types.js'
12
14
  export * from './models/index.js'
13
15
  export * from './proxy-manager.js'
@@ -0,0 +1,90 @@
1
+ import { InMemoryStore, User, addStore } from '@furystack/core'
2
+ import { Injector } from '@furystack/inject'
3
+ import { getRepository } from '@furystack/repository'
4
+ import { PasswordCredential, PasswordResetToken, usePasswordPolicy } from '@furystack/security'
5
+ import { usingAsync } from '@furystack/utils'
6
+ import { describe, expect, it } from 'vitest'
7
+
8
+ import { useHttpAuthentication } from './helpers.js'
9
+ import { createCookieLoginStrategy } from './login-response-strategy.js'
10
+ import { DefaultSession } from './models/default-session.js'
11
+
12
+ const setupInjector = (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
+ .addStore(new InMemoryStore({ model: PasswordResetToken, primaryKey: 'token' }))
17
+
18
+ const repo = getRepository(i)
19
+ repo.createDataSet(User, 'username')
20
+ repo.createDataSet(DefaultSession, 'sessionId')
21
+ repo.createDataSet(PasswordCredential, 'userName')
22
+ repo.createDataSet(PasswordResetToken, 'token')
23
+
24
+ usePasswordPolicy(i)
25
+ useHttpAuthentication(i)
26
+ }
27
+
28
+ describe('createCookieLoginStrategy', () => {
29
+ const testUser: User = { username: 'testuser', roles: ['admin'] }
30
+
31
+ it('Should return the user as the response body', async () => {
32
+ await usingAsync(new Injector(), async (i) => {
33
+ setupInjector(i)
34
+ const strategy = createCookieLoginStrategy(i)
35
+ const result = await strategy.createLoginResponse(testUser, i)
36
+ expect(result.chunk).toEqual(testUser)
37
+ })
38
+ })
39
+
40
+ it('Should return status code 200', async () => {
41
+ await usingAsync(new Injector(), async (i) => {
42
+ setupInjector(i)
43
+ const strategy = createCookieLoginStrategy(i)
44
+ const result = await strategy.createLoginResponse(testUser, i)
45
+ expect(result.statusCode).toBe(200)
46
+ })
47
+ })
48
+
49
+ it('Should include a Set-Cookie header with the session ID', async () => {
50
+ await usingAsync(new Injector(), async (i) => {
51
+ setupInjector(i)
52
+ const strategy = createCookieLoginStrategy(i)
53
+ const result = await strategy.createLoginResponse(testUser, i)
54
+ const setCookie = result.headers['Set-Cookie']
55
+ expect(setCookie).toBeDefined()
56
+ expect(setCookie).toContain('fss=')
57
+ expect(setCookie).toContain('Path=/')
58
+ expect(setCookie).toContain('HttpOnly')
59
+ })
60
+ })
61
+
62
+ it('Should persist the session in the DataSet', async () => {
63
+ await usingAsync(new Injector(), async (i) => {
64
+ setupInjector(i)
65
+ const strategy = createCookieLoginStrategy(i)
66
+ await strategy.createLoginResponse(testUser, i)
67
+
68
+ const repo = getRepository(i)
69
+ const sessionDataSet = repo.getDataSetFor(DefaultSession, 'sessionId')
70
+ const sessions = await sessionDataSet.find(i, { filter: { username: { $eq: 'testuser' } } })
71
+ expect(sessions).toHaveLength(1)
72
+ expect(sessions[0].username).toBe('testuser')
73
+ expect(sessions[0].sessionId).toBeTruthy()
74
+ })
75
+ })
76
+
77
+ it('Should create unique session IDs for each call', async () => {
78
+ await usingAsync(new Injector(), async (i) => {
79
+ setupInjector(i)
80
+ const strategy = createCookieLoginStrategy(i)
81
+
82
+ const result1 = await strategy.createLoginResponse(testUser, i)
83
+ const result2 = await strategy.createLoginResponse(testUser, i)
84
+
85
+ const sessionId1 = result1.headers['Set-Cookie']?.split('=')[1]?.split(';')[0]
86
+ const sessionId2 = result2.headers['Set-Cookie']?.split('=')[1]?.split(';')[0]
87
+ expect(sessionId1).not.toBe(sessionId2)
88
+ })
89
+ })
90
+ })
@@ -0,0 +1,48 @@
1
+ import type { User } from '@furystack/core'
2
+ import { useSystemIdentityContext } from '@furystack/core'
3
+ import type { Injector } from '@furystack/inject'
4
+ import { randomBytes } from 'crypto'
5
+
6
+ import { HttpAuthenticationSettings } from './http-authentication-settings.js'
7
+ import type { ActionResult } from './request-action-implementation.js'
8
+ import { JsonResult } from './request-action-implementation.js'
9
+
10
+ /**
11
+ * A pluggable strategy that turns an authenticated {@link User} into an
12
+ * {@link ActionResult} containing the session/token data for the client.
13
+ *
14
+ * Pass a concrete strategy to action factories like
15
+ * {@link createPasswordLoginAction} or `createGoogleLoginAction` to
16
+ * decouple the authentication mechanism from the session/token mechanism.
17
+ *
18
+ * @typeParam TResult The shape of the response body (e.g. `User` for cookies,
19
+ * `{ accessToken: string; refreshToken: string }` for JWT)
20
+ */
21
+ export type LoginResponseStrategy<TResult> = {
22
+ createLoginResponse: (user: User, injector: Injector) => Promise<ActionResult<TResult>>
23
+ }
24
+
25
+ /**
26
+ * Creates a cookie-based {@link LoginResponseStrategy}.
27
+ *
28
+ * On each login it generates a random session ID, persists it in the session
29
+ * DataSet, and returns the user with a `Set-Cookie` header.
30
+ *
31
+ * @param injector The root injector (must have {@link HttpAuthenticationSettings} configured)
32
+ * @returns A strategy that returns `ActionResult<User>`
33
+ */
34
+ export const createCookieLoginStrategy = (injector: Injector): LoginResponseStrategy<User> => {
35
+ const settings = injector.getInstance(HttpAuthenticationSettings)
36
+ const systemInjector = useSystemIdentityContext({ injector, username: 'CookieLoginStrategy' })
37
+
38
+ return {
39
+ createLoginResponse: async (user) => {
40
+ const sessionDataSet = settings.getSessionDataSet(systemInjector)
41
+ const sessionId = randomBytes(32).toString('hex')
42
+ await sessionDataSet.add(systemInjector, { sessionId, username: user.username })
43
+ return JsonResult(user, 200, {
44
+ 'Set-Cookie': `${settings.cookieName}=${sessionId}; Path=/; HttpOnly`,
45
+ })
46
+ },
47
+ }
48
+ }
@@ -1,6 +1,7 @@
1
1
  import { InMemoryStore, User, addStore } from '@furystack/core'
2
2
  import { getPort } from '@furystack/core/port-generator'
3
3
  import { Injector } from '@furystack/inject'
4
+ import { getRepository } from '@furystack/repository'
4
5
  import type { RestApi } from '@furystack/rest'
5
6
  import { serializeValue } from '@furystack/rest'
6
7
  import { PathHelper, usingAsync } from '@furystack/utils'
@@ -33,10 +34,10 @@ const createIntegrationApi = async () => {
33
34
  addStore(i, new InMemoryStore({ model: User, primaryKey: 'username' })).addStore(
34
35
  new InMemoryStore({ model: DefaultSession, primaryKey: 'sessionId' }),
35
36
  )
36
- useHttpAuthentication(i, {
37
- getUserStore: (sm) => sm.getStoreFor(User, 'username'),
38
- getSessionStore: (sm) => sm.getStoreFor(DefaultSession, 'sessionId'),
39
- })
37
+ const repo = getRepository(i)
38
+ repo.createDataSet(User, 'username')
39
+ repo.createDataSet(DefaultSession, 'sessionId')
40
+ useHttpAuthentication(i)
40
41
  await useRestService<IntegrationTestApi>({
41
42
  injector: i,
42
43
  root,
@@ -2,6 +2,7 @@
2
2
  import { getStoreManager, InMemoryStore, User } from '@furystack/core'
3
3
  import { getPort } from '@furystack/core/port-generator'
4
4
  import { Injector } from '@furystack/inject'
5
+ import { getRepository } from '@furystack/repository'
5
6
  import type { SwaggerDocument, WithSchemaAction } from '@furystack/rest'
6
7
  import { createClient, ResponseError } from '@furystack/rest-client-fetch'
7
8
  import { usingAsync } from '@furystack/utils'
@@ -32,6 +33,10 @@ const createValidateApi = async (options = { enableGetSchema: false }) => {
32
33
 
33
34
  getStoreManager(injector).addStore(new InMemoryStore({ model: User, primaryKey: 'username' }))
34
35
  getStoreManager(injector).addStore(new InMemoryStore({ model: DefaultSession, primaryKey: 'sessionId' }))
36
+ getStoreManager(injector).addStore(new InMemoryStore({ model: MockClass, primaryKey: 'id' }))
37
+ getRepository(injector).createDataSet(MockClass, 'id')
38
+ getRepository(injector).createDataSet(User, 'username')
39
+ getRepository(injector).createDataSet(DefaultSession, 'sessionId')
35
40
 
36
41
  const api = await useRestService<ValidationApi>({
37
42
  injector,