@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.
- package/CHANGELOG.md +120 -0
- package/README.md +3 -2
- package/esm/actions/index.d.ts +1 -0
- package/esm/actions/index.d.ts.map +1 -1
- package/esm/actions/index.js +1 -0
- package/esm/actions/index.js.map +1 -1
- package/esm/actions/login.d.ts +7 -3
- package/esm/actions/login.d.ts.map +1 -1
- package/esm/actions/login.js +11 -5
- package/esm/actions/login.js.map +1 -1
- package/esm/actions/password-login-action.d.ts +24 -0
- package/esm/actions/password-login-action.d.ts.map +1 -0
- package/esm/actions/password-login-action.js +31 -0
- package/esm/actions/password-login-action.js.map +1 -0
- package/esm/actions/password-login-action.spec.d.ts +2 -0
- package/esm/actions/password-login-action.spec.d.ts.map +1 -0
- package/esm/actions/password-login-action.spec.js +105 -0
- package/esm/actions/password-login-action.spec.js.map +1 -0
- package/esm/authenticate.d.ts.map +1 -1
- package/esm/authenticate.js +4 -1
- package/esm/authenticate.js.map +1 -1
- package/esm/authenticate.spec.js +6 -4
- package/esm/authenticate.spec.js.map +1 -1
- package/esm/authentication-providers/authentication-provider.d.ts +25 -0
- package/esm/authentication-providers/authentication-provider.d.ts.map +1 -0
- package/esm/authentication-providers/authentication-provider.js +2 -0
- package/esm/authentication-providers/authentication-provider.js.map +1 -0
- package/esm/authentication-providers/basic-auth-provider.d.ts +8 -0
- package/esm/authentication-providers/basic-auth-provider.d.ts.map +1 -0
- package/esm/authentication-providers/basic-auth-provider.js +18 -0
- package/esm/authentication-providers/basic-auth-provider.js.map +1 -0
- package/esm/authentication-providers/cookie-auth-provider.d.ts +11 -0
- package/esm/authentication-providers/cookie-auth-provider.d.ts.map +1 -0
- package/esm/authentication-providers/cookie-auth-provider.js +21 -0
- package/esm/authentication-providers/cookie-auth-provider.js.map +1 -0
- package/esm/authentication-providers/helpers.d.ts +11 -0
- package/esm/authentication-providers/helpers.d.ts.map +1 -0
- package/esm/authentication-providers/helpers.js +47 -0
- package/esm/authentication-providers/helpers.js.map +1 -0
- package/esm/authentication-providers/index.d.ts +5 -0
- package/esm/authentication-providers/index.d.ts.map +1 -0
- package/esm/authentication-providers/index.js +5 -0
- package/esm/authentication-providers/index.js.map +1 -0
- package/esm/endpoint-generators/utils.d.ts.map +1 -1
- package/esm/endpoint-generators/utils.js +4 -1
- package/esm/endpoint-generators/utils.js.map +1 -1
- package/esm/helpers.d.ts +5 -2
- package/esm/helpers.d.ts.map +1 -1
- package/esm/helpers.js +27 -3
- package/esm/helpers.js.map +1 -1
- package/esm/helpers.spec.js +37 -0
- package/esm/helpers.spec.js.map +1 -1
- package/esm/http-authentication-settings.d.ts +11 -4
- package/esm/http-authentication-settings.d.ts.map +1 -1
- package/esm/http-authentication-settings.js +9 -2
- package/esm/http-authentication-settings.js.map +1 -1
- package/esm/http-user-context.d.ts +9 -4
- package/esm/http-user-context.d.ts.map +1 -1
- package/esm/http-user-context.js +28 -55
- package/esm/http-user-context.js.map +1 -1
- package/esm/http-user-context.spec.d.ts +3 -1
- package/esm/http-user-context.spec.d.ts.map +1 -1
- package/esm/http-user-context.spec.js +103 -45
- package/esm/http-user-context.spec.js.map +1 -1
- package/esm/index.d.ts +2 -0
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +2 -0
- package/esm/index.js.map +1 -1
- package/esm/login-response-strategy.d.ts +28 -0
- package/esm/login-response-strategy.d.ts.map +1 -0
- package/esm/login-response-strategy.js +28 -0
- package/esm/login-response-strategy.js.map +1 -0
- package/esm/login-response-strategy.spec.d.ts +2 -0
- package/esm/login-response-strategy.spec.d.ts.map +1 -0
- package/esm/login-response-strategy.spec.js +78 -0
- package/esm/login-response-strategy.spec.js.map +1 -0
- package/esm/rest-service.integration.spec.d.ts.map +1 -1
- package/esm/rest-service.integration.spec.js +5 -4
- package/esm/rest-service.integration.spec.js.map +1 -1
- package/esm/validate.integration.spec.js +5 -0
- package/esm/validate.integration.spec.js.map +1 -1
- package/package.json +7 -7
- package/src/actions/index.ts +1 -0
- package/src/actions/login.ts +12 -6
- package/src/actions/password-login-action.spec.ts +122 -0
- package/src/actions/password-login-action.ts +35 -0
- package/src/authenticate.spec.ts +6 -4
- package/src/authenticate.ts +4 -1
- package/src/authentication-providers/authentication-provider.ts +25 -0
- package/src/authentication-providers/basic-auth-provider.ts +21 -0
- package/src/authentication-providers/cookie-auth-provider.ts +26 -0
- package/src/authentication-providers/helpers.ts +73 -0
- package/src/authentication-providers/index.ts +4 -0
- package/src/endpoint-generators/utils.ts +4 -1
- package/src/helpers.spec.ts +40 -0
- package/src/helpers.ts +48 -3
- package/src/http-authentication-settings.ts +30 -21
- package/src/http-user-context.spec.ts +462 -394
- package/src/http-user-context.ts +164 -194
- package/src/index.ts +2 -0
- package/src/login-response-strategy.spec.ts +90 -0
- package/src/login-response-strategy.ts +48 -0
- package/src/rest-service.integration.spec.ts +5 -4
- package/src/validate.integration.spec.ts +5 -0
package/src/http-user-context.ts
CHANGED
|
@@ -1,194 +1,164 @@
|
|
|
1
|
-
import type { User } from '@furystack/core'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
public
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (!user) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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,
|