From 3a6b5636d5a16a8213e300117e8dcbd324dd54b0 Mon Sep 17 00:00:00 2001 From: Gabriel Costa Date: Fri, 9 Oct 2026 11:29:27 +0100 Subject: [PATCH 1/2] test(e2e): self-contained Keycloak for e2e stack Add a `keycloak` service and `e2e/keycloak/realm-export.json` to docker-compose.e2e.yml so this repo's e2e suite never needs a sibling mcp-context-forge checkout to exercise SSO. The realm seeds two clients (`mcp-gateway` for the real gateway image, `contextforge-web-ui` for this repo's own BFF) and one test user, modeled on (not synced with) mcp-context-forge's own infra/keycloak/realm-export.json. Also pins KC_HOSTNAME to a full public URL with KC_HOSTNAME_STRICT=true, required so the BFF's internal discovery fetch and the browser's real token issuance agree on the same issuer -- diverges intentionally from mcp-context-forge's own docker-compose.sso.yml convention, which doesn't need that consistency. Signed-off-by: Gabriel Costa Signed-off-by: Gabriel Costa --- .env.example | 6 ++ .secrets.baseline | 64 +++++++++++++++- docker-compose.e2e.yml | 55 +++++++++++++- e2e/auth/sso-login.spec.ts | 64 ++++++++++++++++ e2e/fixtures/real-sso-login.ts | 21 ++++++ e2e/keycloak/README.md | 15 ++++ e2e/keycloak/realm-export.json | 88 ++++++++++++++++++++++ e2e/seed/seed.ts | 132 ++++++++++++++++++++++++++++----- 8 files changed, 423 insertions(+), 22 deletions(-) create mode 100644 e2e/fixtures/real-sso-login.ts create mode 100644 e2e/keycloak/README.md create mode 100644 e2e/keycloak/realm-export.json diff --git a/.env.example b/.env.example index abb74e19..251e9066 100644 --- a/.env.example +++ b/.env.example @@ -103,3 +103,9 @@ SSO_ENABLED=false # the email's local part. See e2e/README.md. E2E_TEST_EMAIL= E2E_TEST_PASSWORD= + +# Optional overrides for the real Keycloak SSO e2e test (Task 6.2) -- 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 diff --git a/.secrets.baseline b/.secrets.baseline index 596507a5..99433e17 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "(?x)(package-lock\\.json$)|^\\.secrets\\.baseline$|src/i18n/locales/*|openapi.json|^.secrets.baseline$", "lines": null }, - "generated_at": "2026-10-09T13:53:36Z", + "generated_at": "2026-10-12T13:05:30Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -85,6 +85,68 @@ "line_number": 95, "type": "Secret Keyword", "verified_result": null + }, + { + "hashed_secret": "c1230136cf11214817a89d4d3260a827fa8ebe32", + "is_secret": false, + "is_verified": false, + "line_number": 111, + "type": "Secret Keyword", + "verified_result": null + } + ], + "docker-compose.e2e.yml": [ + { + "hashed_secret": "79a9c8ac01ebcabbd767a7786dd47b7efef9e562", + "is_secret": false, + "is_verified": false, + "line_number": 23, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "65724217e73023f759367bcb3375ea4c0a618418", + "is_secret": false, + "is_verified": false, + "line_number": 70, + "type": "Secret Keyword", + "verified_result": null + } + ], + "e2e/keycloak/realm-export.json": [ + { + "hashed_secret": "65724217e73023f759367bcb3375ea4c0a618418", + "is_secret": false, + "is_verified": false, + "line_number": 19, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "79a9c8ac01ebcabbd767a7786dd47b7efef9e562", + "is_secret": false, + "is_verified": false, + "line_number": 42, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "1e5c2f367f02e47a8c160cda1cd9d91decbac441", + "is_secret": false, + "is_verified": false, + "line_number": 79, + "type": "Secret Keyword", + "verified_result": null + } + ], + "e2e/seed/seed.ts": [ + { + "hashed_secret": "6947818ac409551f11fbaa78f0ea6391960aa5b8", + "is_secret": false, + "is_verified": false, + "line_number": 81, + "type": "Secret Keyword", + "verified_result": null } ] }, diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index f7385f2a..f8b126cc 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -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: @@ -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. @@ -43,6 +53,7 @@ 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" @@ -50,9 +61,51 @@ services: 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: + - "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: diff --git a/e2e/auth/sso-login.spec.ts b/e2e/auth/sso-login.spec.ts index 74b2546b..de2068be 100644 --- a/e2e/auth/sso-login.spec.ts +++ b/e2e/auth/sso-login.spec.ts @@ -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"; @@ -54,12 +55,18 @@ async function mockSessionTransition(page: Page): Promise { } test.describe("Mocked Keycloak SSO login flow", () => { + // Pure client-side contract tests -- no real backend involved, and a + // faked session has no real cookie behind it to survive a real proxied + // call. "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 ({ @@ -132,3 +139,60 @@ test.describe("Mocked Keycloak SSO login flow", () => { await expect(page.getByLabel("Password")).toBeVisible(); }); }); + +async function loginThroughRealKeycloak(page: Page): Promise { + 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 (mcp-context-forge#6928, Task 6.2)", () => { + test.skip(!IS_REAL_API, "requires E2E_REAL_API=true against the real Keycloak stack"); + // TODO: 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(); + }); +}); diff --git a/e2e/fixtures/real-sso-login.ts b/e2e/fixtures/real-sso-login.ts new file mode 100644 index 00000000..d9945caf --- /dev/null +++ b/e2e/fixtures/real-sso-login.ts @@ -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 { + 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(); +} diff --git a/e2e/keycloak/README.md b/e2e/keycloak/README.md new file mode 100644 index 00000000..39062842 --- /dev/null +++ b/e2e/keycloak/README.md @@ -0,0 +1,15 @@ +# 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. + +**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. diff --git a/e2e/keycloak/realm-export.json b/e2e/keycloak/realm-export.json new file mode 100644 index 00000000..aced045d --- /dev/null +++ b/e2e/keycloak/realm-export.json @@ -0,0 +1,88 @@ +{ + "realm": "mcp-gateway", + "enabled": true, + "displayName": "ContextForge E2E Realm", + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "verifyEmail": false, + "resetPasswordAllowed": true, + "sslRequired": "none", + "clients": [ + { + "clientId": "mcp-gateway", + "name": "ContextForge Gateway (e2e)", + "description": "Preconfigured docker-compose.e2e.yml client for the real gateway image", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "clientAuthenticatorType": "client-secret", + "secret": "keycloak-dev-secret", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true, + "redirectUris": ["http://localhost:4444/auth/sso/callback/keycloak"], + "webOrigins": ["http://localhost:4444"], + "rootUrl": "http://localhost:4444", + "baseUrl": "http://localhost:4444", + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "http://localhost:4444/*" + }, + "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "microprofile-jwt"] + }, + { + "clientId": "contextforge-web-ui", + "name": "ContextForge Web UI (e2e)", + "description": "Preconfigured docker-compose.e2e.yml client for the contextforge-web-ui BFF", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "clientAuthenticatorType": "client-secret", + "secret": "contextforge-web-ui-e2e-secret", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "redirectUris": ["http://localhost:3000/auth/sso/callback"], + "webOrigins": ["http://localhost:3000"], + "rootUrl": "http://localhost:3000", + "baseUrl": "http://localhost:3000", + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "http://localhost:3000/*" + }, + "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "microprofile-jwt"], + "protocolMappers": [ + { + "name": "audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.custom.audience": "mcp-gateway-api", + "id.token.claim": "false", + "access.token.claim": "true" + } + } + ] + } + ], + "users": [ + { + "username": "ssotest@example.com", + "enabled": true, + "emailVerified": true, + "firstName": "SSO", + "lastName": "Test", + "email": "ssotest@example.com", + "credentials": [ + { + "type": "password", + "value": "changeme-e2e-sso", + "temporary": false + } + ] + } + ] +} diff --git a/e2e/seed/seed.ts b/e2e/seed/seed.ts index 56d097ba..c7489347 100644 --- a/e2e/seed/seed.ts +++ b/e2e/seed/seed.ts @@ -10,6 +10,8 @@ const AUTH_HEADER_NAME = process.env.CONTEXTFORGE_AUTH_HEADER_NAME ?? "Authoriza const EMAIL = process.env.E2E_TEST_EMAIL; const BOOTSTRAP_PASSWORD = process.env.E2E_BOOTSTRAP_PASSWORD ?? "changeme-e2e-bootstrap-pwd1"; //pragma: allowlist secret const NEW_PASSWORD = process.env.E2E_TEST_PASSWORD; //pragma: allowlist secret +const SSO_SEED_REALM = process.env.SSO_SEED_REALM ?? "mcp-gateway"; +const SSO_SEED_API_AUDIENCE = process.env.SSO_SEED_API_AUDIENCE ?? "mcp-gateway-api"; const HEALTH_RETRIES = 30; const HEALTH_RETRY_DELAY_MS = 2000; @@ -40,7 +42,30 @@ async function upstreamLogin( return { status: res.status, body: await res.text() }; } -// Clears the bootstrap admin's password_change_required flag; returns a bearer token either way. +// Changes the admin's password away from BOOTSTRAP_PASSWORD using a token +// obtained with it, then confirms NEW_PASSWORD actually works. +async function rotatePassword(bootstrapToken: string): Promise { + const changeRes = await fetch(`${GATEWAY_URL}/auth/email/change-password`, { + method: "POST", + headers: { + "content-type": "application/json", + [AUTH_HEADER_NAME]: `Bearer ${bootstrapToken}`, + }, + body: JSON.stringify({ old_password: BOOTSTRAP_PASSWORD, new_password: NEW_PASSWORD }), // pragma: allowlist secret + }); + if (!changeRes.ok) { + throw new Error(`Password change failed: ${changeRes.status} ${await changeRes.text()}`); + } + + const confirm = await upstreamLogin("/auth/email/login", EMAIL!, NEW_PASSWORD!); + if (confirm.status !== 200) { + throw new Error(`Post-change login failed: ${confirm.status} ${confirm.body}`); + } + return (JSON.parse(confirm.body) as { access_token: string }).access_token; +} + +// Clears the bootstrap admin's password_change_required flag (if set) and +// rotates its password to NEW_PASSWORD; returns a bearer token either way. async function clearForcedPasswordChange(): Promise { if (!EMAIL || !NEW_PASSWORD) { throw new Error("E2E_TEST_EMAIL and E2E_TEST_PASSWORD must be set (see .env.example)."); @@ -48,9 +73,19 @@ async function clearForcedPasswordChange(): Promise { const precondition = await upstreamLogin("/auth/email/login", EMAIL, BOOTSTRAP_PASSWORD); if (precondition.status === 200) { - // Guards a gateway provisioned outside our down-v-every-run flow, or with the bootstrap check disabled. - console.log("Bootstrap admin already past the forced password change — nothing to do."); - return (JSON.parse(precondition.body) as { access_token: string }).access_token; + // No forced change on this gateway/config -- password_change_required + // was already false. Still need to rotate: this branch means the + // password is still BOOTSTRAP_PASSWORD, not NEW_PASSWORD, and callers + // (this script's own "Seed OK" log, e2e password-login tests) expect + // E2E_TEST_EMAIL/PASSWORD to actually work. + if (BOOTSTRAP_PASSWORD === NEW_PASSWORD) { + console.log("Bootstrap admin already past the forced password change — nothing to do."); + return (JSON.parse(precondition.body) as { access_token: string }).access_token; + } + const { access_token: bootstrapToken } = JSON.parse(precondition.body) as { + access_token: string; + }; + return await rotatePassword(bootstrapToken); } if (precondition.status === 401) { // Bootstrap password rejected (not "change required") — an interrupted prior run likely already rotated it. @@ -72,34 +107,91 @@ async function clearForcedPasswordChange(): Promise { throw new Error(`Bypass login failed: ${bypass.status} ${bypass.body}`); } const { access_token: bypassToken } = JSON.parse(bypass.body) as { access_token: string }; + return await rotatePassword(bypassToken); +} - const changeRes = await fetch(`${GATEWAY_URL}/auth/email/change-password`, { - method: "POST", - headers: { - "content-type": "application/json", - [AUTH_HEADER_NAME]: `Bearer ${bypassToken}`, - }, - body: JSON.stringify({ old_password: BOOTSTRAP_PASSWORD, new_password: NEW_PASSWORD }), // pragma: allowlist secret +// TODO(follow-up): seed real servers/tools once mocked suites assert on real data — blocked on an upstream role-bootstrap bug (422s team-scoped creates). + +interface SsoProviderSummary { + id: string; +} + +interface SsoProviderDetail { + provider_metadata?: { realm?: string }; + issuer?: string; + trusted_for_api_auth?: boolean; + api_audience?: string; +} + +// trusted_for_api_auth/api_audience have no env-var bootstrap on the gateway +// (confirmed in mcp-context-forge) -- same admin-API call as that repo's own +// `make compose-sso-seed-audience` Makefile target, ported to fetch. A no-op +// (not an error) when no SSO provider exists at all -- the TLS-only e2e +// variant (npm run e2e:docker:tls) shares this script but never sets +// SSO_KEYCLOAK_*, so its gateway has none. +async function seedSsoTrustedApiAuth(adminToken: string): Promise { + const api = `${GATEWAY_URL}/auth/sso/admin/providers`; + const authHeader = { [AUTH_HEADER_NAME]: `Bearer ${adminToken}` }; + + const listRes = await fetch(api, { headers: authHeader }); + if (!listRes.ok) { + throw new Error(`Could not list SSO providers: ${listRes.status} ${await listRes.text()}`); + } + const providers = (await listRes.json()) as SsoProviderSummary[]; + if (providers.length === 0) { + console.log("No SSO providers configured on this gateway — skipping."); + return; + } + + let matched: string | undefined; + for (const { id } of providers) { + const detailRes = await fetch(`${api}/${id}`, { headers: authHeader }); + if (!detailRes.ok) continue; + const detail = (await detailRes.json()) as SsoProviderDetail; + const realmMatches = + detail.provider_metadata?.realm === SSO_SEED_REALM || + (detail.issuer ?? "").endsWith(`/realms/${SSO_SEED_REALM}`); + if (realmMatches) { + matched = id; + break; + } + } + if (!matched) { + throw new Error( + `No SSO provider matches Keycloak realm '${SSO_SEED_REALM}' (found: ${providers.map((p) => p.id).join(", ")})`, + ); + } + + const putRes = await fetch(`${api}/${matched}`, { + method: "PUT", + headers: { "content-type": "application/json", ...authHeader }, + body: JSON.stringify({ trusted_for_api_auth: true, api_audience: SSO_SEED_API_AUDIENCE }), }); - if (!changeRes.ok) { - throw new Error(`Password change failed: ${changeRes.status} ${await changeRes.text()}`); + if (!putRes.ok) { + throw new Error(`Seeding trusted_for_api_auth failed: ${putRes.status} ${await putRes.text()}`); } - const confirm = await upstreamLogin("/auth/email/login", EMAIL, NEW_PASSWORD); - if (confirm.status !== 200) { - throw new Error(`Post-change login failed: ${confirm.status} ${confirm.body}`); + const verifyRes = await fetch(`${api}/${matched}`, { headers: authHeader }); + const verify = (await verifyRes.json()) as SsoProviderDetail; + if (verify.trusted_for_api_auth !== true || verify.api_audience !== SSO_SEED_API_AUDIENCE) { + throw new Error( + `Provider '${matched}' did not keep trusted_for_api_auth=true / api_audience=${SSO_SEED_API_AUDIENCE}`, + ); } - return (JSON.parse(confirm.body) as { access_token: string }).access_token; + console.log( + `SSO provider '${matched}' trusted for API auth (api_audience=${SSO_SEED_API_AUDIENCE}).`, + ); } -// TODO(follow-up): seed real servers/tools once mocked suites assert on real data — blocked on an upstream role-bootstrap bug (422s team-scoped creates). - async function main() { console.log(`Waiting for gateway at ${GATEWAY_URL}...`); await waitForHealthy(); console.log("Gateway healthy. Clearing bootstrap admin's forced password change..."); - await clearForcedPasswordChange(); + const adminToken = await clearForcedPasswordChange(); console.log("Seed OK — admin can log in with E2E_TEST_EMAIL/PASSWORD."); + + console.log("Trusting Keycloak access tokens for API auth..."); + await seedSsoTrustedApiAuth(adminToken); } main().catch((err) => { From c5a8c8263f80f4859a68665c19896b5435a05eb7 Mon Sep 17 00:00:00 2001 From: Marek Dano Date: Tue, 13 Oct 2026 16:02:58 +0100 Subject: [PATCH 2/2] test(e2e): address review feedback on Keycloak e2e stack - Bind Keycloak to 127.0.0.1 so the fixed admin password and client secrets stay off other host interfaces. - Allow the HTTPS callback on port 8443 for the contextforge-web-ui client, and document that a custom PORT needs its own redirect URI. - Throw when the seed cannot read or re-read an SSO provider, instead of continuing silently. - Correct the seed comment about the TLS variant. - Remove redundant per-test skips from the mocked SSO suite. The describe-level skip covers them. - Link the skipped real SSO suite to #190. - Drop task numbers from comments and test titles. - Document in .env.example that the real SSO test needs SSO_ENABLED=true. Signed-off-by: Marek Dano --- .env.example | 10 ++++++--- .secrets.baseline | 6 +++--- docker-compose.e2e.yml | 2 +- e2e/auth/sso-login.spec.ts | 38 +++++++++++----------------------- e2e/keycloak/README.md | 5 +++++ e2e/keycloak/realm-export.json | 9 +++++--- e2e/seed/seed.ts | 15 ++++++++++---- 7 files changed, 45 insertions(+), 40 deletions(-) diff --git a/.env.example b/.env.example index 251e9066..248ef537 100644 --- a/.env.example +++ b/.env.example @@ -104,8 +104,12 @@ SSO_ENABLED=false E2E_TEST_EMAIL= E2E_TEST_PASSWORD= -# Optional overrides for the real Keycloak SSO e2e test (Task 6.2) -- default -# to the test user seeded in e2e/keycloak/realm-export.json, so these are -# only needed to log in as someone else. +# 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 diff --git a/.secrets.baseline b/.secrets.baseline index 99433e17..a1d73185 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "(?x)(package-lock\\.json$)|^\\.secrets\\.baseline$|src/i18n/locales/*|openapi.json|^.secrets.baseline$", "lines": null }, - "generated_at": "2026-10-12T13:05:30Z", + "generated_at": "2026-10-13T14:57:20Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -90,7 +90,7 @@ "hashed_secret": "c1230136cf11214817a89d4d3260a827fa8ebe32", "is_secret": false, "is_verified": false, - "line_number": 111, + "line_number": 115, "type": "Secret Keyword", "verified_result": null } @@ -134,7 +134,7 @@ "hashed_secret": "1e5c2f367f02e47a8c160cda1cd9d91decbac441", "is_secret": false, "is_verified": false, - "line_number": 79, + "line_number": 82, "type": "Secret Keyword", "verified_result": null } diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index f8b126cc..1b787817 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -91,7 +91,7 @@ services: - ./e2e/keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro - keycloak-data:/opt/keycloak/data ports: - - "8180:8080" + - "127.0.0.1:8180:8080" healthcheck: test: [ diff --git a/e2e/auth/sso-login.spec.ts b/e2e/auth/sso-login.spec.ts index de2068be..a660ed9c 100644 --- a/e2e/auth/sso-login.spec.ts +++ b/e2e/auth/sso-login.spec.ts @@ -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 @@ -55,9 +55,10 @@ async function mockSessionTransition(page: Page): Promise { } test.describe("Mocked Keycloak SSO login flow", () => { - // Pure client-side contract tests -- no real backend involved, and a - // faked session has no real cookie behind it to survive a real proxied - // call. "Real Keycloak SSO login flow" below is the E2E_REAL_API coverage. + // 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 }) => { @@ -72,13 +73,6 @@ test.describe("Mocked Keycloak SSO login flow", () => { 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 @@ -112,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 } }); @@ -148,12 +134,12 @@ async function loginThroughRealKeycloak(page: Page): Promise { await page.waitForURL(new RegExp(`${APP.ROOT}$`)); } -test.describe("Real Keycloak SSO login flow (mcp-context-forge#6928, Task 6.2)", () => { +test.describe("Real Keycloak SSO login flow", () => { test.skip(!IS_REAL_API, "requires E2E_REAL_API=true against the real Keycloak stack"); - // TODO: 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+. + // 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 ({ diff --git a/e2e/keycloak/README.md b/e2e/keycloak/README.md index 39062842..5dcb63fb 100644 --- a/e2e/keycloak/README.md +++ b/e2e/keycloak/README.md @@ -8,6 +8,11 @@ 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 diff --git a/e2e/keycloak/realm-export.json b/e2e/keycloak/realm-export.json index aced045d..2ef3d706 100644 --- a/e2e/keycloak/realm-export.json +++ b/e2e/keycloak/realm-export.json @@ -43,13 +43,16 @@ "standardFlowEnabled": true, "directAccessGrantsEnabled": false, "serviceAccountsEnabled": false, - "redirectUris": ["http://localhost:3000/auth/sso/callback"], - "webOrigins": ["http://localhost:3000"], + "redirectUris": [ + "http://localhost:3000/auth/sso/callback", + "https://localhost:8443/auth/sso/callback" + ], + "webOrigins": ["http://localhost:3000", "https://localhost:8443"], "rootUrl": "http://localhost:3000", "baseUrl": "http://localhost:3000", "attributes": { "pkce.code.challenge.method": "S256", - "post.logout.redirect.uris": "http://localhost:3000/*" + "post.logout.redirect.uris": "http://localhost:3000/*##https://localhost:8443/*" }, "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "email"], "optionalClientScopes": ["address", "phone", "offline_access", "microprofile-jwt"], diff --git a/e2e/seed/seed.ts b/e2e/seed/seed.ts index c7489347..70884421 100644 --- a/e2e/seed/seed.ts +++ b/e2e/seed/seed.ts @@ -126,9 +126,7 @@ interface SsoProviderDetail { // trusted_for_api_auth/api_audience have no env-var bootstrap on the gateway // (confirmed in mcp-context-forge) -- same admin-API call as that repo's own // `make compose-sso-seed-audience` Makefile target, ported to fetch. A no-op -// (not an error) when no SSO provider exists at all -- the TLS-only e2e -// variant (npm run e2e:docker:tls) shares this script but never sets -// SSO_KEYCLOAK_*, so its gateway has none. +// (not an error) when the provider list is empty. async function seedSsoTrustedApiAuth(adminToken: string): Promise { const api = `${GATEWAY_URL}/auth/sso/admin/providers`; const authHeader = { [AUTH_HEADER_NAME]: `Bearer ${adminToken}` }; @@ -146,7 +144,11 @@ async function seedSsoTrustedApiAuth(adminToken: string): Promise { let matched: string | undefined; for (const { id } of providers) { const detailRes = await fetch(`${api}/${id}`, { headers: authHeader }); - if (!detailRes.ok) continue; + if (!detailRes.ok) { + throw new Error( + `Could not read SSO provider '${id}': ${detailRes.status} ${await detailRes.text()}`, + ); + } const detail = (await detailRes.json()) as SsoProviderDetail; const realmMatches = detail.provider_metadata?.realm === SSO_SEED_REALM || @@ -172,6 +174,11 @@ async function seedSsoTrustedApiAuth(adminToken: string): Promise { } const verifyRes = await fetch(`${api}/${matched}`, { headers: authHeader }); + if (!verifyRes.ok) { + throw new Error( + `Could not re-read SSO provider '${matched}': ${verifyRes.status} ${await verifyRes.text()}`, + ); + } const verify = (await verifyRes.json()) as SsoProviderDetail; if (verify.trusted_for_api_auth !== true || verify.api_audience !== SSO_SEED_API_AUDIENCE) { throw new Error(