@furystack/rest-service 12.0.0 → 12.1.1

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 (50) hide show
  1. package/CHANGELOG.md +58 -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/helpers.d.ts.map +1 -1
  19. package/esm/helpers.js +0 -2
  20. package/esm/helpers.js.map +1 -1
  21. package/esm/http-authentication-settings.d.ts +3 -3
  22. package/esm/http-authentication-settings.d.ts.map +1 -1
  23. package/esm/http-authentication-settings.js +3 -2
  24. package/esm/http-authentication-settings.js.map +1 -1
  25. package/esm/http-user-context.d.ts +1 -1
  26. package/esm/http-user-context.d.ts.map +1 -1
  27. package/esm/index.d.ts +1 -0
  28. package/esm/index.d.ts.map +1 -1
  29. package/esm/index.js +1 -0
  30. package/esm/index.js.map +1 -1
  31. package/esm/login-response-strategy.d.ts +28 -0
  32. package/esm/login-response-strategy.d.ts.map +1 -0
  33. package/esm/login-response-strategy.js +28 -0
  34. package/esm/login-response-strategy.js.map +1 -0
  35. package/esm/login-response-strategy.spec.d.ts +2 -0
  36. package/esm/login-response-strategy.spec.d.ts.map +1 -0
  37. package/esm/login-response-strategy.spec.js +78 -0
  38. package/esm/login-response-strategy.spec.js.map +1 -0
  39. package/package.json +7 -7
  40. package/src/actions/index.ts +1 -0
  41. package/src/actions/login.ts +12 -6
  42. package/src/actions/password-login-action.spec.ts +122 -0
  43. package/src/actions/password-login-action.ts +35 -0
  44. package/src/helpers.ts +1 -3
  45. package/src/http-authentication-settings.ts +32 -30
  46. package/src/http-user-context.spec.ts +462 -462
  47. package/src/http-user-context.ts +164 -164
  48. package/src/index.ts +1 -0
  49. package/src/login-response-strategy.spec.ts +90 -0
  50. package/src/login-response-strategy.ts +48 -0
@@ -0,0 +1,78 @@
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
+ import { useHttpAuthentication } from './helpers.js';
8
+ import { createCookieLoginStrategy } from './login-response-strategy.js';
9
+ import { DefaultSession } from './models/default-session.js';
10
+ const setupInjector = (i) => {
11
+ addStore(i, new InMemoryStore({ model: User, primaryKey: 'username' }))
12
+ .addStore(new InMemoryStore({ model: DefaultSession, primaryKey: 'sessionId' }))
13
+ .addStore(new InMemoryStore({ model: PasswordCredential, primaryKey: 'userName' }))
14
+ .addStore(new InMemoryStore({ model: PasswordResetToken, primaryKey: 'token' }));
15
+ const repo = getRepository(i);
16
+ repo.createDataSet(User, 'username');
17
+ repo.createDataSet(DefaultSession, 'sessionId');
18
+ repo.createDataSet(PasswordCredential, 'userName');
19
+ repo.createDataSet(PasswordResetToken, 'token');
20
+ usePasswordPolicy(i);
21
+ useHttpAuthentication(i);
22
+ };
23
+ describe('createCookieLoginStrategy', () => {
24
+ const testUser = { username: 'testuser', roles: ['admin'] };
25
+ it('Should return the user as the response body', async () => {
26
+ await usingAsync(new Injector(), async (i) => {
27
+ setupInjector(i);
28
+ const strategy = createCookieLoginStrategy(i);
29
+ const result = await strategy.createLoginResponse(testUser, i);
30
+ expect(result.chunk).toEqual(testUser);
31
+ });
32
+ });
33
+ it('Should return status code 200', async () => {
34
+ await usingAsync(new Injector(), async (i) => {
35
+ setupInjector(i);
36
+ const strategy = createCookieLoginStrategy(i);
37
+ const result = await strategy.createLoginResponse(testUser, i);
38
+ expect(result.statusCode).toBe(200);
39
+ });
40
+ });
41
+ it('Should include a Set-Cookie header with the session ID', async () => {
42
+ await usingAsync(new Injector(), async (i) => {
43
+ setupInjector(i);
44
+ const strategy = createCookieLoginStrategy(i);
45
+ const result = await strategy.createLoginResponse(testUser, i);
46
+ const setCookie = result.headers['Set-Cookie'];
47
+ expect(setCookie).toBeDefined();
48
+ expect(setCookie).toContain('fss=');
49
+ expect(setCookie).toContain('Path=/');
50
+ expect(setCookie).toContain('HttpOnly');
51
+ });
52
+ });
53
+ it('Should persist the session in the DataSet', async () => {
54
+ await usingAsync(new Injector(), async (i) => {
55
+ setupInjector(i);
56
+ const strategy = createCookieLoginStrategy(i);
57
+ await strategy.createLoginResponse(testUser, i);
58
+ const repo = getRepository(i);
59
+ const sessionDataSet = repo.getDataSetFor(DefaultSession, 'sessionId');
60
+ const sessions = await sessionDataSet.find(i, { filter: { username: { $eq: 'testuser' } } });
61
+ expect(sessions).toHaveLength(1);
62
+ expect(sessions[0].username).toBe('testuser');
63
+ expect(sessions[0].sessionId).toBeTruthy();
64
+ });
65
+ });
66
+ it('Should create unique session IDs for each call', async () => {
67
+ await usingAsync(new Injector(), async (i) => {
68
+ setupInjector(i);
69
+ const strategy = createCookieLoginStrategy(i);
70
+ const result1 = await strategy.createLoginResponse(testUser, i);
71
+ const result2 = await strategy.createLoginResponse(testUser, i);
72
+ const sessionId1 = result1.headers['Set-Cookie']?.split('=')[1]?.split(';')[0];
73
+ const sessionId2 = result2.headers['Set-Cookie']?.split('=')[1]?.split(';')[0];
74
+ expect(sessionId1).not.toBe(sessionId2);
75
+ });
76
+ });
77
+ });
78
+ //# sourceMappingURL=login-response-strategy.spec.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"login-response-strategy.spec.js","sourceRoot":"","sources":["../src/login-response-strategy.spec.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@furystack/rest-service",
3
- "version": "12.0.0",
3
+ "version": "12.1.1",
4
4
  "description": "REST API service implementation for FuryStack",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -39,19 +39,19 @@
39
39
  },
40
40
  "homepage": "https://github.com/furystack/furystack",
41
41
  "dependencies": {
42
- "@furystack/core": "^15.2.1",
42
+ "@furystack/core": "^15.2.2",
43
43
  "@furystack/inject": "^12.0.30",
44
- "@furystack/repository": "^10.1.2",
45
- "@furystack/rest": "^8.0.39",
46
- "@furystack/security": "^7.0.0",
44
+ "@furystack/repository": "^10.1.3",
45
+ "@furystack/rest": "^8.0.40",
46
+ "@furystack/security": "^7.0.1",
47
47
  "@furystack/utils": "^8.1.10",
48
48
  "ajv": "^8.18.0",
49
49
  "ajv-formats": "^3.0.1",
50
50
  "path-to-regexp": "^8.3.0"
51
51
  },
52
52
  "devDependencies": {
53
- "@furystack/rest-client-fetch": "^8.0.39",
54
- "@types/node": "^25.3.0",
53
+ "@furystack/rest-client-fetch": "^8.0.40",
54
+ "@types/node": "^25.3.1",
55
55
  "@types/ws": "^8.18.1",
56
56
  "typescript": "^5.9.3",
57
57
  "vitest": "^4.0.18",
@@ -4,3 +4,4 @@ export * from './is-authenticated.js'
4
4
  export * from './login.js'
5
5
  export * from './logout.js'
6
6
  export * from './not-found-action.js'
7
+ export * from './password-login-action.js'
@@ -1,15 +1,20 @@
1
- import { HttpUserContext } from '../http-user-context.js'
2
1
  import type { User } from '@furystack/core'
3
2
  import { RequestError } from '@furystack/rest'
3
+ import { sleepAsync } from '@furystack/utils'
4
+
5
+ import { HttpUserContext } from '../http-user-context.js'
4
6
  import type { RequestAction } from '../request-action-implementation.js'
5
7
  import { JsonResult } from '../request-action-implementation.js'
6
8
 
7
9
  /**
8
- * Action that logs in the current user
9
- * Should be called with a JSON Post body with ``username`` and ``password`` fields.
10
- * Returns the current user instance
10
+ * Action that logs in the current user.
11
+ * Should be called with a JSON POST body with `username` and `password` fields.
12
+ * Returns the current user instance.
13
+ *
14
+ * @deprecated Use `createPasswordLoginAction(createCookieLoginStrategy(injector))` instead.
15
+ * This static action resolves services from the request-scoped injector on
16
+ * every call; the factory approach captures them once at setup time.
11
17
  */
12
-
13
18
  export const LoginAction: RequestAction<{
14
19
  result: User
15
20
  body: { username: string; password: string }
@@ -20,7 +25,8 @@ export const LoginAction: RequestAction<{
20
25
  const user = await userContext.authenticateUser(body.username, body.password)
21
26
  await userContext.cookieLogin(user, response)
22
27
  return JsonResult(user, 200)
23
- } catch (error) {
28
+ } catch {
29
+ await sleepAsync(Math.random() * 1000)
24
30
  throw new RequestError('Login Failed', 400)
25
31
  }
26
32
  }
@@ -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
+ }
package/src/helpers.ts CHANGED
@@ -42,9 +42,7 @@ export const useHttpAuthentication = <TUser extends User, TSession extends Defau
42
42
  const mergedSettings = Object.assign(new HttpAuthenticationSettings<TUser, TSession>(), settings)
43
43
  const systemInjector = useSystemIdentityContext({ injector, username: 'useHttpAuthentication' })
44
44
  const passwordAuthenticator = injector.getInstance(PasswordAuthenticator)
45
- const userDataSet = mergedSettings.getUserDataSet(systemInjector)
46
- // Narrow from DataSet<TSession, keyof TSession> to DataSet<DefaultSession, 'sessionId'>
47
- // because the built-in providers operate on DefaultSession with the concrete 'sessionId' key
45
+ const userDataSet = mergedSettings.getUserDataSet(systemInjector) as unknown as DataSet<User, 'username'>
48
46
  const sessionDataSet = mergedSettings.getSessionDataSet(systemInjector) as unknown as DataSet<
49
47
  DefaultSession,
50
48
  'sessionId'
@@ -1,30 +1,32 @@
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 { getDataSetFor } from '@furystack/repository'
5
+ import type { AuthenticationProvider } from './authentication-providers/authentication-provider.js'
6
+ import { DefaultSession } from './models/default-session.js'
7
+
8
+ /**
9
+ * Authentication settings object for FuryStack HTTP Api
10
+ */
11
+ @Injectable({ lifetime: 'singleton' })
12
+ export class HttpAuthenticationSettings<TUser extends User, TSession extends DefaultSession> {
13
+ public model: Constructable<TUser> = User as Constructable<TUser>
14
+
15
+ public getUserDataSet = (injector: Injector) =>
16
+ getDataSetFor(injector, this.model, 'username' as 'username' & keyof TUser)
17
+
18
+ public sessionModel: Constructable<TSession> = DefaultSession as Constructable<TSession>
19
+
20
+ public getSessionDataSet = (injector: Injector) =>
21
+ getDataSetFor(injector, this.sessionModel, 'sessionId' as 'sessionId' & keyof TSession)
22
+
23
+ public cookieName = 'fss'
24
+ public enableBasicAuth = true
25
+
26
+ /**
27
+ * Ordered list of authentication providers. Populated by {@link useHttpAuthentication}
28
+ * and extended by `useJwtAuthentication()` or other auth plugins.
29
+ * Safe to mutate only during setup, before the first request is served.
30
+ */
31
+ public authenticationProviders: AuthenticationProvider[] = []
32
+ }