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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,13 @@ SSO_ENABLED=false
# the email's local part. See e2e/README.md.
E2E_TEST_EMAIL=
E2E_TEST_PASSWORD=

# The real Keycloak SSO e2e test needs SSO_ENABLED=true in .env (the value
# above is false). docker-compose.e2e.yml reads it from .env and defaults to
# true only when unset.
#
# Optional overrides for the real Keycloak SSO e2e test -- default to the
# test user seeded in e2e/keycloak/realm-export.json, so these are only
# needed to log in as someone else.
# E2E_SSO_USERNAME=ssotest@example.com
# E2E_SSO_PASSWORD=changeme-e2e-sso
64 changes: 63 additions & 1 deletion .secrets.baseline

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

55 changes: 54 additions & 1 deletion docker-compose.e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,20 @@ services:
env_file:
- .env
environment:
PORT: ${PORT:-3000}
REDIS_URL: redis://redis:6379/0
CONTEXTFORGE_URL: http://gateway:4444
SSO_ENABLED: ${SSO_ENABLED:-true}
SSO_KEYCLOAK_BASE_URL: http://keycloak:8080
SSO_KEYCLOAK_PUBLIC_BASE_URL: http://localhost:8180
SSO_KEYCLOAK_REALM: mcp-gateway
SSO_KEYCLOAK_CLIENT_ID: contextforge-web-ui
SSO_KEYCLOAK_CLIENT_SECRET: contextforge-web-ui-e2e-secret
depends_on:
redis:
condition: service_healthy
keycloak:
condition: service_healthy
restart: "no"

redis:
Expand All @@ -32,9 +41,10 @@ services:

gateway:
# Keep in sync with openapi.json's `info.version` — the API surface this repo's BFF proxies to.
image: ghcr.io/ibm/mcp-context-forge:v1.0.9
image: ghcr.io/ibm/mcp-context-forge:v1.0.11
environment:
HOST: 0.0.0.0
GUNICORN_WORKERS: "1"
DATABASE_URL: sqlite:///./data/mcp.db
# `:-` not `-`: E2E_TEST_EMAIL/PASSWORD ship blank in .env.example, so
# fall back even when set-but-empty.
Expand All @@ -43,16 +53,59 @@ services:
# E2E_TEST_PASSWORD before tests run — real logins never use this one.
PLATFORM_ADMIN_PASSWORD: ${E2E_BOOTSTRAP_PASSWORD:-changeme-e2e-bootstrap-pwd1}
PLATFORM_ADMIN_FULL_NAME: E2E Test User
DEFAULT_USER_PASSWORD: ${DEFAULT_USER_PASSWORD:-e2e-testing-default-pwd1}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-e2e-testing-secret-key-not-for-prod}
AUTH_ENCRYPTION_SECRET: ${AUTH_ENCRYPTION_SECRET:-e2e-testing-secret-not-for-prod-32ch}
MCPGATEWAY_UI_ENABLED: "true"
MCPGATEWAY_ADMIN_API_ENABLED: "true"
AUTH_REQUIRED: "true"
# Parallel workers each logging in for real blow past the login rate limit and lock the account.
RATE_LIMITING_ENABLED: "false"
SSO_ENABLED: "true"
SSO_KEYCLOAK_ENABLED: "true"
SSO_KEYCLOAK_BASE_URL: http://keycloak:8080
SSO_KEYCLOAK_PUBLIC_BASE_URL: http://localhost:8180
SSO_KEYCLOAK_REALM: mcp-gateway
SSO_KEYCLOAK_CLIENT_ID: mcp-gateway
SSO_KEYCLOAK_CLIENT_SECRET: keycloak-dev-secret
SSO_API_TOKEN_AUTH_ENABLED: "true"
ports:
- "4444:4444"
depends_on:
keycloak:
condition: service_healthy
restart: "no"

keycloak:
image: quay.io/keycloak/keycloak:26.1
environment:
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-changeme-e2e-keycloak-admin}
KC_HOSTNAME: ${KC_HOSTNAME:-http://localhost:8180}
KC_HOSTNAME_STRICT: ${KC_HOSTNAME_STRICT:-true}
KC_HTTP_ENABLED: "true"
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "false"
command: ["start-dev", "--import-realm"]
volumes:
- ./e2e/keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
- keycloak-data:/opt/keycloak/data
ports:
- "127.0.0.1:8180:8080"
healthcheck:
test:
[
"CMD",
"bash",
"-ec",
"exec 3<>/dev/tcp/127.0.0.1/9000; printf 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3; grep -Eq '\"status\"[[:space:]]*:[[:space:]]*\"UP\"' <&3",
]
interval: 10s
timeout: 5s
retries: 20
start_period: 30s
restart: "no"

volumes:
redis-data:
keycloak-data:
86 changes: 68 additions & 18 deletions e2e/auth/sso-login.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/**
* Mocked Keycloak SSO login flow (mcp-context-forge#6919, Task 4.1).
* Mocked Keycloak SSO login flow.
*
* The real round trip -- browser redirected to Keycloak's own login UI,
* then back to the BFF's /auth/sso/callback, which exchanges the code and
* calls establishSession() -- can't be driven through a real IdP in CI
* (that's Task 4.2, against a real backend). What's testable end to end
* calls establishSession() -- can't be driven through a real IdP in the
* mocked suite (see "Real Keycloak SSO login flow" below). What's testable end to end
* through a real browser, without any backend, is the client-side contract
* that round trip feeds into: Login.tsx's full-page redirect out and back,
* the post-redirect session bootstrap, the generic error rendering for any
Expand All @@ -13,6 +13,7 @@
* the hop that can't run here, let everything else run for real.
*/
import { test, expect, DEFAULT_TEST_USER, MOCK_CSRF_TOKEN } from "../fixtures/api-mock";
import { realSsoLogin } from "../fixtures/real-sso-login";
import { API, APP } from "../utils/paths";
import type { Page } from "@playwright/test";

Expand Down Expand Up @@ -54,24 +55,24 @@ async function mockSessionTransition(page: Page): Promise<void> {
}

test.describe("Mocked Keycloak SSO login flow", () => {
// Pure client-side contract tests -- no real backend involved. A faked
// /auth/session has no real cookie behind it, so under E2E_REAL_API every
// other proxied call 401s and bounces back to /app/login, racing these
// tests. "Real Keycloak SSO login flow" below is the E2E_REAL_API coverage.
test.skip(IS_REAL_API, "mocked-only, see 'Real Keycloak SSO login flow'");

test.beforeEach(async ({ apiMock }) => {
await apiMock.mockSession({
authenticated: false,
ssoEnabled: true,
providerName: "Keycloak",
});
await apiMock.mockPermissions();
});

test('clicking "Sign in with Keycloak" navigates away, and a successful callback lands authenticated', async ({
page,
}) => {
// Same root cause as the "logout" test's skip below: mockSessionTransition
// fakes /auth/session into an authenticated state with no corresponding
// real backend session, so other real API calls the dashboard needs can
// 401 and bounce back to login. Passes in isolation but is a genuine
// race under full-suite parallel load (confirmed: 3/3 passes alone,
// intermittent failure under `npm run e2e:docker`).
test.skip(IS_REAL_API, "mocked SSO round-trip only -- doesn't apply under a real backend");
await mockSessionTransition(page);
// Stands in for the entire server-side code-exchange + establishSession()
// round trip -- sso-callback.ts's own success path ends in exactly this
Expand Down Expand Up @@ -105,14 +106,6 @@ test.describe("Mocked Keycloak SSO login flow", () => {
});

test("logout clears the session and returns to the login page", async ({ page }) => {
// mockSessionTransition fakes /auth/session with a made-up csrfToken --
// no real backend session is ever established, since the whole SSO
// round-trip this file tests is mocked (see file header: real SSO
// against a real IdP is Task 4.2). Under E2E_REAL_API, every other
// call the authenticated app makes hits the real backend for real and
// 401s (no real session cookie), which bounces straight back to
// /app/login -- the "Test User" click races that live navigation.
test.skip(IS_REAL_API, "mocked SSO round-trip only -- doesn't apply under a real backend");
await mockSessionTransition(page);
await page.route(API.SSO_LOGIN, async (route) => {
await route.fulfill({ status: 302, headers: { location: APP.ROOT } });
Expand All @@ -132,3 +125,60 @@ test.describe("Mocked Keycloak SSO login flow", () => {
await expect(page.getByLabel("Password")).toBeVisible();
});
});

async function loginThroughRealKeycloak(page: Page): Promise<void> {
await page.goto(APP.LOGIN);
await page.getByRole("button", { name: /Sign in with Keycloak/i }).click();
// Real Keycloak's own login form now, not this app's.
await realSsoLogin(page);
await page.waitForURL(new RegExp(`${APP.ROOT}$`));
}

test.describe("Real Keycloak SSO login flow", () => {
test.skip(!IS_REAL_API, "requires E2E_REAL_API=true against the real Keycloak stack");
// TODO(#190): v1.0.11's get_current_user() never dispatches external-IdP
// bearer tokens -- every real-login API call 401s. Fixed on
// mcp-context-forge main, ships in v1.0.12. Remove this skip once
// docker-compose.e2e.yml's gateway image is bumped to v1.0.12+.
test.skip(IS_REAL_API, "blocked on mcp-context-forge v1.0.12 (external-IdP token dispatch fix)");

test("logs in through the real Keycloak form and makes a real proxied API call", async ({
page,
}) => {
await loginThroughRealKeycloak(page);
await expect(page.getByRole("button", { name: "Home" })).toBeVisible();

// Proves the gateway actually accepted the Keycloak-derived bearer token
// forwarded by the BFF's proxy, not just that a BFF-side cookie exists.
// Must go through /api/* (catch-all.ts) -- the bare path falls through
// to the SPA shell and always 200s regardless of auth state.
const response = await page.request.get("/api/rbac/my/permissions");
expect(response.ok()).toBeTruthy();
});

test("logout clears the BFF session and ends the Keycloak SSO session too", async ({ page }) => {
await loginThroughRealKeycloak(page);
await expect(page.getByRole("button", { name: "Home" })).toBeVisible();

// Not "Test User" -- that's the mocked fixture's name. The real
// Keycloak user's full_name comes from realm-export.json's firstName/lastName.
await page.getByRole("button", { name: "SSO Test" }).click();
await page.getByRole("button", { name: "Sign Out" }).click();

// Real logout can land on a bare /app/login (client-side redirect) or on
// /app/login?next=... (a dashboard background call 401ing first) -- both
// are the same "actually logged out" outcome, see smoke/app-loads.spec.ts.
await expect(page).toHaveURL(/\/app\/login(\?.*)?$/);
await expect(page.getByLabel("Password")).toBeVisible();

// BFF side: the session is actually gone, not just the cookie cleared
// client-side. Same /api/* note as the login test above.
const response = await page.request.get("/api/rbac/my/permissions");
expect(response.status()).toBe(401);

// Keycloak side: a live SSO session would silently re-approve this next
// request instead of showing the login form again -- only a real IdP can catch that.
await page.getByRole("button", { name: /Sign in with Keycloak/i }).click();
await expect(page.locator("#username")).toBeVisible();
});
});
21 changes: 21 additions & 0 deletions e2e/fixtures/real-sso-login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Drives a REAL Keycloak login form (E2E_REAL_API=true only, against the
* `keycloak` service in docker-compose.e2e.yml). Unlike real-login.ts, there's
* no BFF endpoint to call directly here -- the whole point of this flow is
* the browser redirect through Keycloak's own UI, so this fills that UI's
* actual rendered form instead of hitting an API.
*/

import type { Page } from "@playwright/test";

// Keycloak's default theme's stable field IDs (verified against a running
// 26.1 container) -- not this app's own components, so no accessible-label
// locators here.
export async function realSsoLogin(page: Page): Promise<void> {
const username = process.env.E2E_SSO_USERNAME ?? "ssotest@example.com";
const password = process.env.E2E_SSO_PASSWORD ?? "changeme-e2e-sso"; // pragma: allowlist secret

await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
}
20 changes: 20 additions & 0 deletions e2e/keycloak/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# e2e Keycloak realm

`realm-export.json` is a self-contained Keycloak realm for `docker-compose.e2e.yml`'s
`keycloak` service, used by the real-backend SSO e2e suite. It's modeled on (not synced
with, and not symlinked to) `mcp-context-forge`'s own `infra/keycloak/realm-export.json` —
this repo's e2e suite never needs a sibling-repo checkout.

It seeds two clients (`mcp-gateway`, for the real gateway image; `contextforge-web-ui`, for
this repo's own BFF) and one test user.

The `contextforge-web-ui` client allows the BFF callback on `http://localhost:3000` (default
`npm run e2e:docker`) and `https://localhost:8443` (TLS variant). Keycloak redirect URIs allow
a wildcard only at the end of the path, so a custom `PORT` needs its own entry in
`redirectUris` and `webOrigins`.

**Dev/test only — never use these values in production.** Every secret and password here
(`keycloak-dev-secret`, `contextforge-web-ui-e2e-secret`, the admin password set on the
`keycloak` service, the test user's password) is fixed, plaintext, and committed to this
repo. Change every one of them before this realm, or any client registered against it, is
anywhere near a real deployment.
Loading
Loading