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
2 changes: 2 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import changePasswordRequiredRoute from "./routes/auth/change-password-required.
import loginRoute from "./routes/auth/login.js";
import logoutRoute from "./routes/auth/logout.js";
import sessionRoute from "./routes/auth/session.js";
import ssoCallbackRoute from "./routes/auth/sso-callback.js";
import ssoLoginRoute from "./routes/auth/sso-login.js";
import catchAllProxyRoute from "./routes/proxy/catch-all.js";
import oauthAuthorizeProxyRoute from "./routes/proxy/oauth-authorize.js";
Expand Down Expand Up @@ -55,6 +56,7 @@ await fastify.register(loginRoute);
await fastify.register(logoutRoute);
await fastify.register(sessionRoute);
await fastify.register(ssoLoginRoute);
await fastify.register(ssoCallbackRoute);
await fastify.register(changePasswordRequiredRoute);
await fastify.register(sseRoutes);
await fastify.register(publicPasswordResetRoute);
Expand Down
18 changes: 17 additions & 1 deletion server/src/lib/establish-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export interface UpstreamAuthenticationResponse {
user: SessionUser;
}

export interface SsoTokens {
refreshToken?: string;
idToken?: string;
}

/**
* Thrown when the upstream auth response still reports
* password_change_required=true. This is the single chokepoint every
Expand All @@ -47,6 +52,7 @@ export async function establishSession(
request: FastifyRequest,
reply: FastifyReply,
auth: UpstreamAuthenticationResponse, // pragma: allowlist secret
ssoTokens?: SsoTokens,
): Promise<{ user: SessionUser; csrfToken: string }> {
if (auth.user?.password_change_required === true) {
throw new PasswordChangeStillRequiredError();
Expand All @@ -56,8 +62,12 @@ export async function establishSession(
// 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
Expand All @@ -72,7 +82,13 @@ export async function establishSession(

const sessionId = await createSession(
fastify.redis,
{ bearerToken: auth.access_token, user: auth.user },
{
bearerToken: auth.access_token,
user: auth.user,
refreshToken: ssoTokens?.refreshToken,
idToken: ssoTokens?.idToken,
tokenExpiresAt: ssoTokens ? Math.floor(Date.now() / 1000) + ssoTokenTtlSeconds : undefined,
},
ttlSeconds,
);

Expand Down
4 changes: 4 additions & 0 deletions server/src/lib/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export interface SessionUser {
export interface SessionRecord {
bearerToken: string;
user: SessionUser;
// SSO-only; undefined for password-login sessions.
refreshToken?: string;
idToken?: string;
tokenExpiresAt?: number;
}

export function sessionRedisKey(sessionId: string): string {
Expand Down
58 changes: 58 additions & 0 deletions server/src/lib/sso-back-channel-logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Location: ./client/server/src/lib/sso-back-channel-logout.ts
// Copyright contributors to the MCP-CONTEXT-FORGE project
// SPDX-License-Identifier: Apache-2.0
//
// Best-effort RP-Initiated Logout (OIDC) against Keycloak's
// end_session_endpoint, so logging out of the BFF also invalidates the
// Keycloak refresh_token/IdP session an SSO session's tokens came from.
// Mirrors revoke-upstream-token.ts's "must not block the response"
// convention. Used by routes/auth/logout.ts.

import type { FastifyRequest } from "fastify";

import { getDiscoveryDocument } from "./oidc-discovery.js";

// Caller (the logout response) is waiting on this -- cap how long a hung
// (not refused) Keycloak can hold it open.
const SSO_BACK_CHANNEL_LOGOUT_TIMEOUT_MS = 3000;

export async function backChannelLogoutSso(
request: FastifyRequest,
idToken: string,
): Promise<void> {
let endSessionEndpoint: string | undefined;
try {
({ endSessionEndpoint } = await getDiscoveryDocument());
} catch (err) {
request.log.warn({ err }, "SSO back-channel logout: discovery failed");
return;
}
// Not every realm/provider advertises one (see oidc-discovery.ts) -- nothing to call.
if (!endSessionEndpoint) return;

// oidc-discovery.ts only checks this is a non-empty string, not a
// well-formed URL -- a malformed value from a misconfigured provider must
// not throw out of this function (see the file header).
let url: URL;
try {
url = new URL(endSessionEndpoint);
} catch (err) {
request.log.warn({ err }, "SSO back-channel logout: malformed end_session_endpoint");
return;
}
url.searchParams.set("id_token_hint", idToken);

try {
const response = await fetch(url, {
signal: AbortSignal.timeout(SSO_BACK_CHANNEL_LOGOUT_TIMEOUT_MS),
});
if (!response.ok) {
request.log.warn(
{ status: response.status },
"SSO back-channel logout returned a non-2xx status",
);
}
} catch (err) {
request.log.warn({ err }, "SSO back-channel logout failed");
}
}
22 changes: 22 additions & 0 deletions server/src/lib/sso-login-error-redirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Location: ./client/server/src/lib/sso-login-error-redirect.ts
// Copyright contributors to the MCP-CONTEXT-FORGE project
// SPDX-License-Identifier: Apache-2.0
//
// Shared by routes/auth/sso-login.ts and routes/auth/sso-callback.ts -- both
// redirect every SSO failure back to the login page with a `sso_`-prefixed
// error code, preserving the caller's destination when known. One shared
// implementation, one argument order -- two near-identical copies previously
// existed with the arguments swapped between them.

const APP_PREFIX = "/app";
export const SSO_DEFAULT_RETURN_TO = `${APP_PREFIX}/`;
export const SSO_LOGIN_PATH = `${APP_PREFIX}/login`;

// Keycloak's own error codes (access_denied, ...) are short RFC 6749 tokens;
// URLSearchParams encodes whatever we're given either way.
export function loginErrorRedirect(code: string, returnTo?: string): string {
const url = new URL(SSO_LOGIN_PATH, "http://placeholder");
url.searchParams.set("error", `sso_${code}`);
if (returnTo && returnTo !== SSO_DEFAULT_RETURN_TO) url.searchParams.set("next", returnTo);
return url.pathname + url.search;
}
13 changes: 10 additions & 3 deletions server/src/lib/sso-user-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,16 @@ export interface SsoIdTokenClaims {
}

export class SsoIdTokenError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
// Set only where the callback route needs to react differently by cause
// (see resolveSsoUser) -- undefined for the generic parse/verify failures.
readonly code?: "email_missing" | "email_unverified";
constructor(
message: string,
options?: { cause?: unknown; code?: "email_missing" | "email_unverified" },
) {
super(message, options);
this.name = "SsoIdTokenError";
this.code = options?.code;
}
}

Expand Down Expand Up @@ -216,13 +223,13 @@ export async function verifySsoIdToken(idToken: string): Promise<SsoIdTokenClaim
// AuthContext.tsx's own hasPermission caveat) -- SSO never grants it directly.
export function resolveSsoUser(claims: SsoIdTokenClaims): SessionUser {
if (typeof claims.email !== "string" || !claims.email) {
throw new SsoIdTokenError("ID token has no email claim");
throw new SsoIdTokenError("ID token has no email claim", { code: "email_missing" });
}
// An unverified email is federated/self-reported and can collide with an
// existing account -- trusting it here would let an attacker take over
// another user's account just by claiming their address at the IdP.
if (claims.email_verified !== true) {
throw new SsoIdTokenError("ID token email is not verified");
throw new SsoIdTokenError("ID token email is not verified", { code: "email_unverified" });
}

const fullName =
Expand Down
12 changes: 11 additions & 1 deletion server/src/routes/auth/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
// the JWT stays cryptographically valid until its natural TOKEN_EXPIRY.
// Best-effort: an upstream failure (network blip, already-revoked token)
// must not block the BFF-side logout the user is waiting on.
//
// SSO sessions also get a best-effort back-channel RP-Initiated Logout
// against Keycloak (see lib/sso-back-channel-logout.ts) -- otherwise the
// Keycloak refresh_token/IdP session stays live and a later
// /auth/sso/login silently re-authenticates the user. No-op for
// password-login sessions, which never carry an idToken.

import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";

Expand All @@ -26,6 +32,7 @@ import {
SESSION_COOKIE_NAME,
} from "../../lib/session-store.js";
import { CSRF_COOKIE_NAME } from "../../plugins/csrf.js";
import { backChannelLogoutSso } from "../../lib/sso-back-channel-logout.js";
import { revokeUpstreamToken } from "../../lib/revoke-upstream-token.js";
import { setNoStore } from "../../lib/no-store.js";

Expand All @@ -43,7 +50,10 @@ export default async function logoutRoute(fastify: FastifyInstance): Promise<voi
// must not leave a live session behind if it stalls or throws.
await deleteSession(fastify.redis, sessionId);
if (record) {
await revokeUpstreamToken(request, record.bearerToken);
await Promise.all([
revokeUpstreamToken(request, record.bearerToken),
...(record.idToken ? [backChannelLogoutSso(request, record.idToken)] : []),
]);
}
}

Expand Down
159 changes: 159 additions & 0 deletions server/src/routes/auth/sso-callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Location: ./client/server/src/routes/auth/sso-callback.ts
// Copyright contributors to the MCP-CONTEXT-FORGE project
// SPDX-License-Identifier: Apache-2.0
//
// GET /auth/sso/callback: Keycloak's redirect target. Exchanges the
// authorization code, resolves the user from the ID token, and establishes
// the same cookie session password login uses. See lib/sso-login-state.ts.

import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";

import { config } from "../../config.js";
import { establishSession, PasswordChangeStillRequiredError } from "../../lib/establish-session.js";
import { setNoStore } from "../../lib/no-store.js";
import { getDiscoveryDocument } from "../../lib/oidc-discovery.js";
import { loginErrorRedirect } from "../../lib/sso-login-error-redirect.js";
import { consumeSsoLoginState, SSO_LOGIN_BINDING_COOKIE } from "../../lib/sso-login-state.js";
import { exchangeSsoCode } from "../../lib/sso-token-exchange.js";
import {
resolveSsoUser,
SsoIdTokenError,
verifySsoIdToken,
} from "../../lib/sso-user-resolution.js";

interface SsoCallbackQuerystring {
code?: string | string[];
state?: string | string[];
error?: string | string[];
}

function firstString(value: string | string[] | undefined): string | undefined {
return typeof value === "string" && value ? value : undefined;
}

export default async function ssoCallbackRoute(fastify: FastifyInstance): Promise<void> {
fastify.get<{ Querystring: SsoCallbackQuerystring }>(
"/auth/sso/callback",
async (
request: FastifyRequest<{ Querystring: SsoCallbackQuerystring }>,
reply: FastifyReply,
) => {
setNoStore(reply);

const binding = request.cookies[SSO_LOGIN_BINDING_COOKIE];
reply.clearCookie(SSO_LOGIN_BINDING_COOKIE, { path: "/", domain: config.cookieDomain });

if (!config.ssoEnabled) {
return reply.redirect(loginErrorRedirect("disabled"));
}

const state = firstString(request.query.state);
// Keycloak echoes `state` on both success and error redirects (RFC
// 6749) -- consume it unconditionally, before branching on `error`, so
// an error response still burns the single-use login-state record
// instead of leaving it (and its bound codeVerifier/nonce/returnTo)
// live in Redis for the full SSO_LOGIN_STATE_TTL_SECONDS window.
const loginState = state ? await consumeSsoLoginState(fastify.redis, state, binding) : null;

// Presence of the key at all is treated as an error, not just a clean
// single value -- a repeated ?error=a&error=b parses as an array, and
// firstString() would otherwise silently drop it, falling through as
// if Keycloak hadn't reported an error at all.
if (request.query.error !== undefined) {
const errorCode = firstString(request.query.error) ?? "callback_invalid";
return reply.redirect(loginErrorRedirect(errorCode, loginState?.returnTo));
}

const code = firstString(request.query.code);
if (!code || !state) {
return reply.redirect(loginErrorRedirect("callback_invalid", loginState?.returnTo));
}

if (!loginState) {
return reply.redirect(loginErrorRedirect("state_invalid"));
}

let tokenEndpoint: string;
try {
tokenEndpoint = (await getDiscoveryDocument()).tokenEndpoint;
} catch (err) {
request.log.error({ err }, "SSO discovery failed");
return reply.redirect(loginErrorRedirect("discovery_failed", loginState.returnTo));
}

let tokens;
try {
tokens = await exchangeSsoCode({
tokenEndpoint,
code,
redirectUri: loginState.redirectUri,
codeVerifier: loginState.codeVerifier,
});
} catch (err) {
request.log.error({ err }, "SSO token exchange failed");
return reply.redirect(loginErrorRedirect("token_exchange_failed", loginState.returnTo));
}

if (!tokens.idToken) {
request.log.error("SSO token response missing id_token");
return reply.redirect(loginErrorRedirect("id_token_missing", loginState.returnTo));
}

let claims;
try {
claims = await verifySsoIdToken(tokens.idToken);
} catch (err) {
request.log.error({ err }, "SSO ID token verification failed");
return reply.redirect(loginErrorRedirect("id_token_invalid", loginState.returnTo));
}

// Confirms this ID token was issued for the authorization request this
// browser started, not replayed from an unrelated flow.
if (claims.nonce !== loginState.nonce) {
request.log.error("SSO ID token nonce mismatch");
return reply.redirect(loginErrorRedirect("nonce_mismatch", loginState.returnTo));
}

let user;
try {
user = resolveSsoUser(claims);
} catch (err) {
// Distinguished so logs/error codes can tell a genuinely absent
// email claim apart from the account-takeover-prevention case
// (email present but unverified) -- see resolveSsoUser.
const isUnverified = err instanceof SsoIdTokenError && err.code === "email_unverified";
request.log.error(
{ err },
isUnverified ? "SSO ID token email not verified" : "SSO ID token missing email claim",
);
return reply.redirect(
loginErrorRedirect(
isUnverified ? "email_unverified" : "email_missing",
loginState.returnTo,
),
);
}

try {
await establishSession(
fastify,
request,
reply,
{ access_token: tokens.accessToken, expires_in: tokens.expiresIn, user },
{ refreshToken: tokens.refreshToken, idToken: tokens.idToken },
);
} catch (err) {
// Can't happen -- resolveSsoUser always sets password_change_required
// false -- but establishSession's own backstop exists for this path.
if (err instanceof PasswordChangeStillRequiredError) {
return reply.redirect(
loginErrorRedirect("password_change_required", loginState.returnTo),
);
}
throw err;
}

return reply.redirect(loginState.returnTo);
},
);
}
Loading
Loading