@furystack/rest-service 14.0.0 → 14.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 +107 -0
- package/esm/actions/error-action.d.ts +7 -2
- package/esm/actions/error-action.d.ts.map +1 -1
- package/esm/actions/error-action.js +7 -2
- package/esm/actions/error-action.js.map +1 -1
- package/esm/actions/get-current-user.d.ts +1 -4
- package/esm/actions/get-current-user.d.ts.map +1 -1
- package/esm/actions/get-current-user.js +1 -4
- package/esm/actions/get-current-user.js.map +1 -1
- package/esm/actions/is-authenticated.d.ts +3 -4
- package/esm/actions/is-authenticated.d.ts.map +1 -1
- package/esm/actions/is-authenticated.js +3 -4
- package/esm/actions/is-authenticated.js.map +1 -1
- package/esm/actions/logout.d.ts +2 -6
- package/esm/actions/logout.d.ts.map +1 -1
- package/esm/actions/logout.js +2 -6
- package/esm/actions/logout.js.map +1 -1
- package/esm/actions/not-found-action.d.ts +1 -3
- package/esm/actions/not-found-action.d.ts.map +1 -1
- package/esm/actions/not-found-action.js +1 -3
- package/esm/actions/not-found-action.js.map +1 -1
- package/esm/actions/password-login-action.d.ts +5 -12
- package/esm/actions/password-login-action.d.ts.map +1 -1
- package/esm/actions/password-login-action.js +5 -12
- package/esm/actions/password-login-action.js.map +1 -1
- package/esm/authenticate.d.ts +5 -11
- package/esm/authenticate.d.ts.map +1 -1
- package/esm/authenticate.js +5 -11
- package/esm/authenticate.js.map +1 -1
- package/esm/authentication-providers/authentication-provider.d.ts +17 -0
- package/esm/authentication-providers/authentication-provider.d.ts.map +1 -1
- package/esm/authentication-providers/basic-auth-provider.d.ts +7 -0
- package/esm/authentication-providers/basic-auth-provider.d.ts.map +1 -1
- package/esm/authentication-providers/basic-auth-provider.js +7 -0
- package/esm/authentication-providers/basic-auth-provider.js.map +1 -1
- package/esm/authentication-providers/cookie-auth-provider.d.ts.map +1 -1
- package/esm/authentication-providers/cookie-auth-provider.js +8 -0
- package/esm/authentication-providers/cookie-auth-provider.js.map +1 -1
- package/esm/authorize.d.ts +5 -17
- package/esm/authorize.d.ts.map +1 -1
- package/esm/authorize.js +5 -17
- package/esm/authorize.js.map +1 -1
- package/esm/get-schema-from-api.d.ts.map +1 -1
- package/esm/get-schema-from-api.js +1 -0
- package/esm/get-schema-from-api.js.map +1 -1
- package/esm/helpers.d.ts +6 -6
- package/esm/helpers.d.ts.map +1 -1
- package/esm/helpers.js +3 -4
- package/esm/helpers.js.map +1 -1
- package/esm/http-authentication-settings.d.ts +27 -6
- package/esm/http-authentication-settings.d.ts.map +1 -1
- package/esm/http-authentication-settings.js +6 -0
- package/esm/http-authentication-settings.js.map +1 -1
- package/esm/http-server-pool.spec.js.map +1 -1
- package/esm/http-user-context.d.ts +8 -5
- package/esm/http-user-context.d.ts.map +1 -1
- package/esm/http-user-context.js +26 -27
- package/esm/http-user-context.js.map +1 -1
- package/esm/http-user-context.spec.js +48 -6
- package/esm/http-user-context.spec.js.map +1 -1
- package/esm/identity-cache-keys.d.ts +28 -0
- package/esm/identity-cache-keys.d.ts.map +1 -0
- package/esm/identity-cache-keys.js +13 -0
- package/esm/identity-cache-keys.js.map +1 -0
- package/esm/identity-cache-keys.spec.d.ts +2 -0
- package/esm/identity-cache-keys.spec.d.ts.map +1 -0
- package/esm/identity-cache-keys.spec.js +21 -0
- package/esm/identity-cache-keys.spec.js.map +1 -0
- package/esm/identity-event-bus.d.ts +102 -0
- package/esm/identity-event-bus.d.ts.map +1 -0
- package/esm/identity-event-bus.js +151 -0
- package/esm/identity-event-bus.js.map +1 -0
- package/esm/identity-event-bus.spec.d.ts +2 -0
- package/esm/identity-event-bus.spec.d.ts.map +1 -0
- package/esm/identity-event-bus.spec.js +392 -0
- package/esm/identity-event-bus.spec.js.map +1 -0
- package/esm/index.d.ts +3 -0
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +3 -0
- package/esm/index.js.map +1 -1
- package/esm/login-response-strategy.d.ts.map +1 -1
- package/esm/login-response-strategy.js.map +1 -1
- package/esm/openapi/generate-openapi-document.spec.js.map +1 -1
- package/esm/proxy-manager.spec.js.map +1 -1
- package/esm/request-action-implementation.d.ts +25 -0
- package/esm/request-action-implementation.d.ts.map +1 -1
- package/esm/request-action-implementation.js +9 -0
- package/esm/request-action-implementation.js.map +1 -1
- package/esm/rest-api-runtime.spec.js.map +1 -1
- package/esm/server-telemetry.d.ts +6 -14
- package/esm/server-telemetry.d.ts.map +1 -1
- package/esm/server-telemetry.js +2 -3
- package/esm/server-telemetry.js.map +1 -1
- package/esm/user-resolution-cache.d.ts +65 -0
- package/esm/user-resolution-cache.d.ts.map +1 -0
- package/esm/user-resolution-cache.js +101 -0
- package/esm/user-resolution-cache.js.map +1 -0
- package/esm/user-resolution-cache.spec.d.ts +2 -0
- package/esm/user-resolution-cache.spec.d.ts.map +1 -0
- package/esm/user-resolution-cache.spec.js +220 -0
- package/esm/user-resolution-cache.spec.js.map +1 -0
- package/esm/user-store.d.ts.map +1 -1
- package/esm/user-store.js.map +1 -1
- package/esm/validate.d.ts +13 -12
- package/esm/validate.d.ts.map +1 -1
- package/esm/validate.integration.spec.js +1 -0
- package/esm/validate.integration.spec.js.map +1 -1
- package/esm/validate.js +12 -0
- package/esm/validate.js.map +1 -1
- package/package.json +14 -12
- package/src/actions/error-action.ts +7 -3
- package/src/actions/get-current-user.ts +1 -4
- package/src/actions/is-authenticated.ts +3 -4
- package/src/actions/logout.ts +2 -6
- package/src/actions/not-found-action.ts +1 -3
- package/src/actions/password-login-action.ts +5 -12
- package/src/authenticate.ts +5 -11
- package/src/authentication-providers/authentication-provider.ts +17 -0
- package/src/authentication-providers/basic-auth-provider.ts +7 -0
- package/src/authentication-providers/cookie-auth-provider.ts +8 -0
- package/src/authorize.ts +6 -17
- package/src/get-schema-from-api.ts +1 -0
- package/src/helpers.ts +11 -11
- package/src/http-authentication-settings.ts +34 -6
- package/src/http-server-pool.spec.ts +1 -1
- package/src/http-user-context.spec.ts +59 -11
- package/src/http-user-context.ts +38 -31
- package/src/identity-cache-keys.spec.ts +24 -0
- package/src/identity-cache-keys.ts +31 -0
- package/src/identity-event-bus.spec.ts +285 -0
- package/src/identity-event-bus.ts +230 -0
- package/src/index.ts +3 -0
- package/src/login-response-strategy.ts +1 -0
- package/src/openapi/generate-openapi-document.spec.ts +2 -2
- package/src/proxy-manager.spec.ts +6 -6
- package/src/request-action-implementation.ts +25 -0
- package/src/rest-api-runtime.spec.ts +1 -1
- package/src/server-telemetry.ts +6 -14
- package/src/user-resolution-cache.spec.ts +241 -0
- package/src/user-resolution-cache.ts +164 -0
- package/src/user-store.ts +1 -0
- package/src/validate.integration.spec.ts +1 -0
- package/src/validate.ts +14 -12
|
@@ -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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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)
|
package/src/actions/logout.ts
CHANGED
|
@@ -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
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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>,
|
package/src/authenticate.ts
CHANGED
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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}.
|
|
24
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
}
|
|
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)
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
-
}
|
|
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
|
|
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(
|
|
282
|
-
const second = await ctx.getCurrentUser(
|
|
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('
|
|
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 = {
|
|
295
|
-
|
|
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};` } }
|
|
409
|
+
{ headers: { cookie: `${ctx.authentication.cookieName}=${sessionId};` } },
|
|
362
410
|
{ setHeader: logoutSetHeader },
|
|
363
411
|
)
|
|
364
412
|
expect(logoutSetHeader).toHaveBeenCalledWith(
|
package/src/http-user-context.ts
CHANGED
|
@@ -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.
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
59
|
-
*
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
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(
|
|
201
|
+
return new HttpUserContextImpl(
|
|
202
|
+
authentication,
|
|
203
|
+
() => injector.get(PasswordAuthenticator),
|
|
204
|
+
systemInjector,
|
|
205
|
+
userCache,
|
|
206
|
+
identityEventBus,
|
|
207
|
+
)
|
|
201
208
|
},
|
|
202
209
|
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { sessionCacheKey, userCacheTag } from './identity-cache-keys.js'
|
|
3
|
+
|
|
4
|
+
describe('identity-cache-keys', () => {
|
|
5
|
+
describe('sessionCacheKey', () => {
|
|
6
|
+
it('prefixes the session id with `cookie:`', () => {
|
|
7
|
+
expect(sessionCacheKey('abc')).toBe('cookie:abc')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('preserves the empty string suffix shape', () => {
|
|
11
|
+
expect(sessionCacheKey('')).toBe('cookie:')
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe('userCacheTag', () => {
|
|
16
|
+
it('prefixes the username with `user:`', () => {
|
|
17
|
+
expect(userCacheTag('alice')).toBe('user:alice')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('preserves the empty string suffix shape', () => {
|
|
21
|
+
expect(userCacheTag('')).toBe('user:')
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
})
|