Skip to content
Draft
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
68 changes: 68 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
243 changes: 243 additions & 0 deletions tests/end-to-end/oidc-sso.spec.ts
Original file line number Diff line number Diff line change
@@ -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("[email protected]")).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("[email protected]")).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("[email protected]")).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("[email protected]")).toBeVisible();

await page.goto(BASE);
await expect(
page.getByRole("heading", { name: /Welcome back/ }),
).toBeVisible();

await page.goto(`${BASE}/protected`);
await expect(page.getByText("[email protected]")).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`);
}
Loading