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
36 changes: 36 additions & 0 deletions e2e/auth/login-flow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,42 @@ test.describe("Login flow", () => {
expect(token).toBeNull();
});

test("403 password-change-required response redirects to the change-password page", async ({
page,
apiMock,
}) => {
await apiMock.mockLogin({
status: 403,
detail: JSON.stringify({
detail: "Password change required. Please change your password before continuing.",
}),
});

await page.goto(APP.LOGIN);
await page.getByLabel(/email address/i).fill("test@example.com");
await page.getByLabel(/password/i).fill("old-password");
await page.getByRole("button", { name: /^sign in$/i }).click();

await expect(page).toHaveURL(
new RegExp(`${APP.CHANGE_PASSWORD_REQUIRED}\\?email=test%40example\\.com$`),
);
});

test("403 response with unrelated detail keeps user on the login page", async ({
page,
apiMock,
}) => {
await apiMock.mockLogin({ status: 403, detail: JSON.stringify({ detail: "Forbidden" }) });

await page.goto(APP.LOGIN);
await page.getByLabel(/email address/i).fill("test@example.com");
await page.getByLabel(/password/i).fill("password123");
await page.getByRole("button", { name: /^sign in$/i }).click();

await expect(page).toHaveURL(new RegExp(`${APP.LOGIN}$`));
await expect(page.getByRole("alert")).toHaveText(/login failed/i);
});

test("500 response surfaces generic failure message", async ({ page, apiMock }) => {
await apiMock.mockLogin({ status: 500 });

Expand Down
165 changes: 165 additions & 0 deletions e2e/auth/password-change-required.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { test, expect } from "../fixtures/api-mock";
import { APP, TOKEN_STORAGE_KEY } from "../utils/paths";

test.describe("Password change required flow", () => {
test.beforeEach(async ({ page, apiMock }) => {
await apiMock.mockSession({ authenticated: false });
await page.addInitScript((key) => {
window.sessionStorage.removeItem(key);
}, TOKEN_STORAGE_KEY);
});

test("successful change lands the user straight in the app, no manual re-login", async ({
page,
apiMock,
}) => {
await apiMock.mockChangePasswordRequired();

await page.goto(`${APP.CHANGE_PASSWORD_REQUIRED}?email=test%40example.com`);
await expect(page.getByLabel(/email address/i)).toHaveValue("test@example.com");

await page.getByLabel(/current password/i).fill("old-password");
await page.getByLabel(/^new password/i).fill("New-password1");
await page.getByLabel(/confirm new password/i).fill("New-password1");
await page.getByRole("button", { name: /change password/i }).click();

await expect(page).toHaveURL(new RegExp(`${APP.ROOT}$`));
await expect(page.getByRole("heading", { name: /dashboard/i })).toBeVisible();
});

test("password changed but auto sign-in failed shows a fallback screen back to login", async ({
page,
}) => {
await page.route("**/auth/change-password-required", async (route) => {
await route.fulfill({
status: 502,
contentType: "application/json",
body: JSON.stringify({ error: "login_after_change_failed" }),
});
});

await page.goto(`${APP.CHANGE_PASSWORD_REQUIRED}?email=test%40example.com`);
await page.getByLabel(/current password/i).fill("old-password");
await page.getByLabel(/^new password/i).fill("New-password1");
await page.getByLabel(/confirm new password/i).fill("New-password1");
await page.getByRole("button", { name: /change password/i }).click();

await expect(page.getByRole("status")).toHaveText(/password changed/i);
await page.getByRole("button", { name: /return to login/i }).click();
await expect(page).toHaveURL(new RegExp(`${APP.LOGIN}$`));
});

test("invalid old password shows a fallback link to forgot-password", async ({
page,
apiMock,
}) => {
await apiMock.mockChangePasswordRequired({ status: 401 });

await page.goto(`${APP.CHANGE_PASSWORD_REQUIRED}?email=test%40example.com`);
await page.getByLabel(/current password/i).fill("wrong-old-password");
await page.getByLabel(/^new password/i).fill("New-password1");
await page.getByLabel(/confirm new password/i).fill("New-password1");
await page.getByRole("button", { name: /change password/i }).click();

await expect(page.getByRole("alert")).toHaveText(/current password is incorrect/i);
await page.getByRole("button", { name: /forgot your password/i }).click();
await expect(page).toHaveURL(new RegExp(`${APP.FORGOT_PASSWORD}$`));
});

test("account that doesn't need a change shows a distinct message and a return-to-login CTA", async ({
page,
}) => {
// Raw route, not the apiMock fixture — this case has its own error code
// ({ error: "password_change_not_required" }), distinct from the
// fixture's generic change_password_failed body for a wrong old password.
await page.route("**/auth/change-password-required", async (route) => {
await route.fulfill({
status: 403,
contentType: "application/json",
body: JSON.stringify({ error: "password_change_not_required" }),
});
});

await page.goto(`${APP.CHANGE_PASSWORD_REQUIRED}?email=test%40example.com`);
await page.getByLabel(/current password/i).fill("correct-password");
await page.getByLabel(/^new password/i).fill("New-password1");
await page.getByLabel(/confirm new password/i).fill("New-password1");
await page.getByRole("button", { name: /change password/i }).click();

const alert = page.getByRole("alert");
await expect(alert).toHaveText(/doesn't need to be changed/i);
await expect(page.getByRole("button", { name: /forgot your password/i })).toHaveCount(0);
await page.getByRole("button", { name: /return to login/i }).click();
await expect(page).toHaveURL(new RegExp(`${APP.LOGIN}$`));
});

test("missing ?email= shows an error and a return-to-login CTA instead of a submittable form", async ({
page,
}) => {
await page.goto(APP.CHANGE_PASSWORD_REQUIRED);

await expect(page.getByRole("alert")).toHaveText(/couldn't tell which account/i);
await expect(page.getByLabel(/current password/i)).toHaveCount(0);
await page.getByRole("button", { name: /return to login/i }).click();
await expect(page).toHaveURL(new RegExp(`${APP.LOGIN}$`));
});

test("mismatched new passwords are rejected client-side", async ({ page }) => {
await page.goto(`${APP.CHANGE_PASSWORD_REQUIRED}?email=test%40example.com`);
await page.getByLabel(/current password/i).fill("old-password");
await page.getByLabel(/^new password/i).fill("New-password1");
await page.getByLabel(/confirm new password/i).fill("Different-password2");
await page.getByRole("button", { name: /change password/i }).click();

await expect(page.getByText(/passwords do not match/i)).toBeVisible();
});

test("focus moves to error input field when validation fails", async ({ page }) => {
await page.goto(`${APP.CHANGE_PASSWORD_REQUIRED}?email=test%40example.com`);

// Trigger new password validation error
await page.getByLabel(/current password/i).fill("old-password");
await page.getByLabel(/^new password/i).fill("short");
await page.getByLabel(/confirm new password/i).fill("short");
await page.getByRole("button", { name: /change password/i }).click();

// Verify focus moved to the new password input
await expect(page.getByLabel(/^new password/i)).toBeFocused();
});

test("focus moves to submit error when server returns error", async ({ page, apiMock }) => {
await apiMock.mockChangePasswordRequired({ status: 401 });

await page.goto(`${APP.CHANGE_PASSWORD_REQUIRED}?email=test%40example.com`);
await page.getByLabel(/current password/i).fill("wrong-old-password");
await page.getByLabel(/^new password/i).fill("New-password1");
await page.getByLabel(/confirm new password/i).fill("New-password1");
await page.getByRole("button", { name: /change password/i }).click();

// Verify focus moved to the error notification container
const errorContainer = page.locator('[role="alert"]').locator("..");
await expect(errorContainer).toBeFocused();
});

test("success heading receives focus after password changed but login failed", async ({
page,
}) => {
await page.route("**/auth/change-password-required", async (route) => {
await route.fulfill({
status: 502,
contentType: "application/json",
body: JSON.stringify({ error: "login_after_change_failed" }),
});
});

await page.goto(`${APP.CHANGE_PASSWORD_REQUIRED}?email=test%40example.com`);
await page.getByLabel(/current password/i).fill("old-password");
await page.getByLabel(/^new password/i).fill("New-password1");
await page.getByLabel(/confirm new password/i).fill("New-password1");
await page.getByRole("button", { name: /change password/i }).click();

// Verify focus moved to the success heading
const successHeading = page.getByRole("heading", { name: /password changed/i });
await expect(successHeading).toBeFocused();
});
});
34 changes: 34 additions & 0 deletions e2e/fixtures/api-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ export interface ApiMock {
*/
mockPermissions(options?: { permissions?: string[] }): Promise<void>;
mockUnauthorized(urlPattern: string | RegExp): Promise<void>;
/**
* Mocks POST /auth/change-password-required, the BFF's route used by
* PasswordChangeRequired.tsx (client/src/pages/) after a "password change
* required" login failure. On success it returns the same { user,
* csrfToken } shape as /auth/login — the BFF re-authenticates with the new
* password and establishes a real session as part of this one call.
*/
mockChangePasswordRequired(options?: {
user?: MockUser;
status?: number;
detail?: string;
}): Promise<void>;
}

export function createApiMock(page: Page): ApiMock {
Expand Down Expand Up @@ -102,6 +114,28 @@ export function createApiMock(page: Page): ApiMock {
});
},

async mockChangePasswordRequired({
user = DEFAULT_TEST_USER,
status = 200,
detail = "Invalid credentials",
} = {}) {
await page.route("**/auth/change-password-required", async (route) => {
if (status === 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify({ user, csrfToken: MOCK_CSRF_TOKEN }),
});
return;
}
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify({ error: "change_password_failed", detail }),
});
});
},

async mockUnauthorized(urlPattern) {
await page.route(urlPattern, async (route) => {
await route.fulfill({
Expand Down
2 changes: 2 additions & 0 deletions e2e/utils/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const APP = {
LOGIN: "/app/login",
FORGOT_PASSWORD: "/app/forgot-password",
CHANGE_PASSWORD: "/app/change-password", // pragma: allowlist secret
CHANGE_PASSWORD_REQUIRED: "/app/change-password-required", // pragma: allowlist secret
GATEWAYS: "/app/gateways",
SERVERS: "/app/servers",
TOOLS: "/app/tools",
Expand Down Expand Up @@ -40,6 +41,7 @@ export const APP = {
export const API = {
LOGIN: "**/auth/login",
SESSION: "**/auth/session",
CHANGE_PASSWORD_REQUIRED: "**/auth/change-password-required",
} as const;

/**
Expand Down
2 changes: 2 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import redisPlugin from "./plugins/redis.js";
import sessionPlugin from "./plugins/session.js";
import staticPlugin from "./plugins/static.js";
import appRoute from "./routes/app.js";
import changePasswordRequiredRoute from "./routes/auth/change-password-required.js";
import loginRoute from "./routes/auth/login.js";
import logoutRoute from "./routes/auth/logout.js";
import sessionRoute from "./routes/auth/session.js";
Expand All @@ -37,6 +38,7 @@ fastify.get("/healthz", async () => ({ ok: true }));
await fastify.register(loginRoute);
await fastify.register(logoutRoute);
await fastify.register(sessionRoute);
await fastify.register(changePasswordRequiredRoute);
await fastify.register(sseRoutes);
await fastify.register(catchAllProxyRoute);
await fastify.register(appRoute);
Expand Down
92 changes: 92 additions & 0 deletions server/src/lib/establish-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Location: ./client/server/src/lib/establish-session.ts
// Copyright contributors to the MCP-CONTEXT-FORGE project
// SPDX-License-Identifier: Apache-2.0
//
// Given an upstream AuthenticationResponse, create the BFF session + rotate
// the CSRF secret + hand back what the caller needs to reply with. Shared by
// routes/auth/login.ts and routes/auth/change-password-required.ts so the
// JWT-lifetime/session-TTL matching and CSRF-rotation security properties
// live in exactly one place.

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

import { config } from "../config.js";
import { createSession, setSessionCookie, type SessionUser } from "./session-store.js";
import { CSRF_COOKIE_NAME } from "../plugins/csrf.js";

// Mirrors mcpgateway.schemas.AuthenticationResponse. expires_in is optional —
// not every upstream login-shaped endpoint this BFF calls is guaranteed to
// send it (see lib/upstream-login.ts's Tier-1 /auth/login bypass call).
export interface UpstreamAuthenticationResponse {
access_token: string;
expires_in?: number;
user: SessionUser;
}

/**
* Thrown when the upstream auth response still reports
* password_change_required=true. This is the single chokepoint every
* session-establishing route goes through, so it's also the single place
* that guarantees a session is never minted for a still-flagged account —
* callers don't each have to re-implement that check correctly. In today's
* two callers this should never actually trip (routes/auth/login.ts only
* gets here after upstream's own 2xx says the account is clear;
* routes/auth/change-password-required.ts only gets here after a successful
* change), but it's the backstop for any future login path (SSO callback,
* admin impersonation, ...) that calls establishSession() directly.
*/
export class PasswordChangeStillRequiredError extends Error {
constructor() {
super("upstream auth response still has password_change_required=true");
this.name = "PasswordChangeStillRequiredError";
}
}

export async function establishSession(
fastify: FastifyInstance,
request: FastifyRequest,
reply: FastifyReply,
auth: UpstreamAuthenticationResponse,
): Promise<{ user: SessionUser; csrfToken: string }> {
if (auth.user?.password_change_required === true) {
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;
if (Number.isFinite(auth.expires_in) && auth.expires_in! > 0) {
ttlSeconds = 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).
request.log.warn(
{ expires_in: auth.expires_in },
"upstream login returned invalid expires_in, using BFF default session TTL",
);
}

const sessionId = await createSession(
fastify.redis,
{ bearerToken: auth.access_token, user: auth.user },
ttlSeconds,
);

setSessionCookie(reply, sessionId, ttlSeconds);
// generateCsrf() only mints a fresh secret when request.cookies has no
// bff_csrf entry — reply.clearCookie() alone doesn't clear that (it only
// queues an outgoing Set-Cookie, request.cookies is untouched), so delete
// it directly to force rotation. Otherwise a secret planted before login
// (subdomain XSS, a plaintext hop with COOKIE_SECURE=false) survives into
// the authenticated session.
delete request.cookies[CSRF_COOKIE_NAME];
// Cookie holds the CSRF secret (HttpOnly); the SPA needs the derived token
// itself to echo back via X-CSRF-Token — see plugins/csrf.ts.
const csrfToken = await reply.generateCsrf();

return { user: auth.user, csrfToken };
}
Loading
Loading