@furystack/rest-service 14.0.0 → 14.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 (143) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/esm/actions/error-action.d.ts +7 -2
  3. package/esm/actions/error-action.d.ts.map +1 -1
  4. package/esm/actions/error-action.js +7 -2
  5. package/esm/actions/error-action.js.map +1 -1
  6. package/esm/actions/get-current-user.d.ts +1 -4
  7. package/esm/actions/get-current-user.d.ts.map +1 -1
  8. package/esm/actions/get-current-user.js +1 -4
  9. package/esm/actions/get-current-user.js.map +1 -1
  10. package/esm/actions/is-authenticated.d.ts +3 -4
  11. package/esm/actions/is-authenticated.d.ts.map +1 -1
  12. package/esm/actions/is-authenticated.js +3 -4
  13. package/esm/actions/is-authenticated.js.map +1 -1
  14. package/esm/actions/logout.d.ts +2 -6
  15. package/esm/actions/logout.d.ts.map +1 -1
  16. package/esm/actions/logout.js +2 -6
  17. package/esm/actions/logout.js.map +1 -1
  18. package/esm/actions/not-found-action.d.ts +1 -3
  19. package/esm/actions/not-found-action.d.ts.map +1 -1
  20. package/esm/actions/not-found-action.js +1 -3
  21. package/esm/actions/not-found-action.js.map +1 -1
  22. package/esm/actions/password-login-action.d.ts +5 -12
  23. package/esm/actions/password-login-action.d.ts.map +1 -1
  24. package/esm/actions/password-login-action.js +5 -12
  25. package/esm/actions/password-login-action.js.map +1 -1
  26. package/esm/authenticate.d.ts +5 -11
  27. package/esm/authenticate.d.ts.map +1 -1
  28. package/esm/authenticate.js +5 -11
  29. package/esm/authenticate.js.map +1 -1
  30. package/esm/authentication-providers/authentication-provider.d.ts +17 -0
  31. package/esm/authentication-providers/authentication-provider.d.ts.map +1 -1
  32. package/esm/authentication-providers/basic-auth-provider.d.ts +7 -0
  33. package/esm/authentication-providers/basic-auth-provider.d.ts.map +1 -1
  34. package/esm/authentication-providers/basic-auth-provider.js +7 -0
  35. package/esm/authentication-providers/basic-auth-provider.js.map +1 -1
  36. package/esm/authentication-providers/cookie-auth-provider.d.ts.map +1 -1
  37. package/esm/authentication-providers/cookie-auth-provider.js +8 -0
  38. package/esm/authentication-providers/cookie-auth-provider.js.map +1 -1
  39. package/esm/authorize.d.ts +5 -17
  40. package/esm/authorize.d.ts.map +1 -1
  41. package/esm/authorize.js +5 -17
  42. package/esm/authorize.js.map +1 -1
  43. package/esm/get-schema-from-api.d.ts.map +1 -1
  44. package/esm/get-schema-from-api.js +1 -0
  45. package/esm/get-schema-from-api.js.map +1 -1
  46. package/esm/helpers.d.ts +6 -6
  47. package/esm/helpers.d.ts.map +1 -1
  48. package/esm/helpers.js +3 -4
  49. package/esm/helpers.js.map +1 -1
  50. package/esm/http-authentication-settings.d.ts +27 -6
  51. package/esm/http-authentication-settings.d.ts.map +1 -1
  52. package/esm/http-authentication-settings.js +6 -0
  53. package/esm/http-authentication-settings.js.map +1 -1
  54. package/esm/http-server-pool.spec.js.map +1 -1
  55. package/esm/http-user-context.d.ts +8 -5
  56. package/esm/http-user-context.d.ts.map +1 -1
  57. package/esm/http-user-context.js +26 -27
  58. package/esm/http-user-context.js.map +1 -1
  59. package/esm/http-user-context.spec.js +48 -6
  60. package/esm/http-user-context.spec.js.map +1 -1
  61. package/esm/identity-cache-keys.d.ts +28 -0
  62. package/esm/identity-cache-keys.d.ts.map +1 -0
  63. package/esm/identity-cache-keys.js +13 -0
  64. package/esm/identity-cache-keys.js.map +1 -0
  65. package/esm/identity-cache-keys.spec.d.ts +2 -0
  66. package/esm/identity-cache-keys.spec.d.ts.map +1 -0
  67. package/esm/identity-cache-keys.spec.js +21 -0
  68. package/esm/identity-cache-keys.spec.js.map +1 -0
  69. package/esm/identity-event-bus.d.ts +102 -0
  70. package/esm/identity-event-bus.d.ts.map +1 -0
  71. package/esm/identity-event-bus.js +151 -0
  72. package/esm/identity-event-bus.js.map +1 -0
  73. package/esm/identity-event-bus.spec.d.ts +2 -0
  74. package/esm/identity-event-bus.spec.d.ts.map +1 -0
  75. package/esm/identity-event-bus.spec.js +392 -0
  76. package/esm/identity-event-bus.spec.js.map +1 -0
  77. package/esm/index.d.ts +3 -0
  78. package/esm/index.d.ts.map +1 -1
  79. package/esm/index.js +3 -0
  80. package/esm/index.js.map +1 -1
  81. package/esm/login-response-strategy.d.ts.map +1 -1
  82. package/esm/login-response-strategy.js.map +1 -1
  83. package/esm/openapi/generate-openapi-document.spec.js.map +1 -1
  84. package/esm/proxy-manager.spec.js.map +1 -1
  85. package/esm/request-action-implementation.d.ts +25 -0
  86. package/esm/request-action-implementation.d.ts.map +1 -1
  87. package/esm/request-action-implementation.js +9 -0
  88. package/esm/request-action-implementation.js.map +1 -1
  89. package/esm/rest-api-runtime.spec.js.map +1 -1
  90. package/esm/server-telemetry.d.ts +6 -14
  91. package/esm/server-telemetry.d.ts.map +1 -1
  92. package/esm/server-telemetry.js +2 -3
  93. package/esm/server-telemetry.js.map +1 -1
  94. package/esm/user-resolution-cache.d.ts +65 -0
  95. package/esm/user-resolution-cache.d.ts.map +1 -0
  96. package/esm/user-resolution-cache.js +101 -0
  97. package/esm/user-resolution-cache.js.map +1 -0
  98. package/esm/user-resolution-cache.spec.d.ts +2 -0
  99. package/esm/user-resolution-cache.spec.d.ts.map +1 -0
  100. package/esm/user-resolution-cache.spec.js +220 -0
  101. package/esm/user-resolution-cache.spec.js.map +1 -0
  102. package/esm/user-store.d.ts.map +1 -1
  103. package/esm/user-store.js.map +1 -1
  104. package/esm/validate.d.ts +13 -12
  105. package/esm/validate.d.ts.map +1 -1
  106. package/esm/validate.integration.spec.js +1 -0
  107. package/esm/validate.integration.spec.js.map +1 -1
  108. package/esm/validate.js +12 -0
  109. package/esm/validate.js.map +1 -1
  110. package/package.json +15 -13
  111. package/src/actions/error-action.ts +7 -3
  112. package/src/actions/get-current-user.ts +1 -4
  113. package/src/actions/is-authenticated.ts +3 -4
  114. package/src/actions/logout.ts +2 -6
  115. package/src/actions/not-found-action.ts +1 -3
  116. package/src/actions/password-login-action.ts +5 -12
  117. package/src/authenticate.ts +5 -11
  118. package/src/authentication-providers/authentication-provider.ts +17 -0
  119. package/src/authentication-providers/basic-auth-provider.ts +7 -0
  120. package/src/authentication-providers/cookie-auth-provider.ts +8 -0
  121. package/src/authorize.ts +6 -17
  122. package/src/get-schema-from-api.ts +1 -0
  123. package/src/helpers.ts +11 -11
  124. package/src/http-authentication-settings.ts +34 -6
  125. package/src/http-server-pool.spec.ts +1 -1
  126. package/src/http-user-context.spec.ts +59 -11
  127. package/src/http-user-context.ts +38 -31
  128. package/src/identity-cache-keys.spec.ts +24 -0
  129. package/src/identity-cache-keys.ts +31 -0
  130. package/src/identity-event-bus.spec.ts +285 -0
  131. package/src/identity-event-bus.ts +230 -0
  132. package/src/index.ts +3 -0
  133. package/src/login-response-strategy.ts +1 -0
  134. package/src/openapi/generate-openapi-document.spec.ts +2 -2
  135. package/src/proxy-manager.spec.ts +6 -6
  136. package/src/request-action-implementation.ts +25 -0
  137. package/src/rest-api-runtime.spec.ts +1 -1
  138. package/src/server-telemetry.ts +6 -14
  139. package/src/user-resolution-cache.spec.ts +241 -0
  140. package/src/user-resolution-cache.ts +164 -0
  141. package/src/user-store.ts +1 -0
  142. package/src/validate.integration.spec.ts +1 -0
  143. package/src/validate.ts +14 -12
@@ -5,10 +5,14 @@ import { JsonResult } from '../request-action-implementation.js'
5
5
  import { SchemaValidationError } from '../schema-validator/schema-validation-error.js'
6
6
 
7
7
  /**
8
- * Action for unhandled (500) errors
9
- * Returns a serialized error instance in JSON format.
8
+ * Default error handler. Maps thrown errors to HTTP responses:
9
+ *
10
+ * - {@link SchemaValidationError} → 400 with `{ message, errors }`
11
+ * - {@link RequestError} → its `responseCode` with `{ message }`
12
+ * - {@link AuthorizationError} → 403 with `{ message }`
13
+ * - other `Error` → 500 with `{ message }`
14
+ * - non-Error throwables → 500 with a generic message
10
15
  */
11
-
12
16
  export const ErrorAction: RequestAction<{
13
17
  body: unknown
14
18
  result: { message: string }
@@ -4,10 +4,7 @@ import { Authenticate } from '../authenticate.js'
4
4
  import type { RequestAction } from '../request-action-implementation.js'
5
5
  import { JsonResult } from '../request-action-implementation.js'
6
6
 
7
- /**
8
- * Action that returns the current authenticated user
9
- * @param injector The injector from the current stack
10
- */
7
+ /** Returns the current authenticated user; 401 if no user is bound. */
11
8
  export const GetCurrentUser: RequestAction<{
12
9
  result: User
13
10
  }> = Authenticate()(async ({ injector }) => {
@@ -3,10 +3,9 @@ import type { RequestAction } from '../request-action-implementation.js'
3
3
  import { JsonResult } from '../request-action-implementation.js'
4
4
 
5
5
  /**
6
- * Action that returns if the current user is authenticated
7
- * @param options The options for the Custom Action
8
- * @param options.injector The Injector from the current context
9
- * @returns A standard authentication result
6
+ * Returns `{ isAuthenticated: boolean }`. Unauthenticated callers receive
7
+ * a 200 with `false` rather than a 401 — use this when the client needs to
8
+ * branch on auth state without triggering a re-login flow.
10
9
  */
11
10
  export const IsAuthenticated: RequestAction<{ result: { isAuthenticated: boolean } }> = async ({ injector }) => {
12
11
  const isAuthenticatedResult = await isAuthenticated(injector)
@@ -3,12 +3,8 @@ import type { RequestAction } from '../request-action-implementation.js'
3
3
  import { EmptyResult } from '../request-action-implementation.js'
4
4
 
5
5
  /**
6
- * Action that logs out the current user
7
- * @param root0 The Options object
8
- * @param root0.injector The injector from the context
9
- * @param root0.request The current Request object
10
- * @param root0.response The Response object
11
- * @returns An empty result that indicates the success
6
+ * Logs out via {@link HttpUserContext.cookieLogout} — clears the session
7
+ * cookie and removes the session row from the session DataSet.
12
8
  */
13
9
  export const LogoutAction: RequestAction<{ result: unknown }> = async ({ injector, request, response }) => {
14
10
  await injector.get(HttpUserContext).cookieLogout(request, response)
@@ -1,9 +1,7 @@
1
1
  import type { RequestAction } from '../request-action-implementation.js'
2
2
  import { JsonResult } from '../request-action-implementation.js'
3
3
 
4
- /**
5
- * @returns The standard Not Found action result
6
- */
4
+ /** Default `404` action. Returns `{ error: 'Content not found' }`. */
7
5
  export const NotFoundAction: RequestAction<{ result: { error: string } }> = async () => {
8
6
  return JsonResult(
9
7
  {
@@ -6,18 +6,11 @@ import type { LoginResponseStrategy } from '../login-response-strategy.js'
6
6
  import type { RequestAction } from '../request-action-implementation.js'
7
7
 
8
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
9
+ * Username + password login action. Authenticates via {@link HttpUserContext},
10
+ * then delegates session/token creation to `strategy`. The result type is
11
+ * inferred from the strategy (e.g. `User` for cookies, `{ accessToken,
12
+ * refreshToken }` for JWT). Adds a 0–1 s random delay on failure to mitigate
13
+ * timing attacks; rethrows as `RequestError(400)`.
21
14
  */
22
15
  export const createPasswordLoginAction = <TResult>(
23
16
  strategy: LoginResponseStrategy<TResult>,
@@ -5,21 +5,15 @@ import type { ActionResult, RequestAction, RequestActionOptions } from './reques
5
5
  import { JsonResult } from './request-action-implementation.js'
6
6
 
7
7
  /**
8
- * Higher-order function that wraps a request action to require authentication.
9
- * If the user is not authenticated, returns a 401 Unauthorized response.
10
- * Includes a random delay on unauthorized requests to mitigate timing attacks.
8
+ * Wraps a {@link RequestAction} to require authentication. Unauthenticated
9
+ * requests return `401 Unauthorized` (with `WWW-Authenticate: Basic` when
10
+ * the chain includes Basic Auth). Adds a 0–1 s random delay on rejection to
11
+ * mitigate timing attacks.
11
12
  *
12
- * @returns A function that wraps the provided action with authentication check
13
13
  * @example
14
14
  * ```ts
15
- * // Wrap an endpoint to require authentication
16
- * const protectedEndpoint = Authenticate()(myEndpoint)
17
- *
18
- * // Or use directly in API definition
19
15
  * api: {
20
- * GET: {
21
- * '/users': Authenticate()(getUsersEndpoint)
22
- * }
16
+ * GET: { '/users': Authenticate()(getUsersEndpoint) },
23
17
  * }
24
18
  * ```
25
19
  */
@@ -22,4 +22,21 @@ export type AuthenticationProvider = {
22
22
  */
23
23
  readonly name: string
24
24
  authenticate: (request: Pick<IncomingMessage, 'headers'>) => Promise<User | null>
25
+ /**
26
+ * Optional cache-key resolver consulted by the user-resolution cache before
27
+ * the provider chain is walked.
28
+ *
29
+ * Returns a stable, opaque string identifying the authenticated principal
30
+ * for the given request, or `null` when this provider does not apply to
31
+ * the request (or refuses to participate in caching — e.g. Basic Auth,
32
+ * which must verify the password on every request).
33
+ *
34
+ * The first non-null key returned by the configured providers is used as
35
+ * the cache key. Keys returned by different providers must not collide;
36
+ * built-in providers prefix their keys (`cookie:`, `jwt:`).
37
+ *
38
+ * If no provider returns a key, the request bypasses the cache entirely
39
+ * and the provider chain is walked on every call.
40
+ */
41
+ readonly getCacheKey?: (request: Pick<IncomingMessage, 'headers'>) => string | null
25
42
  }
@@ -3,6 +3,13 @@ import type { AuthenticationProvider } from './authentication-provider.js'
3
3
 
4
4
  /**
5
5
  * Creates an authentication provider for HTTP Basic Authentication.
6
+ *
7
+ * **Caching note:** this provider intentionally does **not** implement
8
+ * `getCacheKey`. Caching authenticated users by username would bypass
9
+ * password verification on every cache hit — the cache cannot revalidate the
10
+ * password without re-running the authenticator. Basic Auth requests
11
+ * therefore always walk the provider chain, which is also where the password
12
+ * is verified.
6
13
  * @param authenticateUser Callback that verifies credentials and returns a User or throws
7
14
  */
8
15
  export const createBasicAuthProvider = (
@@ -1,5 +1,6 @@
1
1
  import type { User } from '@furystack/core'
2
2
  import { UnauthenticatedError } from '@furystack/security'
3
+ import { sessionCacheKey, type SessionCacheKey } from '../identity-cache-keys.js'
3
4
  import type { DefaultSession } from '../models/default-session.js'
4
5
  import type { AuthenticationProvider } from './authentication-provider.js'
5
6
  import { extractSessionIdFromCookies } from './helpers.js'
@@ -23,4 +24,11 @@ export const createCookieAuthProvider = (
23
24
  if (!session) throw new UnauthenticatedError()
24
25
  return await getUserByName(session.username)
25
26
  },
27
+ // The session id alone uniquely identifies the principal — caching by it is
28
+ // safe because session invalidation (logout, expiry) drops the session row,
29
+ // and the TTL on the user-resolution cache bounds staleness across nodes.
30
+ getCacheKey: (request): SessionCacheKey | null => {
31
+ const sessionId = extractSessionIdFromCookies(request, cookieName)
32
+ return sessionId ? sessionCacheKey(sessionId) : null
33
+ },
26
34
  })
package/src/authorize.ts CHANGED
@@ -1,25 +1,14 @@
1
1
  import { AuthorizationError, isAuthorized } from '@furystack/core'
2
2
  import { sleepAsync } from '@furystack/utils'
3
+ import type { Authenticate } from './authenticate.js'
3
4
  import type { ActionResult, RequestAction, RequestActionOptions } from './request-action-implementation.js'
4
5
 
5
6
  /**
6
- * Higher-order function that wraps a request action to require specific roles.
7
- * If the user does not have the required roles, throws an AuthorizationError.
8
- * Includes a random delay on unauthorized requests to mitigate timing attacks.
9
- *
10
- * @param roles - The roles required to access the endpoint
11
- * @returns A function that wraps the provided action with authorization check
12
- * @example
13
- * ```ts
14
- * // Require 'admin' role
15
- * const adminEndpoint = Authorize('admin')(myEndpoint)
16
- *
17
- * // Require multiple roles (user must have ALL specified roles)
18
- * const superAdminEndpoint = Authorize('admin', 'superuser')(myEndpoint)
19
- *
20
- * // Chain with Authenticate for authentication + authorization
21
- * const protectedEndpoint = Authenticate()(Authorize('admin')(myEndpoint))
22
- * ```
7
+ * Wraps a {@link RequestAction} to require **all** specified roles. Throws
8
+ * {@link AuthorizationError} (translated to 403 by `ErrorAction`) when any
9
+ * role is missing. Adds a 0–1 s random delay on rejection to mitigate
10
+ * timing attacks. Compose with {@link Authenticate} to require both:
11
+ * `Authenticate()(Authorize('admin')(myEndpoint))`.
23
12
  */
24
13
  export const Authorize =
25
14
  (...roles: string[]) =>
@@ -61,6 +61,7 @@ export const getSchemaFromApi = <T extends RestApiImplementation<RestApi>>({
61
61
  if (!endpoints[methodKey]) {
62
62
  endpoints[methodKey] = {}
63
63
  }
64
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- false positive: TS narrows `endpointList` to a union the iterator can't index without this cast
64
65
  Object.entries(endpointList as Record<string, RequestAction<{ result: unknown }>>).forEach(
65
66
  ([url, requestAction]) => {
66
67
  if (method && url && requestAction) {
package/src/helpers.ts CHANGED
@@ -1,6 +1,5 @@
1
- import { useSystemIdentityContext, type User } from '@furystack/core'
1
+ import { useSystemIdentityContext } from '@furystack/core'
2
2
  import type { Injector } from '@furystack/inject'
3
- import type { DataSet } from '@furystack/repository'
4
3
  import type { RestApi } from '@furystack/rest'
5
4
  import { PasswordAuthenticator } from '@furystack/security'
6
5
  import { PathHelper } from '@furystack/utils'
@@ -18,10 +17,12 @@ import { buildProxyServerApi, type ProxyOptions } from './proxy-runtime.js'
18
17
  import { compileApi, onRestApiMessage, type RestApiImplementation, shouldExecRequest } from './rest-api-runtime.js'
19
18
  import { ServerTelemetryToken } from './server-telemetry.js'
20
19
  import { buildStaticSiteServerApi, type StaticServerOptions } from './static-site-runtime.js'
20
+ import type { SessionStore, UserStore } from './user-store.js'
21
21
 
22
22
  /**
23
- * Options accepted by {@link useRestService}. Mirrors the pre-migration
24
- * shape, minus fields that have moved to tokens.
23
+ * Options accepted by {@link useRestService}. Cross-cutting concerns
24
+ * (authentication, telemetry) have moved to DI tokens — they are not
25
+ * configured here.
25
26
  */
26
27
  export interface ImplementApiOptions<T extends RestApi> {
27
28
  /** The structure of the implemented API. */
@@ -49,11 +50,10 @@ export interface ImplementApiOptions<T extends RestApi> {
49
50
  }
50
51
 
51
52
  /**
52
- * Acquires a pooled HTTP server, compiles the user-supplied API definition
53
+ * Acquires a pooled HTTP server (creating one if needed), compiles `api`
53
54
  * and attaches a {@link ServerApi} that dispatches matching requests.
54
- *
55
- * Public contract is unchanged from the pre-migration release; internally
56
- * everything goes through the functional tokens.
55
+ * Multiple `useRestService` calls on the same `port` + `hostName` share a
56
+ * single underlying HTTP server.
57
57
  */
58
58
  export const useRestService = async <T extends RestApi>(options: ImplementApiOptions<T>): Promise<ServerApi> => {
59
59
  const {
@@ -71,14 +71,14 @@ export const useRestService = async <T extends RestApi>(options: ImplementApiOpt
71
71
  } = options
72
72
 
73
73
  const extendedApi: typeof api = enableGetSchema
74
- ? ({
74
+ ? {
75
75
  ...api,
76
76
  GET: {
77
77
  ...api.GET,
78
78
  '/schema': CreateGetSchemaAction(api, name, description, version),
79
79
  '/openapi.json': CreateGetOpenApiDocumentAction(api, name, description, version),
80
80
  },
81
- } as typeof api)
81
+ }
82
82
  : api
83
83
 
84
84
  const supportedMethods = Object.keys(extendedApi)
@@ -129,7 +129,7 @@ export const useHttpAuthentication = (injector: Injector, overrides?: Partial<Ht
129
129
  }
130
130
  const systemInjector = useSystemIdentityContext({ injector, username: 'useHttpAuthentication' })
131
131
  const passwordAuthenticator = injector.get(PasswordAuthenticator)
132
- const userDataSet = systemInjector.get(mergedSettings.userDataSet) as unknown as DataSet<User, 'username'>
132
+ const userDataSet = systemInjector.get(mergedSettings.userDataSet)
133
133
  const sessionDataSet = systemInjector.get(mergedSettings.sessionDataSet)
134
134
 
135
135
  const providers: AuthenticationProvider[] = []
@@ -1,7 +1,10 @@
1
1
  import type { User } from '@furystack/core'
2
+ import type { Injector } from '@furystack/inject'
2
3
  import { defineService, type Token } from '@furystack/inject'
3
4
  import type { DataSetToken } from '@furystack/repository'
4
5
  import type { AuthenticationProvider } from './authentication-providers/authentication-provider.js'
6
+ import type { useHttpAuthentication } from './helpers.js'
7
+ import type { HttpUserContext } from './http-user-context.js'
5
8
  import type { DefaultSession } from './models/default-session.js'
6
9
  import { SessionDataSet, UserDataSet } from './user-store.js'
7
10
 
@@ -10,12 +13,10 @@ import { SessionDataSet, UserDataSet } from './user-store.js'
10
13
  * during application setup and consumed by {@link HttpUserContext},
11
14
  * cookie-login strategies and OpenAPI generators.
12
15
  *
13
- * The field shape reflects the functional-DI redesign:
14
- *
15
- * - `userDataSet` / `sessionDataSet` are token references (not accessors).
16
- * Consumers resolve them through the injector at the appropriate scope.
17
- * - `authenticationProviders` is a simple ordered list; `HttpUserContext`
18
- * walks it on every unauthenticated request.
16
+ * `userDataSet` / `sessionDataSet` are token references — consumers resolve
17
+ * them through the injector at the appropriate scope. `authenticationProviders`
18
+ * is an ordered list walked by {@link HttpUserContext} on every
19
+ * unauthenticated request.
19
20
  */
20
21
  export interface HttpAuthenticationSettings {
21
22
  /** Data set token for user lookups. */
@@ -32,8 +33,33 @@ export interface HttpAuthenticationSettings {
32
33
  * `useJwtAuthentication`.
33
34
  */
34
35
  authenticationProviders: AuthenticationProvider[]
36
+ /**
37
+ * TTL applied to the user-resolution cache. Every authenticated request
38
+ * runs at most one provider chain walk per cache key per TTL window;
39
+ * subsequent calls within the window return the cached `User`.
40
+ *
41
+ * Bounds the staleness window for out-of-band identity changes
42
+ * (session invalidation on a sibling instance, role mutation in storage,
43
+ * etc.) when the application is deployed across multiple nodes.
44
+ *
45
+ * Set to `0` to disable caching entirely. When omitted, the default
46
+ * {@link DEFAULT_USER_CACHE_TTL_MS} is used by `UserResolutionCache`.
47
+ */
48
+ userCacheTtlMs?: number
49
+ /**
50
+ * Maximum number of entries in the user-resolution cache before LRU
51
+ * eviction kicks in. When omitted, the default
52
+ * {@link DEFAULT_USER_CACHE_CAPACITY} is used by `UserResolutionCache`.
53
+ */
54
+ userCacheCapacity?: number
35
55
  }
36
56
 
57
+ /** Default TTL applied to the user-resolution cache (30 seconds). */
58
+ export const DEFAULT_USER_CACHE_TTL_MS = 30_000
59
+
60
+ /** Default LRU capacity ceiling for the user-resolution cache. */
61
+ export const DEFAULT_USER_CACHE_CAPACITY = 10_000
62
+
37
63
  /**
38
64
  * Returns a fresh copy of the default {@link HttpAuthenticationSettings}.
39
65
  * Used as the starting point inside {@link useHttpAuthentication}.
@@ -44,6 +70,8 @@ export const defaultHttpAuthenticationSettings = (): HttpAuthenticationSettings
44
70
  cookieName: 'fss',
45
71
  enableBasicAuth: true,
46
72
  authenticationProviders: [],
73
+ userCacheTtlMs: DEFAULT_USER_CACHE_TTL_MS,
74
+ userCacheCapacity: DEFAULT_USER_CACHE_CAPACITY,
47
75
  })
48
76
 
49
77
  /**
@@ -179,7 +179,7 @@ describe('HttpServerPool', () => {
179
179
  cb?.(closeError)
180
180
  return record.server
181
181
  })
182
- record.server.close = closeSpy as unknown as typeof record.server.close
182
+ record.server.close = closeSpy
183
183
  ;(record.server as unknown as { off: (...args: unknown[]) => void }).off = () => undefined
184
184
 
185
185
  await expect(injector[Symbol.asyncDispose]()).resolves.toBeUndefined()
@@ -185,7 +185,7 @@ describe('HttpUserContext', () => {
185
185
  await setupUser(i, 'testuser', 'password')
186
186
  const result = await i.get(HttpUserContext).authenticateRequest({
187
187
  headers: { authorization: `Basic dGVzdHVzZXI6cGFzc3dvcmQ=` },
188
- } as IncomingMessage)
188
+ })
189
189
  expect(result.username).toBe('testuser')
190
190
  })
191
191
  })
@@ -197,7 +197,7 @@ describe('HttpUserContext', () => {
197
197
  await expect(
198
198
  i.get(HttpUserContext).authenticateRequest({
199
199
  headers: { authorization: `Basic dGVzdHVzZXI6cGFzc3dvcmQ=` },
200
- } as IncomingMessage),
200
+ }),
201
201
  ).rejects.toBeInstanceOf(UnauthenticatedError)
202
202
  })
203
203
  })
@@ -209,7 +209,7 @@ describe('HttpUserContext', () => {
209
209
  await expect(
210
210
  ctx.authenticateRequest({
211
211
  headers: { cookie: `${ctx.authentication.cookieName}=666;` },
212
- } as IncomingMessage),
212
+ }),
213
213
  ).rejects.toBeInstanceOf(UnauthenticatedError)
214
214
  })
215
215
  })
@@ -223,7 +223,7 @@ describe('HttpUserContext', () => {
223
223
 
224
224
  const result = await ctx.authenticateRequest({
225
225
  headers: { cookie: `${ctx.authentication.cookieName}=666;` },
226
- } as IncomingMessage)
226
+ })
227
227
  expect(result).toEqual(testUser)
228
228
  })
229
229
  })
@@ -273,26 +273,33 @@ describe('HttpUserContext', () => {
273
273
  })
274
274
 
275
275
  describe('getCurrentUser', () => {
276
- it('caches the result of authenticateRequest within the request scope', async () => {
276
+ it('caches the result of authenticateRequest by session id when the cookie provider yields a key', async () => {
277
277
  await usingAsync(createInjector(), async (i) => {
278
278
  prepareInjector(i)
279
279
  const ctx = i.get(HttpUserContext)
280
+ const cookiedRequest = {
281
+ headers: { cookie: `${ctx.authentication.cookieName}=session-1;` },
282
+ } as IncomingMessage
280
283
  ctx.authenticateRequest = vi.fn(async () => testUser)
281
- const first = await ctx.getCurrentUser(request)
282
- const second = await ctx.getCurrentUser(request)
284
+ const first = await ctx.getCurrentUser(cookiedRequest)
285
+ const second = await ctx.getCurrentUser(cookiedRequest)
283
286
  expect(ctx.authenticateRequest).toHaveBeenCalledTimes(1)
284
287
  expect(first).toBe(testUser)
285
288
  expect(second).toBe(testUser)
286
289
  })
287
290
  })
288
291
 
289
- it('does not leak a cached user between distinct requests on the same instance', async () => {
292
+ it('treats distinct sessions as distinct cache entries', async () => {
290
293
  await usingAsync(createInjector(), async (i) => {
291
294
  prepareInjector(i)
292
295
  const ctx = i.get(HttpUserContext)
293
296
  const otherUser: User = { username: 'otherUser', roles: [] }
294
- const requestA = { headers: {} } as IncomingMessage
295
- const requestB = { headers: {} } as IncomingMessage
297
+ const requestA = {
298
+ headers: { cookie: `${ctx.authentication.cookieName}=session-a;` },
299
+ } as IncomingMessage
300
+ const requestB = {
301
+ headers: { cookie: `${ctx.authentication.cookieName}=session-b;` },
302
+ } as IncomingMessage
296
303
  const authenticateRequest = vi
297
304
  .fn<(req: Pick<IncomingMessage, 'headers'>) => Promise<User>>()
298
305
  .mockResolvedValueOnce(testUser)
@@ -305,6 +312,47 @@ describe('HttpUserContext', () => {
305
312
  })
306
313
  })
307
314
 
315
+ it('bypasses the cache when no provider yields a key (e.g. Basic Auth, anonymous)', async () => {
316
+ await usingAsync(createInjector(), async (i) => {
317
+ prepareInjector(i)
318
+ const ctx = i.get(HttpUserContext)
319
+ const anonRequest = { headers: {} } as IncomingMessage
320
+ const authenticateRequest = vi
321
+ .fn<(req: Pick<IncomingMessage, 'headers'>) => Promise<User>>()
322
+ .mockResolvedValue(testUser)
323
+ ctx.authenticateRequest = authenticateRequest
324
+ await ctx.getCurrentUser(anonRequest)
325
+ await ctx.getCurrentUser(anonRequest)
326
+ expect(authenticateRequest).toHaveBeenCalledTimes(2)
327
+ })
328
+ })
329
+
330
+ it('drops the local cache entry on cookieLogout so the next request re-authenticates', async () => {
331
+ await usingAsync(createInjector(), async (i) => {
332
+ prepareInjector(i)
333
+ const ctx = i.get(HttpUserContext)
334
+ const sessionId = 'session-logout'
335
+ const cookiedRequest = {
336
+ headers: { cookie: `${ctx.authentication.cookieName}=${sessionId};` },
337
+ } as IncomingMessage
338
+ await i.get(SessionStore).add({ sessionId, username: testUser.username })
339
+ await i.get(UserStore).add(testUser)
340
+
341
+ const authenticateRequest = vi
342
+ .fn<(req: Pick<IncomingMessage, 'headers'>) => Promise<User>>()
343
+ .mockResolvedValue(testUser)
344
+ ctx.authenticateRequest = authenticateRequest
345
+
346
+ await ctx.getCurrentUser(cookiedRequest)
347
+ expect(authenticateRequest).toHaveBeenCalledTimes(1)
348
+
349
+ await ctx.cookieLogout(cookiedRequest, { setHeader: vi.fn() })
350
+
351
+ await ctx.getCurrentUser(cookiedRequest)
352
+ expect(authenticateRequest).toHaveBeenCalledTimes(2)
353
+ })
354
+ })
355
+
308
356
  it('re-walks providers after a failed authenticateRequest', async () => {
309
357
  await usingAsync(createInjector(), async (i) => {
310
358
  prepareInjector(i)
@@ -358,7 +406,7 @@ describe('HttpUserContext', () => {
358
406
 
359
407
  const logoutSetHeader = vi.fn()
360
408
  await ctx.cookieLogout(
361
- { headers: { cookie: `${ctx.authentication.cookieName}=${sessionId};` } } as IncomingMessage,
409
+ { headers: { cookie: `${ctx.authentication.cookieName}=${sessionId};` } },
362
410
  { setHeader: logoutSetHeader },
363
411
  )
364
412
  expect(logoutSetHeader).toHaveBeenCalledWith(
@@ -6,8 +6,11 @@ import { PasswordAuthenticator, UnauthenticatedError } from '@furystack/security
6
6
  import { EventHub, type ListenerErrorPayload } from '@furystack/utils'
7
7
  import { randomBytes } from 'crypto'
8
8
  import type { IncomingMessage } from 'http'
9
+ import type { AuthenticationProvider } from './authentication-providers/authentication-provider.js'
9
10
  import { extractSessionIdFromCookies } from './authentication-providers/helpers.js'
10
11
  import { HttpAuthenticationSettings } from './http-authentication-settings.js'
12
+ import { IdentityEventBus } from './identity-event-bus.js'
13
+ import { resolveUserCacheKey, UserResolutionCache } from './user-resolution-cache.js'
11
14
 
12
15
  /**
13
16
  * Events emitted by a per-request {@link HttpUserContext} instance. Since
@@ -24,11 +27,14 @@ export type HttpUserContextEvents = {
24
27
  * Request-scoped authentication/authorization facade.
25
28
  *
26
29
  * `HttpUserContext` consolidates identity lookup, authentication-provider
27
- * walk-through and cookie session helpers behind a single object. Resolved
28
- * users are memoized per `request.headers` via a {@link WeakMap} so repeated
29
- * calls within the same request skip re-authentication — and so ancestor
30
- * scopes caching the same `HttpUserContext` instance cannot leak an
31
- * authenticated user into sibling requests.
30
+ * walk-through and cookie session helpers behind a single object.
31
+ * Successful resolutions are memoized in the singleton
32
+ * {@link UserResolutionCache} keyed by the cache key produced by the
33
+ * matching {@link AuthenticationProvider} (e.g. `cookie:${sessionId}`
34
+ * for cookie auth) and expire after
35
+ * {@link HttpAuthenticationSettings.userCacheTtlMs}. Requests whose
36
+ * providers all opt out of caching (Basic Auth, JWT Bearer, anonymous
37
+ * traffic) bypass the cache and walk the provider chain on every call.
32
38
  */
33
39
  export interface HttpUserContext extends EventHub<HttpUserContextEvents> {
34
40
  /** The active {@link HttpAuthenticationSettings} captured at resolve time. */
@@ -55,29 +61,27 @@ export interface HttpUserContext extends EventHub<HttpUserContextEvents> {
55
61
  }
56
62
 
57
63
  /**
58
- * Internal concrete EventHub-backed {@link HttpUserContext}. Exposed via
59
- * the {@link HttpUserContextToken} token; constructed by its factory.
64
+ * Internal concrete {@link EventHub}-backed implementation of
65
+ * {@link HttpUserContext}. Exposed via the `HttpUserContext` token;
66
+ * constructed by its factory.
60
67
  */
61
68
  class HttpUserContextImpl extends EventHub<HttpUserContextEvents> implements HttpUserContext {
62
- /**
63
- * Per-request user cache keyed by the request's `headers` object identity.
64
- * Each incoming HTTP/WS message allocates its own `headers` object, so
65
- * entries here never leak between requests — even when this
66
- * `HttpUserContext` instance happens to be cached by an ancestor scope
67
- * (e.g. because setup code resolved it on the root injector before any
68
- * per-request child scope existed).
69
- */
70
- private readonly userCache = new WeakMap<object, Promise<User>>()
71
-
72
69
  /**
73
70
  * `authenticator` is resolved lazily so that tests (and applications) that
74
71
  * never invoke `authenticateUser` don't need to bind password-credential
75
72
  * stores.
73
+ *
74
+ * The shared {@link UserResolutionCache} memoizes successful authentications
75
+ * across requests within the process. Cache keys are produced by the
76
+ * configured providers via `getCacheKey`; requests whose providers all
77
+ * opt out (e.g. Basic Auth, anonymous traffic) bypass the cache.
76
78
  */
77
79
  constructor(
78
80
  public readonly authentication: HttpAuthenticationSettings,
79
81
  private readonly resolveAuthenticator: () => PasswordAuthenticator,
80
82
  private readonly systemInjector: Injector,
83
+ private readonly userCache: UserResolutionCache,
84
+ private readonly identityEventBus: IdentityEventBus,
81
85
  ) {
82
86
  super()
83
87
  }
@@ -114,19 +118,11 @@ class HttpUserContextImpl extends EventHub<HttpUserContextEvents> implements Htt
114
118
  }
115
119
 
116
120
  public async getCurrentUser(request: Pick<IncomingMessage, 'headers'>): Promise<User> {
117
- const key = request.headers
118
- const cached = this.userCache.get(key)
119
- if (cached) {
120
- return cached
121
- }
122
- const pending = this.authenticateRequest(request)
123
- this.userCache.set(key, pending)
124
- try {
125
- return await pending
126
- } catch (error) {
127
- this.userCache.delete(key)
128
- throw error
121
+ const cacheKey = resolveUserCacheKey(this.authentication, request)
122
+ if (!cacheKey) {
123
+ return this.authenticateRequest(request)
129
124
  }
125
+ return this.userCache.resolve(cacheKey, () => this.authenticateRequest(request))
130
126
  }
131
127
 
132
128
  public getSessionIdFromRequest(request: Pick<IncomingMessage, 'headers'>): string | null {
@@ -157,7 +153,6 @@ class HttpUserContextImpl extends EventHub<HttpUserContextEvents> implements Htt
157
153
  request: Pick<IncomingMessage, 'headers'>,
158
154
  response: { setHeader: (header: string, value: string) => void },
159
155
  ): Promise<void> {
160
- this.userCache.delete(request.headers)
161
156
  const sessionId = this.getSessionIdFromRequest(request)
162
157
  response.setHeader('Set-Cookie', `${this.authentication.cookieName}=; Path=/; HttpOnly`)
163
158
 
@@ -167,6 +162,10 @@ class HttpUserContextImpl extends EventHub<HttpUserContextEvents> implements Htt
167
162
  filter: { sessionId: { $eq: sessionId } },
168
163
  })
169
164
  await sessionDataSet.remove(this.systemInjector, ...sessions.map((s) => s.sessionId))
165
+ // Cross-node identity invalidation: the facade applies the local cache
166
+ // drop synchronously, then broadcasts so siblings collapse the
167
+ // staleness window from `userCacheTtlMs` to bus latency.
168
+ await this.identityEventBus.publish({ type: 'userLoggedOut', sessionId })
170
169
  }
171
170
  this.emit('onLogout', undefined)
172
171
  }
@@ -193,10 +192,18 @@ export const HttpUserContext: Token<HttpUserContext, 'scoped'> = defineService({
193
192
  lifetime: 'scoped',
194
193
  factory: ({ inject, injector, onDispose }): HttpUserContext => {
195
194
  const authentication = inject(HttpAuthenticationSettings)
195
+ const userCache = inject(UserResolutionCache)
196
+ const identityEventBus = inject(IdentityEventBus)
196
197
  const systemInjector = useSystemIdentityContext({ injector, username: 'HttpUserContext' })
197
198
  onDispose(() => systemInjector[Symbol.asyncDispose]())
198
199
  // Password authenticator resolution is deferred: tests that exercise only
199
200
  // unauthenticated endpoints never have to bind credential stores.
200
- return new HttpUserContextImpl(authentication, () => injector.get(PasswordAuthenticator), systemInjector)
201
+ return new HttpUserContextImpl(
202
+ authentication,
203
+ () => injector.get(PasswordAuthenticator),
204
+ systemInjector,
205
+ userCache,
206
+ identityEventBus,
207
+ )
201
208
  },
202
209
  })