@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.
- package/CHANGELOG.md +52 -0
- 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/index.d.ts +1 -0
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +1 -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/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/http-authentication-settings.ts +30 -30
- package/src/http-user-context.spec.ts +462 -462
- package/src/http-user-context.ts +164 -164
- package/src/index.ts +1 -0
- package/src/login-response-strategy.spec.ts +90 -0
- package/src/login-response-strategy.ts +48 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { InMemoryStore, StoreManager, User, addStore } from '@furystack/core'
|
|
2
|
+
import { Injector } from '@furystack/inject'
|
|
3
|
+
import { getRepository } from '@furystack/repository'
|
|
4
|
+
import { PasswordAuthenticator, PasswordCredential, PasswordResetToken, usePasswordPolicy } 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
|
+
|
|
9
|
+
import { useHttpAuthentication } from '../helpers.js'
|
|
10
|
+
import type { LoginResponseStrategy } from '../login-response-strategy.js'
|
|
11
|
+
import { DefaultSession } from '../models/default-session.js'
|
|
12
|
+
import { JsonResult } from '../request-action-implementation.js'
|
|
13
|
+
import { createPasswordLoginAction } from './password-login-action.js'
|
|
14
|
+
|
|
15
|
+
const setupInjector = async (i: Injector, username: string, password: string) => {
|
|
16
|
+
addStore(i, new InMemoryStore({ model: User, primaryKey: 'username' }))
|
|
17
|
+
.addStore(new InMemoryStore({ model: DefaultSession, primaryKey: 'sessionId' }))
|
|
18
|
+
.addStore(new InMemoryStore({ model: PasswordCredential, primaryKey: 'userName' }))
|
|
19
|
+
.addStore(new InMemoryStore({ model: PasswordResetToken, primaryKey: 'token' }))
|
|
20
|
+
|
|
21
|
+
const repo = getRepository(i)
|
|
22
|
+
repo.createDataSet(User, 'username')
|
|
23
|
+
repo.createDataSet(DefaultSession, 'sessionId')
|
|
24
|
+
repo.createDataSet(PasswordCredential, 'userName')
|
|
25
|
+
repo.createDataSet(PasswordResetToken, 'token')
|
|
26
|
+
|
|
27
|
+
usePasswordPolicy(i)
|
|
28
|
+
useHttpAuthentication(i)
|
|
29
|
+
|
|
30
|
+
const sm = i.getInstance(StoreManager)
|
|
31
|
+
const pw = i.getInstance(PasswordAuthenticator)
|
|
32
|
+
const cred = await pw.hasher.createCredential(username, password)
|
|
33
|
+
await sm.getStoreFor(PasswordCredential, 'userName').add(cred)
|
|
34
|
+
await sm.getStoreFor(User, 'username').add({ username, roles: ['admin'] })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const mockStrategy: LoginResponseStrategy<User> = {
|
|
38
|
+
createLoginResponse: vi.fn(async (user: User) => JsonResult(user, 200)),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('createPasswordLoginAction', () => {
|
|
42
|
+
const request = {} as IncomingMessage
|
|
43
|
+
const response = {} as ServerResponse
|
|
44
|
+
|
|
45
|
+
it('Should delegate to the strategy on successful authentication', async () => {
|
|
46
|
+
await usingAsync(new Injector(), async (i) => {
|
|
47
|
+
await setupInjector(i, 'testuser', 'testpass')
|
|
48
|
+
const action = createPasswordLoginAction(mockStrategy)
|
|
49
|
+
const result = await action({
|
|
50
|
+
injector: i,
|
|
51
|
+
request,
|
|
52
|
+
response,
|
|
53
|
+
getBody: () => Promise.resolve({ username: 'testuser', password: 'testpass' }),
|
|
54
|
+
})
|
|
55
|
+
expect(mockStrategy.createLoginResponse).toHaveBeenCalled()
|
|
56
|
+
expect(result.chunk.username).toBe('testuser')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('Should pass the correct user to the strategy', async () => {
|
|
61
|
+
await usingAsync(new Injector(), async (i) => {
|
|
62
|
+
await setupInjector(i, 'testuser', 'testpass')
|
|
63
|
+
const strategyFn = vi.fn(async (user: User) => JsonResult(user, 200))
|
|
64
|
+
const strategy: LoginResponseStrategy<User> = { createLoginResponse: strategyFn }
|
|
65
|
+
const action = createPasswordLoginAction(strategy)
|
|
66
|
+
await action({
|
|
67
|
+
injector: i,
|
|
68
|
+
request,
|
|
69
|
+
response,
|
|
70
|
+
getBody: () => Promise.resolve({ username: 'testuser', password: 'testpass' }),
|
|
71
|
+
})
|
|
72
|
+
expect(strategyFn).toHaveBeenCalledWith(expect.objectContaining({ username: 'testuser', roles: ['admin'] }), i)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('Should throw RequestError on invalid credentials', async () => {
|
|
77
|
+
await usingAsync(new Injector(), async (i) => {
|
|
78
|
+
await setupInjector(i, 'testuser', 'testpass')
|
|
79
|
+
const action = createPasswordLoginAction(mockStrategy)
|
|
80
|
+
await expect(
|
|
81
|
+
action({
|
|
82
|
+
injector: i,
|
|
83
|
+
request,
|
|
84
|
+
response,
|
|
85
|
+
getBody: () => Promise.resolve({ username: 'testuser', password: 'wrongpass' }),
|
|
86
|
+
}),
|
|
87
|
+
).rejects.toThrow('Login Failed')
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('Should throw RequestError for nonexistent user', async () => {
|
|
92
|
+
await usingAsync(new Injector(), async (i) => {
|
|
93
|
+
await setupInjector(i, 'testuser', 'testpass')
|
|
94
|
+
const action = createPasswordLoginAction(mockStrategy)
|
|
95
|
+
await expect(
|
|
96
|
+
action({
|
|
97
|
+
injector: i,
|
|
98
|
+
request,
|
|
99
|
+
response,
|
|
100
|
+
getBody: () => Promise.resolve({ username: 'nobody', password: 'nopass' }),
|
|
101
|
+
}),
|
|
102
|
+
).rejects.toThrow('Login Failed')
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('Should work with a custom result type from strategy', async () => {
|
|
107
|
+
await usingAsync(new Injector(), async (i) => {
|
|
108
|
+
await setupInjector(i, 'testuser', 'testpass')
|
|
109
|
+
const tokenStrategy: LoginResponseStrategy<{ accessToken: string }> = {
|
|
110
|
+
createLoginResponse: async () => JsonResult({ accessToken: 'tok123' }, 200),
|
|
111
|
+
}
|
|
112
|
+
const action = createPasswordLoginAction(tokenStrategy)
|
|
113
|
+
const result = await action({
|
|
114
|
+
injector: i,
|
|
115
|
+
request,
|
|
116
|
+
response,
|
|
117
|
+
getBody: () => Promise.resolve({ username: 'testuser', password: 'testpass' }),
|
|
118
|
+
})
|
|
119
|
+
expect(result.chunk.accessToken).toBe('tok123')
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { RequestError } from '@furystack/rest'
|
|
2
|
+
import { sleepAsync } from '@furystack/utils'
|
|
3
|
+
|
|
4
|
+
import { HttpUserContext } from '../http-user-context.js'
|
|
5
|
+
import type { LoginResponseStrategy } from '../login-response-strategy.js'
|
|
6
|
+
import type { RequestAction } from '../request-action-implementation.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates a login {@link RequestAction} that authenticates a user by
|
|
10
|
+
* username + password, then delegates session/token creation to the
|
|
11
|
+
* provided {@link LoginResponseStrategy}.
|
|
12
|
+
*
|
|
13
|
+
* The return type is inferred from the strategy:
|
|
14
|
+
* - Cookie strategy -> `ActionResult<User>`
|
|
15
|
+
* - JWT strategy -> `ActionResult<{ accessToken: string; refreshToken: string }>`
|
|
16
|
+
*
|
|
17
|
+
* A random delay (0-1 s) is added on failure to mitigate timing attacks.
|
|
18
|
+
*
|
|
19
|
+
* @param strategy The login response strategy that produces the session/token result
|
|
20
|
+
* @returns A `RequestAction` that can be wired into a REST API route
|
|
21
|
+
*/
|
|
22
|
+
export const createPasswordLoginAction = <TResult>(
|
|
23
|
+
strategy: LoginResponseStrategy<TResult>,
|
|
24
|
+
): RequestAction<{ result: TResult; body: { username: string; password: string } }> => {
|
|
25
|
+
return async ({ injector, getBody }) => {
|
|
26
|
+
const body = await getBody()
|
|
27
|
+
try {
|
|
28
|
+
const user = await injector.getInstance(HttpUserContext).authenticateUser(body.username, body.password)
|
|
29
|
+
return strategy.createLoginResponse(user, injector)
|
|
30
|
+
} catch {
|
|
31
|
+
await sleepAsync(Math.random() * 1000)
|
|
32
|
+
throw new RequestError('Login Failed', 400)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
import { User } from '@furystack/core'
|
|
2
|
-
import type { Constructable, Injector } from '@furystack/inject'
|
|
3
|
-
import { Injectable } from '@furystack/inject'
|
|
4
|
-
import type { DataSet } from '@furystack/repository'
|
|
5
|
-
import { getDataSetFor } from '@furystack/repository'
|
|
6
|
-
import type { AuthenticationProvider } from './authentication-providers/authentication-provider.js'
|
|
7
|
-
import { DefaultSession } from './models/default-session.js'
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Authentication settings object for FuryStack HTTP Api
|
|
11
|
-
*/
|
|
12
|
-
@Injectable({ lifetime: 'singleton' })
|
|
13
|
-
export class HttpAuthenticationSettings<TUser extends User, TSession extends DefaultSession> {
|
|
14
|
-
public model: Constructable<TUser> = User as Constructable<TUser>
|
|
15
|
-
|
|
16
|
-
public getUserDataSet = (injector: Injector) => getDataSetFor(injector, User, 'username')
|
|
17
|
-
|
|
18
|
-
public getSessionDataSet: (injector: Injector) => DataSet<TSession, keyof TSession> = (injector) =>
|
|
19
|
-
getDataSetFor(injector, DefaultSession, 'sessionId') as unknown as DataSet<TSession, keyof TSession>
|
|
20
|
-
|
|
21
|
-
public cookieName = 'fss'
|
|
22
|
-
public enableBasicAuth = true
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Ordered list of authentication providers. Populated by {@link useHttpAuthentication}
|
|
26
|
-
* and extended by `useJwtAuthentication()` or other auth plugins.
|
|
27
|
-
* Safe to mutate only during setup, before the first request is served.
|
|
28
|
-
*/
|
|
29
|
-
public authenticationProviders: AuthenticationProvider[] = []
|
|
30
|
-
}
|
|
1
|
+
import { User } from '@furystack/core'
|
|
2
|
+
import type { Constructable, Injector } from '@furystack/inject'
|
|
3
|
+
import { Injectable } from '@furystack/inject'
|
|
4
|
+
import type { DataSet } from '@furystack/repository'
|
|
5
|
+
import { getDataSetFor } from '@furystack/repository'
|
|
6
|
+
import type { AuthenticationProvider } from './authentication-providers/authentication-provider.js'
|
|
7
|
+
import { DefaultSession } from './models/default-session.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Authentication settings object for FuryStack HTTP Api
|
|
11
|
+
*/
|
|
12
|
+
@Injectable({ lifetime: 'singleton' })
|
|
13
|
+
export class HttpAuthenticationSettings<TUser extends User, TSession extends DefaultSession> {
|
|
14
|
+
public model: Constructable<TUser> = User as Constructable<TUser>
|
|
15
|
+
|
|
16
|
+
public getUserDataSet = (injector: Injector) => getDataSetFor(injector, User, 'username')
|
|
17
|
+
|
|
18
|
+
public getSessionDataSet: (injector: Injector) => DataSet<TSession, keyof TSession> = (injector) =>
|
|
19
|
+
getDataSetFor(injector, DefaultSession, 'sessionId') as unknown as DataSet<TSession, keyof TSession>
|
|
20
|
+
|
|
21
|
+
public cookieName = 'fss'
|
|
22
|
+
public enableBasicAuth = true
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Ordered list of authentication providers. Populated by {@link useHttpAuthentication}
|
|
26
|
+
* and extended by `useJwtAuthentication()` or other auth plugins.
|
|
27
|
+
* Safe to mutate only during setup, before the first request is served.
|
|
28
|
+
*/
|
|
29
|
+
public authenticationProviders: AuthenticationProvider[] = []
|
|
30
|
+
}
|