Files
computer-playbook/roles/web-app-litellm/files/playwright/test-oidc-login.js
kevinveenbirkenbachandClaude Opus 5 d9f497688d feat(litellm): authenticate the admin UI through Keycloak
The role declared sso.enabled false behind three suppressions, so the checks were switched off rather than satisfied. LiteLLM authenticates its own admin UI instead of sitting behind an oauth2 proxy, so the declaration is flavor oidc and the client id, the three Keycloak endpoints, the proxy base URL and the admin id all render together under one flag: a half-set group would let the gateway fall back to its own form with nothing complaining. The variants pin both states so the matrix runs with and without SSO.

The proof has to measure the departure, not the arrival: a gateway ignoring the client id answers /sso/key/generate with its own username and password form and never redirects, so the spec asserts the round trip to Keycloak and back. The guest spec became SSO-aware, since its canonical-domain and password-field assertions only hold where no SSO is configured.

Two more gateway defects: the key warm-up ran even where no model was served, falling back to openrouter/auto so the gateway then answered every prompt with 400, and it is now gated on a served-model expression mirroring the model selection itself. A lint requires every declared svc-ai backend to have a model_list branch, because a backend without a route is an alias nobody serves. probe_auth.py proves revocation rather than creation: it mints a throwaway key, revokes it, and fails unless a real consumer key survives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:44:26 +02:00

52 lines
2.1 KiB
JavaScript

const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("./timeouts");
const { skipUnlessServiceEnabled } = require("./service-gating");
const {
decodeDotenvQuotedValue,
normalizeBaseUrl,
performKeycloakLoginForm,
gotoOnion,
} = require("./personas");
const baseUrl = normalizeBaseUrl(process.env.LITELLM_UI_BASE_URL || "");
const oidcIssuerUrl = normalizeBaseUrl(process.env.OIDC_ISSUER_URL || "");
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME || "");
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD || "");
test.use({ ignoreHTTPSErrors: true });
test("OIDC: the LiteLLM admin UI signs the administrator in through Keycloak", async ({ page }) => {
skipUnlessServiceEnabled("sso");
expect(baseUrl, "LITELLM_UI_BASE_URL must be set").toBeTruthy();
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set").toBeTruthy();
expect(adminUsername, "ADMIN_USERNAME must be set").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set").toBeTruthy();
await page.context().clearCookies();
const expectedBase = baseUrl.replace(/\/$/, "");
const expectedAuth = `${oidcIssuerUrl}/protocol/openid-connect/auth`;
await gotoOnion(page, `${expectedBase}/sso/key/generate`);
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: `expected /sso/key/generate to redirect to ${expectedAuth}; staying put means GENERIC_CLIENT_ID never reached the gateway`,
})
.toContain(expectedAuth);
await performKeycloakLoginForm(page, adminUsername, adminPassword);
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(90_000),
message: `expected Keycloak to redirect back to ${expectedBase}`,
})
.toContain(expectedBase);
await gotoOnion(page, `${expectedBase}/ui`, { waitUntil: "domcontentloaded" });
await expect(
page.locator("input[type=password]"),
"after the Keycloak round-trip the UI must not fall back to its own username/password gate",
).toHaveCount(0, { timeout: resolveTimeout(30_000) });
});