diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c11db3f..51b6f513 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,6 +162,74 @@ jobs: if-no-files-found: error retention-days: 1 + oidc_sso_test: + runs-on: ubuntu-latest + needs: compile_and_lint + steps: + - uses: actions/checkout@v4 + - name: Download SQLPage binary + uses: actions/download-artifact@v4 + with: + name: sqlpage-linux-debug + path: target/debug/ + - name: Make binary executable + run: chmod +x target/debug/sqlpage + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install Playwright + working-directory: tests/end-to-end + run: | + npm ci + npx playwright install chromium --with-deps + - name: Build Keycloak image + working-directory: "examples/single sign on" + run: docker build -t keycloak-sso -f keycloak.Dockerfile . + - name: Start Keycloak + run: | + docker run -d --name keycloak \ + -e KEYCLOAK_ADMIN=admin \ + -e KEYCLOAK_ADMIN_PASSWORD=admin \ + -p 8181:8181 \ + keycloak-sso + - name: Wait for Keycloak to be ready + run: | + echo "Waiting for Keycloak to start..." + for i in {1..60}; do + if curl -s -f http://localhost:8181/realms/sqlpage_demo/.well-known/openid-configuration > /dev/null 2>&1; then + echo "Keycloak is ready!" + break + fi + echo "Attempt $i: Keycloak not ready yet..." + sleep 5 + done + curl -f http://localhost:8181/realms/sqlpage_demo/.well-known/openid-configuration || (docker logs keycloak && exit 1) + - name: Start SQLPage with OIDC config + working-directory: "examples/single sign on" + run: | + ../../target/debug/sqlpage & + sleep 3 + env: + SQLPAGE_CONFIGURATION_DIRECTORY: ./sqlpage + - name: Verify SQLPage is running + run: | + curl -f http://localhost:8080/ || exit 1 + - name: Run OIDC SSO tests + working-directory: tests/end-to-end + run: npx playwright test oidc-sso.spec.ts --reporter=line + env: + SQLPAGE_URL: http://localhost:8080 + - name: Show Keycloak logs on failure + if: failure() + run: docker logs keycloak + - name: Upload test results + uses: actions/upload-artifact@v4 + if: failure() + with: + name: oidc-test-results + path: tests/end-to-end/test-results/ + docker_push: runs-on: ubuntu-latest if: github.event_name != 'pull_request' diff --git a/tests/end-to-end/oidc-sso.spec.ts b/tests/end-to-end/oidc-sso.spec.ts new file mode 100644 index 00000000..95996f47 --- /dev/null +++ b/tests/end-to-end/oidc-sso.spec.ts @@ -0,0 +1,243 @@ +import { expect, type Page, test } from "@playwright/test"; + +const BASE = process.env.SQLPAGE_URL || "http://localhost:8080"; +const TEST_USER = { username: "demo", password: "demo" }; + +test.describe("OIDC SSO Authentication", () => { + test.beforeEach(async ({ context }) => { + await context.clearCookies(); + }); + + test("public page accessible without authentication", async ({ page }) => { + await page.goto(BASE); + await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible(); + await expect(page.getByText("browsing as a guest")).toBeVisible(); + await expect(page.getByRole("link", { name: "Log in" })).toBeVisible(); + }); + + test("protected page redirects to OIDC provider", async ({ page }) => { + const responsePromise = page.waitForResponse( + (response) => + response.url().includes("/protected") && response.status() === 303, + ); + await page.goto(`${BASE}/protected`); + const response = await responsePromise; + expect(response.status()).toBe(303); + await page.waitForURL(/.*\/realms\/sqlpage_demo\/protocol\/openid-connect/); + await expect(page.locator("#username")).toBeVisible(); + }); + + test("full login flow with valid credentials", async ({ page }) => { + await page.goto(`${BASE}/protected`); + await page.waitForURL(/.*\/realms\/sqlpage_demo\/protocol\/openid-connect/); + + await page.locator("#username").fill(TEST_USER.username); + await page.locator("#password").fill(TEST_USER.password); + await page.locator("#kc-login").click(); + + await page.waitForURL(`${BASE}/protected`); + await expect( + page.getByRole("heading", { name: /You're in, Demo User/ }), + ).toBeVisible(); + await expect(page.getByText("demo@example.com")).toBeVisible(); + }); + + test("user info functions return correct claims", async ({ page }) => { + await loginWithKeycloak(page); + await page.goto(`${BASE}/protected`); + await page.waitForURL(`${BASE}/protected`); + + await expect(page.getByText("demo@example.com")).toBeVisible(); + await expect(page.getByTitle("sub")).toBeVisible(); + await expect(page.getByTitle("email")).toBeVisible(); + await expect(page.getByTitle("name")).toBeVisible(); + }); + + test("logout clears authentication and removes cookie", async ({ + page, + context, + }) => { + await loginWithKeycloak(page); + await page.goto(`${BASE}/logout`); + await page.waitForURL(/.*\/realms\/sqlpage_demo\/protocol\/openid-connect/); + + const cookies = await context.cookies(); + const authCookie = cookies.find((c) => c.name === "sqlpage_auth"); + expect(authCookie).toBeUndefined(); + await page.goto(BASE); + await expect(page.getByText("browsing as a guest")).toBeVisible(); + }); + + test("authenticated user sees personalized home page", async ({ page }) => { + await loginWithKeycloak(page); + await page.goto(BASE); + + await expect( + page.getByRole("heading", { name: /Welcome back, Demo User/ }), + ).toBeVisible(); + await expect(page.getByText("demo@example.com")).toBeVisible(); + await expect(page.getByRole("link", { name: "log out" })).toBeVisible(); + }); + + test("protected public path is accessible without auth", async ({ page }) => { + await page.goto(`${BASE}/protected/public/hello.jpeg`); + const response = await page.waitForResponse((r) => + r.url().includes("/protected/public/hello.jpeg"), + ); + expect(response.status()).toBe(200); + expect(response.headers()["content-type"]).toContain("image/jpeg"); + }); + + test("invalid auth cookie is handled gracefully", async ({ + page, + context, + }) => { + await context.addCookies([ + { + name: "sqlpage_auth", + value: "invalid.jwt.token", + domain: "localhost", + path: "/", + }, + ]); + await page.goto(`${BASE}/protected`); + await page.waitForURL(/.*\/realms\/sqlpage_demo\/protocol\/openid-connect/); + await expect(page.locator("#username")).toBeVisible(); + }); + + test("expired token triggers re-authentication", async ({ + page, + context, + }) => { + const expiredJwt = + "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwiIiA6ICJQIn0." + + "eyJleHAiOjEsImlhdCI6MSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MTgxL3JlYWxtcy9zcWxwYWdlX2RlbW8iLCJhdWQiOiJzcWxwYWdlIiwic3ViIjoiMTIzIn0." + + "signature"; + await context.addCookies([ + { + name: "sqlpage_auth", + value: expiredJwt, + domain: "localhost", + path: "/", + }, + ]); + await page.goto(`${BASE}/protected`); + await page.waitForURL(/.*\/realms\/sqlpage_demo\/protocol\/openid-connect/); + await expect(page.locator("#username")).toBeVisible(); + }); + + test("login preserves original target URL", async ({ page }) => { + await page.goto(`${BASE}/protected?foo=bar`); + await page.waitForURL(/.*\/realms\/sqlpage_demo\/protocol\/openid-connect/); + + await page.locator("#username").fill(TEST_USER.username); + await page.locator("#password").fill(TEST_USER.password); + await page.locator("#kc-login").click(); + + await page.waitForURL(/.*\/protected\?foo=bar/); + }); + + test("failed login stays on Keycloak login page", async ({ page }) => { + await page.goto(`${BASE}/protected`); + await page.waitForURL(/.*\/realms\/sqlpage_demo\/protocol\/openid-connect/); + + await page.locator("#username").fill("wrong"); + await page.locator("#password").fill("credentials"); + await page.locator("#kc-login").click(); + + await expect(page.getByText(/Invalid username or password/)).toBeVisible(); + expect(page.url()).toContain( + "/realms/sqlpage_demo/protocol/openid-connect", + ); + }); + + test("CSRF state cookie is set during login flow", async ({ + page, + context, + }) => { + await page.goto(`${BASE}/protected`); + await page.waitForURL(/.*\/realms\/sqlpage_demo\/protocol\/openid-connect/); + + const cookies = await context.cookies(); + const stateCookie = cookies.find((c) => + c.name.startsWith("sqlpage_oidc_state_"), + ); + expect(stateCookie).toBeDefined(); + expect(stateCookie?.httpOnly).toBe(true); + }); + + test("nonce cookie is set after successful login", async ({ + page, + context, + }) => { + await loginWithKeycloak(page); + + const cookies = await context.cookies(); + const nonceCookie = cookies.find((c) => c.name === "sqlpage_oidc_nonce"); + expect(nonceCookie).toBeDefined(); + expect(nonceCookie?.httpOnly).toBe(true); + }); + + test("auth cookie has correct security attributes", async ({ + page, + context, + }) => { + await loginWithKeycloak(page); + + const cookies = await context.cookies(); + const authCookie = cookies.find((c) => c.name === "sqlpage_auth"); + expect(authCookie).toBeDefined(); + expect(authCookie?.httpOnly).toBe(true); + expect(authCookie?.sameSite).toBe("Lax"); + expect(authCookie?.path).toBe("/"); + }); + + test("multiple protected pages work with single login", async ({ page }) => { + await loginWithKeycloak(page); + + await page.goto(`${BASE}/protected`); + await expect(page.getByText("demo@example.com")).toBeVisible(); + + await page.goto(BASE); + await expect( + page.getByRole("heading", { name: /Welcome back/ }), + ).toBeVisible(); + + await page.goto(`${BASE}/protected`); + await expect(page.getByText("demo@example.com")).toBeVisible(); + }); + + test("callback endpoint returns error for missing state", async ({ + page, + }) => { + const response = await page.goto(`${BASE}/sqlpage/oidc_callback?code=test`); + expect(response?.status()).toBeGreaterThanOrEqual(300); + }); + + test("callback endpoint with invalid code redirects to OIDC", async ({ + page, + context, + }) => { + await context.addCookies([ + { + name: "sqlpage_oidc_state_test_state", + value: JSON.stringify({ n: "test_nonce", r: "/" }), + domain: "localhost", + path: "/", + }, + ]); + await page.goto( + `${BASE}/sqlpage/oidc_callback?code=invalid&state=test_state`, + ); + await page.waitForURL(/.*\/realms\/sqlpage_demo\/protocol\/openid-connect/); + }); +}); + +async function loginWithKeycloak(page: Page) { + await page.goto(`${BASE}/protected`); + await page.waitForURL(/.*\/realms\/sqlpage_demo\/protocol\/openid-connect/); + await page.locator("#username").fill(TEST_USER.username); + await page.locator("#password").fill(TEST_USER.password); + await page.locator("#kc-login").click(); + await page.waitForURL(`${BASE}/protected`); +}