Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,22 @@ LOG_LEVEL=info

# Keycloak SSO login (BFF's own OIDC client). Disabled by default -- leave
# SSO_ENABLED=false to skip all of this.
#
# Values below match mcp-context-forge's own `make compose-sso` stack --
# both Keycloak clients ("mcp-gateway" for the gateway's own /admin UI,
# "contextforge-web-ui" for this BFF) are seeded directly in that repo's
# infra/keycloak/realm-export.json, no manual admin-console setup needed.
SSO_ENABLED=false
# SSO_KEYCLOAK_BASE_URL=http://localhost:8180
# Only needed if the browser-facing Keycloak host differs from the one above
# (e.g. a Docker service name vs. a public hostname).
# SSO_KEYCLOAK_PUBLIC_BASE_URL=
# SSO_KEYCLOAK_REALM=mcp-gateway
# SSO_KEYCLOAK_CLIENT_ID=contextforge-web-ui
# SSO_KEYCLOAK_CLIENT_SECRET=
# SSO_KEYCLOAK_CLIENT_SECRET=contextforge-web-ui-dev-secret
# SSO_KEYCLOAK_SCOPES=openid profile email
# SSO_LOGIN_STATE_TTL_SECONDS=300
# SSO_TOKEN_REFRESH_LEEWAY_SECONDS=30

# Build-time UI feature flags. These are read by Vite when the frontend starts.
VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT=false
Expand Down
1 change: 1 addition & 0 deletions .env.prod.example
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,4 @@ SSO_KEYCLOAK_CLIENT_ID=
SSO_KEYCLOAK_CLIENT_SECRET=
SSO_KEYCLOAK_SCOPES=openid profile email
SSO_LOGIN_STATE_TTL_SECONDS=300
SSO_TOKEN_REFRESH_LEEWAY_SECONDS=30
15 changes: 13 additions & 2 deletions .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ export const config = {
// TTL for the one-time PKCE verifier/state/nonce minted by the login route
// and consumed by the callback route — same pattern as the nonce above.
ssoLoginStateTtlSeconds: Number(optional("SSO_LOGIN_STATE_TTL_SECONDS", "300")),
// Refresh an SSO session's Keycloak tokens this many seconds before
// tokenExpiresAt, not exactly at it.
ssoTokenRefreshLeewaySeconds: Number(optional("SSO_TOKEN_REFRESH_LEEWAY_SECONDS", "30")),

// memory:// (default) = in-process store, no Redis needed — dev only.
// See lib/memory-redis.ts. Use a real redis:// URL beyond a single
Expand Down Expand Up @@ -181,6 +184,13 @@ if (!Number.isSafeInteger(config.ssoLoginStateTtlSeconds) || config.ssoLoginStat
throw new Error("SSO_LOGIN_STATE_TTL_SECONDS must be a positive integer");
}

if (
!Number.isSafeInteger(config.ssoTokenRefreshLeewaySeconds) ||
config.ssoTokenRefreshLeewaySeconds < 0
) {
throw new Error("SSO_TOKEN_REFRESH_LEEWAY_SECONDS must be a non-negative integer");
}

// Fail fast at boot, not at the first /auth/sso/login request.
if (config.ssoEnabled) {
const missingSsoVars = [
Expand Down
28 changes: 12 additions & 16 deletions server/src/lib/establish-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,28 +58,24 @@ export async function establishSession(
throw new PasswordChangeStillRequiredError();
}

// The BFF session/cookie must not outlive the bearer token it wraps — use
// the upstream JWT's own lifetime, not a fixed BFF-side default. See
// createSession's comment in lib/session-store.ts.
let ttlSeconds = config.sessionTtlSeconds;
// Separate from ttlSeconds' BFF-default fallback -- tokenExpiresAt must never
// borrow it and claim an unknown-lifetime access token is still valid.
let ssoTokenTtlSeconds = 0;
if (Number.isFinite(auth.expires_in) && auth.expires_in! > 0) {
ttlSeconds = auth.expires_in!;
ssoTokenTtlSeconds = auth.expires_in!;
} else if (auth.expires_in !== undefined) {
// Upstream sent expires_in, but it's not a usable positive number — fall
// back, but log it: this means the BFF session can outlive the JWT it
// wraps until the proxy's revoke-on-401 catches up (see session-store.ts).
// A simply *absent* expires_in is not logged here — some upstream
// login-shaped endpoints don't send it by design (see upstream-login.ts).
const validExpiresIn =
Number.isFinite(auth.expires_in) && auth.expires_in! > 0 ? auth.expires_in! : undefined;
if (validExpiresIn === undefined && auth.expires_in !== undefined) {
request.log.warn(
{ expires_in: auth.expires_in },
"upstream login returned invalid expires_in, using BFF default session TTL",
);
}

// A session can only outlive the access token if it actually has a
// refreshToken to extend it with -- otherwise (password login, or an SSO
// response that omitted it) the cookie/Redis TTL must match the access
// token's own lifetime, same as sessionAuth's refresh gate requires.
const ttlSeconds = ssoTokens?.refreshToken
? config.sessionTtlSeconds
: (validExpiresIn ?? config.sessionTtlSeconds);
const ssoTokenTtlSeconds = validExpiresIn ?? 0;

const sessionId = await createSession(
fastify.redis,
{
Expand Down
46 changes: 46 additions & 0 deletions server/src/lib/memory-redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

import { EventEmitter } from "node:events";

import type { RedisLike } from "./session-store.js";

export const MEMORY_REDIS_URL_PREFIX = "memory://";

export function isMemoryRedisUrl(url: string): boolean {
Expand Down Expand Up @@ -61,10 +63,44 @@ export class MemoryRedis extends EventEmitter {
return entry.value;
}

// Mirrors ioredis's SET key value [EX secs|PX ms] [NX|XX] signature.
async set(
key: string,
value: string,
mode: "PX" | "EX",
ttl: number,
flag: "NX" | "XX",
): Promise<"OK" | null> {
const existing = store.get(key);
const exists = !!existing && !isExpired(existing);
if (flag === "NX" && exists) return null;
if (flag === "XX" && !exists) return null;
const ttlMs = mode === "EX" ? ttl * 1000 : ttl;
store.set(key, { value, expiresAt: Date.now() + ttlMs });
return "OK";
}

async del(key: string): Promise<number> {
return store.delete(key) ? 1 : 0;
}

// ponytail: only implements the one compare-and-delete script this app
// issues (see UNLOCK_SCRIPT in plugins/session.ts), not general Lua --
// MemoryRedis is dev-only and that's the sole script ever passed here.
// Safe without a real atomic guarantee: no `await` runs between the read
// and the delete, so nothing else in this single-threaded process can
// interleave.
async eval(_script: string, numKeys: number, ...args: Array<string | number>): Promise<unknown> {
const key = String(args[0]);
const expected = String(args[numKeys]);
const entry = store.get(key);
if (entry && !isExpired(entry) && entry.value === expected) {
store.delete(key);
return 1;
}
return 0;
}

async publish(channel: string, message: string): Promise<number> {
const before = bus.listenerCount("publish");
bus.emit("publish", channel, message);
Expand Down Expand Up @@ -97,3 +133,13 @@ export class MemoryRedis extends EventEmitter {
return "OK";
}
}

// plugins/redis.ts decorates fastify.redis with a MemoryRedis instance via
// `as unknown as FastifyInstance["redis"]`, which bypasses structural
// checking against the real ioredis type -- MemoryRedis can never fully
// satisfy that (hundreds of commands). This is the one place a MemoryRedis
// method actually falling behind RedisLike (session-store.ts's calling
// contract) would otherwise go unnoticed until it 500s at runtime instead of
// failing `tsc`.
const _redisLikeCheck: RedisLike = new MemoryRedis();
void _redisLikeCheck;
38 changes: 24 additions & 14 deletions server/src/lib/oidc-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ const DISCOVERY_FETCH_TIMEOUT_MS = 5000;

export interface OidcDiscoveryDocument {
issuer: string;
// Browser-redirect targets -- rewritten to ssoKeycloakPublicBaseUrl when configured.
// Browser-redirect target -- rewritten to ssoKeycloakPublicBaseUrl when configured.
authorizationEndpoint: string;
// Not every OIDC provider advertises this.
// Server-to-server only -- rewritten to ssoKeycloakBaseUrl. Not every
// OIDC provider advertises this.
endSessionEndpoint?: string;
// Called by the BFF, server-to-server only -- stay on the internal host.
// Server-to-server only -- rewritten to ssoKeycloakBaseUrl, never trusted
// as-discovered (Keycloak reports one hostname for every endpoint).
tokenEndpoint: string;
jwksUri: string;
}
Expand Down Expand Up @@ -103,11 +105,16 @@ async function fetchDiscoveryDocument(): Promise<OidcDiscoveryDocument> {

return {
issuer,
authorizationEndpoint: rewritePublicBaseUrl(authorizationEndpoint, "authorization_endpoint"),
tokenEndpoint,
jwksUri,
authorizationEndpoint: rewriteBaseUrl(
authorizationEndpoint,
config.ssoKeycloakPublicBaseUrl,
"authorization_endpoint",
),
tokenEndpoint: rewriteBaseUrl(tokenEndpoint, config.ssoKeycloakBaseUrl, "token_endpoint"),
jwksUri: rewriteBaseUrl(jwksUri, config.ssoKeycloakBaseUrl, "jwks_uri"),
endSessionEndpoint:
endSessionEndpoint && rewritePublicBaseUrl(endSessionEndpoint, "end_session_endpoint"),
endSessionEndpoint &&
rewriteBaseUrl(endSessionEndpoint, config.ssoKeycloakBaseUrl, "end_session_endpoint"),
};
}

Expand All @@ -124,15 +131,18 @@ function optionalStringField(body: Record<string, unknown>, field: string): stri
return typeof value === "string" && value ? value : undefined;
}

// Swaps only scheme+host+port to ssoKeycloakPublicBaseUrl, keeping the
// discovered path -- the browser can't reach the internal host.
function rewritePublicBaseUrl(endpoint: string, fieldName: string): string {
if (!config.ssoKeycloakPublicBaseUrl) return endpoint;
// Swaps only scheme+host+port to targetBase, keeping the discovered path.
function rewriteBaseUrl(
endpoint: string,
targetBase: string | undefined,
fieldName: string,
): string {
if (!targetBase) return endpoint;
try {
const publicBase = new URL(config.ssoKeycloakPublicBaseUrl);
const target = new URL(targetBase);
const rewritten = new URL(endpoint);
rewritten.protocol = publicBase.protocol;
rewritten.host = publicBase.host; // host includes port
rewritten.protocol = target.protocol;
rewritten.host = target.host; // host includes port
return rewritten.toString();
} catch (err) {
throw new OidcDiscoveryError(
Expand Down
50 changes: 43 additions & 7 deletions server/src/lib/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,23 @@ export interface RedisLike {
// second concurrent caller's get() can still observe the value.
getdel(key: string): Promise<string | null>;
setex(key: string, ttlSeconds: number, value: string): Promise<unknown>;
// PX+NX for lock acquire; EX+XX for a conditional update that no-ops if
// the key's gone (see updateSessionTokens).
set(
key: string,
value: string,
mode: "PX" | "EX",
ttl: number,
flag: "NX" | "XX",
): Promise<"OK" | null>;
del(key: string): Promise<unknown>;
// Atomic Lua eval (ioredis's own `eval(script, numkeys, ...keys, ...args)`)
// -- used for compare-and-delete lock release (see UNLOCK_SCRIPT in
// plugins/session.ts). An unconditional DEL would let a second lock holder
// (whose lock was acquired after this one's PX TTL auto-expired while this
// holder's own refresh ran long) have its lock deleted by this holder's
// delayed release.
eval(script: string, numKeys: number, ...args: Array<string | number>): Promise<unknown>;
publish(channel: string, message: string): Promise<unknown>;
}

Expand All @@ -46,6 +62,8 @@ export interface SessionRecord {
refreshToken?: string;
idToken?: string;
tokenExpiresAt?: number;
// Skip-refresh-until marker after a transient failure; cleared on success.
refreshRetryAfter?: number;
}

export function sessionRedisKey(sessionId: string): string {
Expand All @@ -57,6 +75,11 @@ export function sessionRevokedChannel(sessionId: string): string {
return `${config.redisKeyPrefix}:session:revoked:${sessionId}`;
}

/** Cross-instance lock so concurrent requests don't race a token refresh (see plugins/session.ts). */
export function sessionRefreshLockKey(sessionId: string): string {
return `${config.redisKeyPrefix}:session-refresh-lock:${sessionId}`;
}

// TTL defaults to config.sessionTtlSeconds, but callers should pass the
// upstream token's real expires_in (see routes/auth/login.ts) — the BFF
// session and cookie must not outlive the bearer token they wrap. A session
Expand Down Expand Up @@ -89,13 +112,18 @@ export async function getSession(

// Re-persists a session's tokens in place after an SSO token refresh -- same
// session id, so the browser's cookie never needs to change. Keycloak doesn't
// always rotate the refresh token on every use, so a field the refresh
// response omits keeps its previous value rather than being wiped. Returns
// false (no write) if the session was deleted (logout, expiry) mid-refresh.
// always rotate the refresh token on every use, so an omitted field keeps
// its previous value instead of being wiped.
export async function updateSessionTokens(
redis: RedisLike,
sessionId: string,
tokens: { bearerToken: string; refreshToken?: string; idToken?: string },
tokens: {
bearerToken: string;
refreshToken?: string;
idToken?: string;
tokenExpiresAt: number;
refreshRetryAfter?: number;
},
ttlSeconds: number,
): Promise<boolean> {
const existing = await getSession(redis, sessionId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This read and the SETEX below are not atomic. Logout, natural expiry, or the upstream-401 revocation path can delete the session between them, after which SETEX recreates the revoked session. That can leave a copied bff_sid usable after the user logs out.

Please make this a conditional atomic write, for example SET key value EX ttl XX or a Lua CAS, and return false if the key no longer exists. A deterministic test that deletes the session between the read and write would cover the race.

Expand All @@ -106,10 +134,18 @@ export async function updateSessionTokens(
bearerToken: tokens.bearerToken,
refreshToken: tokens.refreshToken ?? existing.refreshToken,
idToken: tokens.idToken ?? existing.idToken,
tokenExpiresAt: Math.floor(Date.now() / 1000) + ttlSeconds,
tokenExpiresAt: tokens.tokenExpiresAt,
refreshRetryAfter: tokens.refreshRetryAfter,
};
await redis.setex(sessionRedisKey(sessionId), ttlSeconds, JSON.stringify(updated));
return true;
// XX: no-op instead of recreating a session a concurrent logout just deleted.
const result = await redis.set(
sessionRedisKey(sessionId),
JSON.stringify(updated),
"EX",
ttlSeconds,
"XX",
);
return result === "OK";
}

export async function deleteSession(redis: RedisLike, sessionId: string): Promise<void> {
Expand Down
Loading
Loading