@furystack/rest-service 12.0.0 → 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 (40) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/esm/actions/index.d.ts +1 -0
  3. package/esm/actions/index.d.ts.map +1 -1
  4. package/esm/actions/index.js +1 -0
  5. package/esm/actions/index.js.map +1 -1
  6. package/esm/actions/login.d.ts +7 -3
  7. package/esm/actions/login.d.ts.map +1 -1
  8. package/esm/actions/login.js +11 -5
  9. package/esm/actions/login.js.map +1 -1
  10. package/esm/actions/password-login-action.d.ts +24 -0
  11. package/esm/actions/password-login-action.d.ts.map +1 -0
  12. package/esm/actions/password-login-action.js +31 -0
  13. package/esm/actions/password-login-action.js.map +1 -0
  14. package/esm/actions/password-login-action.spec.d.ts +2 -0
  15. package/esm/actions/password-login-action.spec.d.ts.map +1 -0
  16. package/esm/actions/password-login-action.spec.js +105 -0
  17. package/esm/actions/password-login-action.spec.js.map +1 -0
  18. package/esm/index.d.ts +1 -0
  19. package/esm/index.d.ts.map +1 -1
  20. package/esm/index.js +1 -0
  21. package/esm/index.js.map +1 -1
  22. package/esm/login-response-strategy.d.ts +28 -0
  23. package/esm/login-response-strategy.d.ts.map +1 -0
  24. package/esm/login-response-strategy.js +28 -0
  25. package/esm/login-response-strategy.js.map +1 -0
  26. package/esm/login-response-strategy.spec.d.ts +2 -0
  27. package/esm/login-response-strategy.spec.d.ts.map +1 -0
  28. package/esm/login-response-strategy.spec.js +78 -0
  29. package/esm/login-response-strategy.spec.js.map +1 -0
  30. package/package.json +7 -7
  31. package/src/actions/index.ts +1 -0
  32. package/src/actions/login.ts +12 -6
  33. package/src/actions/password-login-action.spec.ts +122 -0
  34. package/src/actions/password-login-action.ts +35 -0
  35. package/src/http-authentication-settings.ts +30 -30
  36. package/src/http-user-context.spec.ts +462 -462
  37. package/src/http-user-context.ts +164 -164
  38. package/src/index.ts +1 -0
  39. package/src/login-response-strategy.spec.ts +90 -0
  40. package/src/login-response-strategy.ts +48 -0
@@ -1,164 +1,164 @@
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
- }
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
@@ -9,6 +9,7 @@ export * from './get-schema-from-api.js'
9
9
  export * from './helpers.js'
10
10
  export * from './http-authentication-settings.js'
11
11
  export * from './http-user-context.js'
12
+ export * from './login-response-strategy.js'
12
13
  export * from './mime-types.js'
13
14
  export * from './models/index.js'
14
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
+ }