Skip to content

Commit 854d919

Browse files
authored
feat(auth): store OIDC sessions in Valkey behind a feature flag (#1089)
* feat(auth): store OIDC sessions in Valkey behind a feature flag Cookie-only sessions grow unbounded as more tokens (impersonation, API-gateway, crowdfunding, profile) get written onto req.appSession, risking browser/proxy header limits. Wire express-openid-connect's native session.store into the existing ValkeyService so the cookie only carries an opaque signed session id; gated by SESSION_STORE_ENABLED + VALKEY_URL so it fails soft to the current cookie-only behavior when unset. LFXV2-2666 Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org> * fix(auth): guard destroy against an unsafe session cache key destroyAsync called valkeyService.del() without checking cacheKey() for null, unlike get/set which both short-circuit on an invalid id. del() already no-ops on null so behavior was unaffected, but the inconsistency was flagged in review. LFXV2-2666 Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org> * fix(review): address PR #1089 review feedback Address review comments from @copilot-pull-request-reviewer, @coderabbitai: - valkey.service.ts / valkey-cache.interface.ts: del() now returns a boolean success signal instead of Promise<void> (per @copilot-pull-request-reviewer) - session-store.service.ts: destroyAsync escalates to logger.error when a session delete fails on logout, since the session would otherwise remain valid in Valkey until it expires via TTL with no visibility (per @copilot-pull-request-reviewer) - session-store.service.ts: isSessionPayload now also requires `cookie`, matching the full SessionStorePayload shape and rejecting a corrupt/legacy entry missing that field before it reaches ttlSecondsFor (per @coderabbitai) Resolves 2 review threads. Responded to a third (expires_at type) with evidence that express-openid-connect's own Session interface types expires_at as string, so no change was made there. A fourth thread (rollout/mixed-pod compatibility) is left open as a documented deployment trade-off, not a code fix. Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org> * fix(review): address PR #1089 review feedback (iteration 2) Address review comments from @copilot-pull-request-reviewer: - session-store.service.ts: setAsync now invalidates the cache key when the write fails, instead of only logging a warning. setJson is a plain SET-with-TTL, so a failed write previously left the prior session value in place — a cleared token (e.g. stopping impersonation) could be silently reloaded on the next request. The store now fails closed: a failed write forces the key to be deleted, and a failure to also invalidate escalates to logger.error (per @copilot-pull-request-reviewer) Resolves 1 review thread. A second new thread (server.ts:196) restates the already-discussed mixed-pod rollout risk from server.ts:194 — replied pointing back to the existing answer, left open as the same documented deployment trade-off. Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org> * fix(review): address PR #1089 review feedback Address review comment from @copilot-pull-request-reviewer: - apps/lfx-one/src/server/services/session-store.service.ts: setAsync now throws when a session write (and its fallback invalidation) fails, instead of swallowing the error. express-openid-connect awaits store.set() inside its res.end() wrapper and calls next(err) on rejection rather than completing the response — surfacing the failure now prevents the OIDC login callback from issuing a cookie for a session that was never persisted, which previously caused an indefinite silent login loop whenever Valkey was unreachable. Resolves 1 review thread (server.ts:175 — Valkey-reachability gate). Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org> * fix(review): address PR #1089 review feedback Address review comments from @copilot-pull-request-reviewer: - session-store.service.ts: updated the class doc comment to distinguish fail-soft reads (a read fault degrades to a cache miss, forcing re-auth) from fail-closed writes (a failed persist now throws and propagates as a request error via express-openid-connect) — the prior wording claimed writes were also best-effort/fail-soft, which is no longer accurate after the earlier fail-closed write fix. - session-store.service.ts: tightened isSessionPayload to validate the nested SessionStorePayload shape (numeric header.iat/uat/exp, a non-null data object, numeric cookie.expires/maxAge) instead of only checking that the header/data/cookie keys are present. A corrupt cached entry like `{ header: {...}, data: null, cookie: {} }` previously passed this guard, causing express-openid-connect to crash attaching `null` as req.appSession instead of degrading to a cache miss. Resolves 3 review threads. Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org> * fix(review): address PR #1089 review feedback Address review comments from @cursor, @copilot-pull-request-reviewer, @audigregorie: - session-store.service.ts: setAsync now fails closed when cacheKey() returns null instead of silently no-op'ing, so a rejected session id can no longer complete the response with a cookie that resolves to nothing in Valkey (per @cursor). - session-store.service.ts: setAsync now distinguishes a brand-new session (header.iat === header.uat) from a rolling refresh of an already-established one. Since express-openid-connect defaults session.rolling to true, a write fires on every authenticated request, not just at login — failing closed on every failed write meant a Valkey outage would 500 all authenticated traffic for its duration. A failed write for a new session still fails closed (nothing was ever persisted for that id); a failed write for an existing session now fails soft, leaving the prior entry in place until its own TTL (per @audigregorie). - server.ts: reworded the session-store gate comment to stop implying VALKEY_URL proves live reachability, and to point at SessionStoreService for the fail-soft read / fail-closed write split (per @copilot-pull-request-reviewer, @audigregorie). - server.ts: noted that genid's 64 hex chars is load-bearing against isFilterSafeIdentifier's 64-char cap, so a future change to the id format doesn't silently fail-closed every session (per @audigregorie). - PR description: documented the destroyAsync fail-soft trade-off (a failed logout delete leaves the session valid until TTL) and updated the write fail-soft/fail-closed description to match the new split (per @audigregorie). Resolves 6 review threads. Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org> * fix(review): address PR #1089 review feedback Address review comments from @copilot-pull-request-reviewer: - session-store.service.ts: session writes are now unconditionally fail-closed — the iat===uat new-session heuristic from the prior iteration incorrectly treated every rolling-refresh write failure as safe to leave in place, but a refresh write can carry a real mutation (e.g. stop-impersonation clearing impersonationToken) that would otherwise survive stale until TTL. A failed write of any kind now invalidates the key and throws AuthenticationError (401) instead of a bare Error, so apiErrorHandler returns a structured re-auth response and logs at warn — a Valkey outage still degrades to forced re-login rather than a raw 500, without trading away data integrity. - server.ts: added a rollout-safety note next to the sessionStoreEnabled gate pointing to the PR description's documented mitigation for the mixed old/new-pod cookie-format hazard during a RollingUpdate. - PR description: documented the rollout-safety trade-off (Recreate / low-traffic-window mitigation for toggling SESSION_STORE_ENABLED) and updated the write-failure behavior description to match the always-fail-closed design above. Resolves 3 review threads. Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org> * fix(review): address PR #1089 review feedback (iteration 7) Address review comments from @cursor (Bugbot), @copilot-pull-request-reviewer: - session-store.service.ts: retry the fallback `del` once when a failed session write's invalidation also fails, closing most of the window where a transient double-fault leaves a stale/mutated entry for a later `get` to resurrect (per @cursor) - session-store.service.ts, valkey-cache.constants.ts: clamp a present-but- non-positive `cookie.maxAge` to a new 1s `SESSION_EXPIRED_TTL_SECONDS` instead of handing an already-expired session the 7-day fallback TTL reserved for missing metadata (per @copilot-pull-request-reviewer) - server.ts: fail startup when `SESSION_STORE_ENABLED` is on in production with a non-`rediss://` `VALKEY_URL`, since the session payload now carries the full bearer-token bundle and a plaintext transport would ship those credentials unencrypted (per @copilot-pull-request-reviewer) Resolves 3 review threads. A 4th (PR description test-plan wording: "signed id" -> "opaque id") is addressed via `gh pr edit`, not a commit. LFXV2-2666 Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org> * fix(review): address PR #1089 review feedback Address a review comment from @copilot-pull-request-reviewer on session-store.service.ts:86-89: a failed Valkey write whose fallback invalidation also fails left a stale session-store entry in place, but still returned a 401 whose response express-openid-connect processes through its own cookie-write hook — a hook that fires independently of how the request settles and would reissue the same session-id cookie, resurrecting the stale entry on the next request. - authentication.error.ts: add a `clearSession` option to AuthenticationError, carried as a public readonly flag (not part of the JSON response body) - session-store.service.ts: set `clearSession: true` on both AuthenticationError throws in setAsync (unsafe cache key, and write failure whether or not fallback invalidation succeeds) - error-handler.middleware.ts: when clearSession is set, set req.appSession = null before responding — express-openid-connect's setter treats this as "clear the cookie" instead of reissuing it - express.d.ts: widen req.appSession to accept null, matching express-openid-connect's actual setter contract LFXV2-2666 Resolves 1 review thread. Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org> * fix(review): address more PR #1089 review feedback Address minor, zero-logic-risk findings from @copilot-pull-request-reviewer: - docs/architecture/backend/impersonation.md: correct the stale "cookie-based (no server-side session store)" statement — impersonation fields live on req.appSession regardless of backend, cookie-only by default or Valkey-backed when SESSION_STORE_ENABLED=true - docs/runtime-configuration.md: add SESSION_STORE_ENABLED to the server-side cache variable table, with the production rediss:// requirement and the no-overlap rollout/rollback constraint - session-store.interface.ts: document that SessionStoreCookieMeta.expires and maxAge are both milliseconds, matching ttlSecondsFor's existing /1000 - session-store.service.ts: correct the write-failure log message — with the clearSession fix from the prior commit, the user is logged out on the current request (401 + cleared cookie), not "on next request" The session-store.service.ts:74 optimistic-concurrency/race-condition finding (concurrent writes to the same session id, last-write-wins) is acknowledged as valid but not addressed in this PR — a correct fix needs per-session versioning with an atomic compare-and-set, which is a meaningful architectural addition better scoped and reviewed on its own rather than rushed into this iteration. Responded on the thread with the reasoning; left open. LFXV2-2666 Resolves 3 review threads; 1 addressed via response only (deferred). Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org> * fix(docs): fix prettier table formatting in runtime-configuration.md Realign markdown table column widths flagged by yarn format:check in CI for PR #1089. Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org> * fix(review): address round 3 PR #1089 review feedback Address review comments from @copilot-pull-request-reviewer: - server.ts: clear legacy chunked appSession.N cookies on every request while SESSION_STORE_ENABLED is on — the native custom-store cookie writer only manages the unchunked appSession cookie, so pre-cutover chunk cookies could otherwise survive a logout and be silently resurrected by a later rollback to cookie mode - charts/lfx-self-serve/values.yaml: corrected the SESSION_STORE_ENABLED comment — it previously advertised an instant/anytime rollback, which contradicts the chart's default 3-replica RollingUpdate - docs/architecture/backend/impersonation.md: updated the Session Storage section's intro text and diagram, which still asserted cookie-only, encrypted-chunked storage even after the flag toggles to Valkey-backed storage LFXV2-2666 Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org> * fix(review): improve session write failure error message Address review comment from @copilot-pull-request-reviewer on PR #1089: - session-store.service.ts: the AuthenticationError message returned to the client on a Valkey session write failure said "Session write failed to persist" (infrastructure jargon, no recovery action). Now reads "Your session could not be saved — please sign in again." Resolves 1 review thread. LFXV2-2666 Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org> * fix(review): address round 4 PR #1089 review feedback Address review comments from @copilot-pull-request-reviewer and @cursor: - error-handler.middleware.ts: move the AuthenticationError clearSession handling before the res.headersSent early-return, so a failed session write still clears req.appSession (preventing cookie reissue) even on streaming responses that already flushed headers before the write failed (per @copilot-pull-request-reviewer, @cursor) - server.ts: pass matching httpOnly/sameSite/secure attributes to the legacy appSession.N chunk-cookie clearCookie() calls, mirroring express-openid-connect's own cookie defaults, so the clear reliably takes effect in every browser (per @cursor) - runtime-configuration.md: documented the concrete Helm override needed to safely flip to a Recreate rollout strategy — strategy.type=Recreate alone leaves the chart's default strategy.rollingUpdate map in place, which Kubernetes rejects; operators must also null it out (per @copilot-pull-request-reviewer) Resolves 4 review threads. LFXV2-2666 Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org> * fix(review): address round 5 PR #1089 review feedback Address review comments from @copilot-pull-request-reviewer, @cursor: - server.ts: apply the same clearSession-before-headersSent fix inside the outer global error handler, which has its own headersSent short-circuit that runs before apiErrorHandler is ever reached — the round-4 fix inside apiErrorHandler alone never ran for SSR/auth-redirect routes (per @cursor) - session-store.service.ts: get/set/destroy now return the underlying promise when called without a callback, so express-openid-connect's minified-source probe (which can lose the "cb"/"callback" substring match after production minification renames local parameters) still detects these methods correctly instead of invoking `callback` as undefined (per @copilot-pull-request-reviewer) - session-store.service.ts: cache-key safety-check failures now log at debug instead of warn — the sid comes from an unsigned, anonymous-controlled cookie, so a malformed value is expected untrusted input, not a system fault (per @copilot-pull-request-reviewer) - session-store.service.ts: replaced the remaining infrastructure-jargon AuthenticationError message ("cache key failed the safety check") with the same user-facing re-authentication guidance used elsewhere (per @copilot-pull-request-reviewer) LFXV2-2666 Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org> * fix(review): address round 6 PR #1089 review feedback Address review comment from @copilot-pull-request-reviewer: - server.ts: register the legacy appSession.N chunk-cookie cleanup middleware before auth(authConfig) instead of after. express-openid- connect's built-in /logout route completes the response inside its own router without calling next(), so cleanup registered after auth() never ran on a logout request — the exact request where clearing stale chunks matters most, since a later rollback to cookie mode could otherwise silently restore a pre-logout session from an uncleared chunk cookie. LFXV2-2666 Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org> --------- Signed-off-by: Rashad <mrashad@contractor.linuxfoundation.org>
1 parent 2407e27 commit 854d919

13 files changed

Lines changed: 413 additions & 34 deletions

File tree

apps/lfx-one/src/server/errors/authentication.error.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,24 @@ import { BaseApiError } from './base.error';
88
* Used when a user attempts to access protected routes without proper authentication
99
*/
1010
export class AuthenticationError extends BaseApiError {
11+
/** When true, `apiErrorHandler` clears `req.appSession` before responding, so express-openid-connect's
12+
* cookie-write hook (which fires independently of how the request settles) clears the session cookie
13+
* instead of reissuing it — needed whenever the thrown error means the session data can't be trusted. */
14+
public readonly clearSession: boolean;
15+
1116
public constructor(
1217
message = 'Authentication required',
1318
options: {
1419
operation?: string;
1520
service?: string;
1621
path?: string;
1722
metadata?: Record<string, any>;
23+
clearSession?: boolean;
1824
} = {}
1925
) {
20-
super(message, 401, 'AUTHENTICATION_REQUIRED', options);
26+
const { clearSession, ...rest } = options;
27+
super(message, 401, 'AUTHENTICATION_REQUIRED', rest);
28+
this.clearSession = clearSession ?? false;
2129
}
2230
}
2331

apps/lfx-one/src/server/middleware/error-handler.middleware.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import { NextFunction, Request, Response } from 'express';
55

6-
import { BaseApiError, isBaseApiError } from '../errors';
6+
import { AuthenticationError, BaseApiError, isBaseApiError } from '../errors';
77
import { logger } from '../services/logger.service';
88

99
/**
@@ -22,6 +22,14 @@ function getOperationFromPath(path: string): string {
2222
}
2323

2424
export function apiErrorHandler(error: Error | BaseApiError, req: Request, res: Response, next: NextFunction): void {
25+
// express-openid-connect's session-write hook (the patched res.end()) can throw this error after
26+
// headers were already flushed — e.g. SSE controllers that flush before streaming. Clear the
27+
// in-memory session before the headersSent check below so a failed write's session still gets
28+
// cleared (and its cookie not reissued) even on responses we can no longer reshape.
29+
if (error instanceof AuthenticationError && error.clearSession) {
30+
req.appSession = null;
31+
}
32+
2533
// If response already sent, delegate to default Express error handler
2634
if (res.headersSent) {
2735
next(error);

apps/lfx-one/src/server/server.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { AuthContext, RuntimeConfig, User } from '@lfx-one/shared/interfaces';
88
import dotenv from 'dotenv';
99
import express, { NextFunction, Request, Response } from 'express';
1010
import { attemptSilentLogin, auth, ConfigParams } from 'express-openid-connect';
11+
import { randomBytes } from 'node:crypto';
1112
import { Server as HttpServer } from 'node:http';
1213
import { dirname, resolve } from 'node:path';
1314
import { fileURLToPath } from 'node:url';
@@ -18,6 +19,7 @@ import { ProfileController } from './controllers/profile.controller';
1819
import { CrowdfundingAuthService } from './services/crowdfunding-auth.service';
1920
import { customErrorSerializer } from './helpers/error-serializer';
2021
import { validateAndSanitizeUrl } from './helpers/url-validation';
22+
import { AuthenticationError } from './errors';
2123
import { authMiddleware } from './middleware/auth.middleware';
2224
import { apiErrorHandler } from './middleware/error-handler.middleware';
2325
import { apiRateLimiter, authRateLimiter, publicApiRateLimiter } from './middleware/rate-limit.middleware';
@@ -60,6 +62,7 @@ import mktgAgentsRouter from './routes/mktg-agents.route';
6062
import { reqSerializer, resSerializer, serverLogger } from './server-logger';
6163
import { logger } from './services/logger.service';
6264
import { NatsService } from './services/nats.service';
65+
import { sessionStoreService } from './services/session-store.service';
6366
import { SnowflakeService } from './services/snowflake.service';
6467
import { clearImpersonationSession, decodeJwtPayload } from './utils/auth-helper';
6568
import { isShuttingDown, markShuttingDown, runShutdownHooks } from './utils/shutdown';
@@ -165,6 +168,32 @@ const httpLogger = pinoHttp({
165168

166169
app.use(httpLogger);
167170

171+
// LFXV2-2666: move the session bundle out of the encrypted `appSession` cookie and into Valkey,
172+
// keyed by an opaque session id, so cookie size stays flat as more tokens (impersonation,
173+
// API-gateway, crowdfunding, profile) are added onto req.appSession. Only wired up when
174+
// SESSION_STORE_ENABLED is set and VALKEY_URL is present — without VALKEY_URL every store
175+
// read/write would degrade to "session missing" (ValkeyService's fail-soft behavior) and silently
176+
// log everyone out. Note: this only gates on URL presence, not live reachability — a Valkey outage
177+
// after startup surfaces as failed session writes (401, forced re-login) rather than a silent miss
178+
// (see SessionStoreService for the fail-soft read / fail-closed write behavior).
179+
//
180+
// Rollout note: toggling this flag changes what the `appSession` cookie *means* (encrypted JWE vs.
181+
// opaque Valkey id). The chart's default RollingUpdate strategy means old and new pods coexist
182+
// during the rollout window, so requests hitting different pods will flap between "valid session"
183+
// and "invalid session" until the rollout completes — see the PR description's rollout-safety note
184+
// for the accepted operational mitigation.
185+
const valkeyUrl = process.env['VALKEY_URL'];
186+
const sessionStoreEnabled = process.env['SESSION_STORE_ENABLED'] === 'true' && !!valkeyUrl;
187+
188+
// The session-store payload carries the full bearer-token bundle (Auth0 access/refresh plus
189+
// impersonation/API-gateway/crowdfunding/profile tokens) — unlike ValkeyService's other, lower-
190+
// sensitivity cache entries, a plaintext `redis://` transport would ship those credentials
191+
// unencrypted. Fail startup rather than silently degrade; local/dev environments are exempt since
192+
// they don't carry real user credentials.
193+
if (sessionStoreEnabled && process.env['NODE_ENV'] === 'production' && !valkeyUrl!.startsWith('rediss://')) {
194+
throw new Error('SESSION_STORE_ENABLED requires a TLS-secured VALKEY_URL (rediss://) in production — refusing to start with an insecure transport.');
195+
}
196+
168197
const authConfig: ConfigParams = {
169198
// Global auth disabled; selective middleware handles it.
170199
authRequired: false,
@@ -182,8 +211,52 @@ const authConfig: ConfigParams = {
182211
routes: {
183212
login: false,
184213
},
214+
...(sessionStoreEnabled && {
215+
session: {
216+
store: sessionStoreService,
217+
// 256 bits of cryptographically strong randomness — sufficient entropy on its own, per the
218+
// library's genid docs, without needing signSessionStoreCookie. 64 hex chars is also the
219+
// exact cap enforced by isFilterSafeIdentifier (used to build the Valkey cache key) — don't
220+
// widen randomBytes() or change the encoding without raising that cap too, or every session
221+
// id will fail the cache-key safety check and every session will be treated as missing.
222+
genid: () => randomBytes(32).toString('hex'),
223+
},
224+
}),
185225
};
186226

227+
// The native custom-store cookie writer (appSession.js's CustomStore.setCookie) only ever sets or
228+
// clears the single unchunked `appSession` cookie — it has no awareness of the legacy
229+
// `appSession.0`, `appSession.1`, ... chunk cookies a large pre-cutover session may have left in a
230+
// user's browser. Left uncleared, those chunks stay valid (decryptable, unexpired) in the browser
231+
// even after the user logs out under the store, and a later rollback to cookie mode would silently
232+
// re-authenticate them from that stale, pre-cutover session snapshot. Proactively clear any such
233+
// chunks on every request while the store is enabled, so nothing survives to be resurrected by a
234+
// rollback.
235+
//
236+
// Registered BEFORE auth(authConfig): express-openid-connect's built-in /logout route completes
237+
// the response inside its own router without calling next(), so cleanup registered after auth()
238+
// would never run on a logout request — the exact request where clearing these chunks matters most.
239+
if (sessionStoreEnabled) {
240+
// Mirror the attributes express-openid-connect used when it originally set these chunk cookies
241+
// (config.js's session.cookie defaults: httpOnly=true, sameSite='Lax', secure=true iff baseURL is
242+
// https) — a Set-Cookie clear with mismatched attributes can be silently ignored by the browser.
243+
const chunkCookieOptions = { httpOnly: true, sameSite: 'lax' as const, secure: /^https:/i.test(authConfig.baseURL as string) };
244+
app.use((req, res, next) => {
245+
const cookieHeader = req.headers.cookie;
246+
if (cookieHeader) {
247+
for (const pair of cookieHeader.split(';')) {
248+
const eqIndex = pair.indexOf('=');
249+
if (eqIndex === -1) continue;
250+
const name = pair.slice(0, eqIndex).trim();
251+
if (/^appSession\.\d+$/.test(name)) {
252+
res.clearCookie(name, chunkCookieOptions);
253+
}
254+
}
255+
}
256+
next();
257+
});
258+
}
259+
187260
app.use(auth(authConfig));
188261

189262
// Meeting join pages are optional-auth; silent login picks up any existing SSO session.
@@ -414,6 +487,13 @@ app.use('/**', async (req: Request, res: Response, next: NextFunction) => {
414487

415488
// Global error handler — must be last.
416489
app.use((error: Error, req: Request, res: Response, next: NextFunction) => {
490+
// Clear the in-memory session before this headersSent guard so a failed session write's
491+
// clearSession still takes effect on SSR/auth-redirect routes that flush headers before
492+
// apiErrorHandler would otherwise run — mirrors the same guard inside apiErrorHandler itself.
493+
if (error instanceof AuthenticationError && error.clearSession) {
494+
req.appSession = null;
495+
}
496+
417497
if (res.headersSent) {
418498
next(error);
419499
return;
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
import { VALKEY_CACHE } from '@lfx-one/shared/constants';
5+
import { SessionStorePayload } from '@lfx-one/shared/interfaces';
6+
7+
import { AuthenticationError } from '../errors';
8+
import { buildSessionCacheKey, valkeyService } from './valkey.service';
9+
import { logger } from './logger.service';
10+
11+
/**
12+
* express-openid-connect session store backed by Valkey. Moves the session bundle (Auth0 tokens
13+
* plus impersonation / API-gateway / crowdfunding / profile tokens written onto `req.appSession`)
14+
* out of the encrypted `appSession` cookie and into Valkey, keyed by an opaque session id — the
15+
* cookie then only carries that id. Reads are fail-soft: a Valkey read fault degrades to a miss
16+
* (treated by express-openid-connect as an expired session, forcing re-auth) rather than a 500,
17+
* matching ValkeyService's existing fail-soft cache behavior. Writes are always fail-closed: a
18+
* session that fails to persist is invalidated (so a stale/mutated value never survives at that
19+
* key) and throws an `AuthenticationError` (401) rather than resolving — express-openid-connect
20+
* surfaces the throw as a request error, and 401 rather than a bare 500 means a Valkey outage
21+
* degrades every affected request to "please log in again" instead of a crash, mirroring the
22+
* fail-soft *experience* of a read fault while never serving stale data. This applies uniformly to
23+
* every write (new session or rolling refresh) — a refresh write can carry a real mutation (e.g.
24+
* stop-impersonation clearing `impersonationToken`), and there's no cheap, reliable way to prove
25+
* the payload is unchanged before deciding it's safe to leave the prior entry in place.
26+
*
27+
* Structurally matches express-openid-connect's `session.store` contract (`get`/`set`/`destroy`
28+
* with a callback) — that type isn't exported from the library, so compatibility is enforced by
29+
* assignment in `server.ts` rather than an `implements` clause here.
30+
*/
31+
export class SessionStoreService {
32+
// express-openid-connect's safePromisify probes callback-vs-promise stores two ways: first by
33+
// checking whether the method's *minified* source still contains the substring "cb"/"callback"
34+
// (unreliable — production builds minify local parameter names), and if that fails, by invoking
35+
// the method with no callback arg and checking whether the result is a thenable. Returning the
36+
// underlying promise whenever `callback` is omitted satisfies that probe under either detection
37+
// path, instead of invoking `callback` as a function when it's `undefined`.
38+
public get(sid: string, callback?: (err: unknown, session?: SessionStorePayload | null) => void): void | Promise<SessionStorePayload | null> {
39+
const promise = this.getAsync(sid);
40+
if (!callback) {
41+
return promise;
42+
}
43+
void promise.then(
44+
(session) => callback(null, session),
45+
(err) => callback(err)
46+
);
47+
}
48+
49+
public set(sid: string, session?: SessionStorePayload, callback?: (err?: unknown) => void): void | Promise<void> {
50+
// The same detection probe above can invoke `set` with only the `sid` arg to test for a
51+
// thenable return — guard against treating that as a real write (which would persist
52+
// `undefined` as the session value) rather than reaching setAsync with a missing payload.
53+
if (!session) {
54+
return callback ? callback() : Promise.resolve();
55+
}
56+
const promise = this.setAsync(sid, session);
57+
if (!callback) {
58+
return promise;
59+
}
60+
void promise.then(
61+
() => callback(),
62+
(err) => callback(err)
63+
);
64+
}
65+
66+
public destroy(sid: string, callback?: (err?: unknown) => void): void | Promise<void> {
67+
const promise = this.destroyAsync(sid);
68+
if (!callback) {
69+
return promise;
70+
}
71+
void promise.then(
72+
() => callback(),
73+
(err) => callback(err)
74+
);
75+
}
76+
77+
private async getAsync(sid: string): Promise<SessionStorePayload | null> {
78+
const key = this.cacheKey(sid);
79+
if (key === null) {
80+
return null;
81+
}
82+
return valkeyService.getJson<SessionStorePayload>(key, SessionStoreService.isSessionPayload);
83+
}
84+
85+
private async setAsync(sid: string, session: SessionStorePayload): Promise<void> {
86+
const startTime = logger.startOperation(undefined, 'session_store_set');
87+
const key = this.cacheKey(sid);
88+
if (key === null) {
89+
// Nothing was persisted, so letting this resolve normally would hand back a cookie whose id
90+
// never resolves in Valkey — the same unrecoverable-login-loop failure mode a Valkey write
91+
// failure below fails closed on. Fail closed here too instead of silently no-op'ing.
92+
throw new AuthenticationError('Your session could not be saved — please sign in again.', {
93+
operation: 'session_store_set',
94+
clearSession: true,
95+
});
96+
}
97+
const ttlSeconds = this.ttlSecondsFor(session);
98+
const persisted = await valkeyService.setJson(key, session, ttlSeconds);
99+
if (!persisted) {
100+
// setJson is a `SET key val EX ttl` — a failed write leaves any prior value at this key
101+
// untouched, so a stale session (e.g. a cleared impersonation token) would otherwise survive
102+
// and be reloaded on the next request. Always invalidate on a failed write — whether this was
103+
// a brand-new session or a rolling refresh — since a refresh write can carry a real mutation
104+
// and there's no cheap, reliable way to prove the payload is unchanged before leaving the
105+
// prior entry in place.
106+
// `set` and `del` are independent Redis commands — a transient blip can fail both back-to-back
107+
// while a later `get` succeeds against a since-recovered connection, resurrecting the stale
108+
// entry. One immediate retry closes most of that window without adding real latency.
109+
let invalidated = await valkeyService.del(key);
110+
if (!invalidated) {
111+
invalidated = await valkeyService.del(key);
112+
}
113+
if (!invalidated) {
114+
logger.error(undefined, 'session_store_set', startTime, new Error('Valkey write and fallback invalidation both failed'), {
115+
message: 'Session write failed and the stale entry could not be invalidated — a prior session value may still be served',
116+
});
117+
} else {
118+
logger.warning(
119+
undefined,
120+
'session_store_set',
121+
'Session write failed — invalidated the stale entry; apiErrorHandler clears the cookie and the current request gets a 401, forcing an immediate re-login'
122+
);
123+
}
124+
// express-openid-connect awaits store.set() inside its res.end() wrapper and calls next(err)
125+
// on rejection instead of completing the response. Throwing AuthenticationError (401) rather
126+
// than a bare Error means apiErrorHandler returns a structured "please re-authenticate"
127+
// response and logs at warn (not error) — so a Valkey outage degrades every affected request
128+
// to a forced re-login instead of a raw 500, without ever serving the invalidated stale entry.
129+
// Also tell apiErrorHandler to clear req.appSession: express-openid-connect's cookie-write hook
130+
// fires regardless of this throw, and would otherwise reissue a cookie pointing at a stale (or
131+
// un-invalidated) Valkey entry.
132+
throw new AuthenticationError('Your session could not be saved — please sign in again.', { operation: 'session_store_set', clearSession: true });
133+
}
134+
}
135+
136+
private async destroyAsync(sid: string): Promise<void> {
137+
const startTime = logger.startOperation(undefined, 'session_store_destroy');
138+
const key = this.cacheKey(sid);
139+
if (key === null) {
140+
return;
141+
}
142+
const deleted = await valkeyService.del(key);
143+
if (!deleted) {
144+
logger.error(undefined, 'session_store_destroy', startTime, new Error('Valkey delete failed'), {
145+
message: 'Session delete failed on logout — session will remain valid in Valkey until it expires via TTL',
146+
});
147+
}
148+
}
149+
150+
/** Fail-closed on an unsafe/oversized session id — express-openid-connect then treats the session as missing rather than reading/writing a corrupt key. */
151+
private cacheKey(sid: string): string | null {
152+
const key = buildSessionCacheKey(sid);
153+
if (key === null) {
154+
// The sid comes straight from an unsigned cookie an anonymous client controls, so a malformed
155+
// value is expected untrusted input rather than a system fault — debug, not warn, avoids a
156+
// log flood from probing/malformed cookies ahead of route-level rate limiting.
157+
logger.debug(undefined, 'session_store_key', 'Session id failed the cache-key safety check — treating session as missing');
158+
}
159+
return key;
160+
}
161+
162+
/** Derives the Valkey TTL from the session's own `cookie.maxAge` (set by express-openid-connect from `session.rollingDuration`/`session.absoluteDuration`) so entries expire alongside the cookie; falls back to the configured default if that shape is ever missing. */
163+
private ttlSecondsFor(session: SessionStorePayload): number {
164+
const maxAgeMs = session.cookie?.maxAge;
165+
if (typeof maxAgeMs === 'number') {
166+
// A present-but-non-positive maxAge means the cookie has already reached its absolute expiry —
167+
// the multi-day fallback below is reserved for missing/invalid metadata, not an expired session.
168+
return maxAgeMs > 0 ? Math.ceil(maxAgeMs / 1000) : VALKEY_CACHE.SESSION_EXPIRED_TTL_SECONDS;
169+
}
170+
return VALKEY_CACHE.SESSION_FALLBACK_TTL_SECONDS;
171+
}
172+
173+
/**
174+
* Guards against a corrupt/legacy cache entry being handed back to express-openid-connect as a
175+
* valid session — a shallow key-presence check would let e.g. `data: null` through, and
176+
* express-openid-connect crashes trying to redefine `req.appSession` with a non-object value.
177+
*/
178+
private static isSessionPayload(value: unknown): value is SessionStorePayload {
179+
if (typeof value !== 'object' || value === null) {
180+
return false;
181+
}
182+
const header = (value as { header?: unknown }).header;
183+
const data = (value as { data?: unknown }).data;
184+
const cookie = (value as { cookie?: unknown }).cookie;
185+
if (typeof header !== 'object' || header === null || typeof data !== 'object' || data === null || typeof cookie !== 'object' || cookie === null) {
186+
return false;
187+
}
188+
const { iat, uat, exp } = header as { iat?: unknown; uat?: unknown; exp?: unknown };
189+
const { expires, maxAge } = cookie as { expires?: unknown; maxAge?: unknown };
190+
return typeof iat === 'number' && typeof uat === 'number' && typeof exp === 'number' && typeof expires === 'number' && typeof maxAge === 'number';
191+
}
192+
}
193+
194+
export const sessionStoreService = new SessionStoreService();

0 commit comments

Comments
 (0)