Skip to content

Commit a4f6695

Browse files
cursoragentlovasoa
andcommitted
Add OIDC SSO tests and CI job
Co-authored-by: contact <[email protected]>
1 parent 3ada9b4 commit a4f6695

File tree

2 files changed

+311
-0
lines changed

2 files changed

+311
-0
lines changed

.github/workflows/ci.yml

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,74 @@ jobs:
162162
if-no-files-found: error
163163
retention-days: 1
164164

165+
oidc_sso_test:
166+
runs-on: ubuntu-latest
167+
needs: compile_and_lint
168+
steps:
169+
- uses: actions/checkout@v4
170+
- name: Download SQLPage binary
171+
uses: actions/download-artifact@v4
172+
with:
173+
name: sqlpage-linux-debug
174+
path: target/debug/
175+
- name: Make binary executable
176+
run: chmod +x target/debug/sqlpage
177+
- name: Set up Node.js
178+
uses: actions/setup-node@v4
179+
with:
180+
node-version: '20'
181+
- name: Install Playwright
182+
working-directory: tests/end-to-end
183+
run: |
184+
npm ci
185+
npx playwright install chromium --with-deps
186+
- name: Build Keycloak image
187+
working-directory: "examples/single sign on"
188+
run: docker build -t keycloak-sso -f keycloak.Dockerfile .
189+
- name: Start Keycloak
190+
run: |
191+
docker run -d --name keycloak \
192+
-e KEYCLOAK_ADMIN=admin \
193+
-e KEYCLOAK_ADMIN_PASSWORD=admin \
194+
-p 8181:8181 \
195+
keycloak-sso
196+
- name: Wait for Keycloak to be ready
197+
run: |
198+
echo "Waiting for Keycloak to start..."
199+
for i in {1..60}; do
200+
if curl -s -f http://localhost:8181/realms/sqlpage_demo/.well-known/openid-configuration > /dev/null 2>&1; then
201+
echo "Keycloak is ready!"
202+
break
203+
fi
204+
echo "Attempt $i: Keycloak not ready yet..."
205+
sleep 5
206+
done
207+
curl -f http://localhost:8181/realms/sqlpage_demo/.well-known/openid-configuration || (docker logs keycloak && exit 1)
208+
- name: Start SQLPage with OIDC config
209+
working-directory: "examples/single sign on"
210+
run: |
211+
../../target/debug/sqlpage &
212+
sleep 3
213+
env:
214+
SQLPAGE_CONFIGURATION_DIRECTORY: ./sqlpage
215+
- name: Verify SQLPage is running
216+
run: |
217+
curl -f http://localhost:8080/ || exit 1
218+
- name: Run OIDC SSO tests
219+
working-directory: tests/end-to-end
220+
run: npx playwright test oidc-sso.spec.ts --reporter=line
221+
env:
222+
SQLPAGE_URL: http://localhost:8080
223+
- name: Show Keycloak logs on failure
224+
if: failure()
225+
run: docker logs keycloak
226+
- name: Upload test results
227+
uses: actions/upload-artifact@v4
228+
if: failure()
229+
with:
230+
name: oidc-test-results
231+
path: tests/end-to-end/test-results/
232+
165233
docker_push:
166234
runs-on: ubuntu-latest
167235
if: github.event_name != 'pull_request'

tests/end-to-end/oidc-sso.spec.ts

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import { expect, type Page, test } from "@playwright/test";
2+
3+
const BASE = process.env.SQLPAGE_URL || "http://localhost:8080";
4+
const TEST_USER = { username: "demo", password: "demo" };
5+
6+
test.describe("OIDC SSO Authentication", () => {
7+
test.beforeEach(async ({ context }) => {
8+
await context.clearCookies();
9+
});
10+
11+
test("public page accessible without authentication", async ({ page }) => {
12+
await page.goto(BASE);
13+
await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
14+
await expect(page.getByText("browsing as a guest")).toBeVisible();
15+
await expect(page.getByRole("link", { name: "Log in" })).toBeVisible();
16+
});
17+
18+
test("protected page redirects to OIDC provider", async ({ page }) => {
19+
const responsePromise = page.waitForResponse(
20+
(response) =>
21+
response.url().includes("/protected") && response.status() === 303,
22+
);
23+
await page.goto(`${BASE}/protected`);
24+
const response = await responsePromise;
25+
expect(response.status()).toBe(303);
26+
await page.waitForURL(/.*\/realms\/sqlpage_demo\/protocol\/openid-connect/);
27+
await expect(page.locator("#username")).toBeVisible();
28+
});
29+
30+
test("full login flow with valid credentials", async ({ page }) => {
31+
await page.goto(`${BASE}/protected`);
32+
await page.waitForURL(/.*\/realms\/sqlpage_demo\/protocol\/openid-connect/);
33+
34+
await page.locator("#username").fill(TEST_USER.username);
35+
await page.locator("#password").fill(TEST_USER.password);
36+
await page.locator("#kc-login").click();
37+
38+
await page.waitForURL(`${BASE}/protected`);
39+
await expect(
40+
page.getByRole("heading", { name: /You're in, Demo User/ }),
41+
).toBeVisible();
42+
await expect(page.getByText("[email protected]")).toBeVisible();
43+
});
44+
45+
test("user info functions return correct claims", async ({ page }) => {
46+
await loginWithKeycloak(page);
47+
await page.goto(`${BASE}/protected`);
48+
await page.waitForURL(`${BASE}/protected`);
49+
50+
await expect(page.getByText("[email protected]")).toBeVisible();
51+
await expect(page.getByTitle("sub")).toBeVisible();
52+
await expect(page.getByTitle("email")).toBeVisible();
53+
await expect(page.getByTitle("name")).toBeVisible();
54+
});
55+
56+
test("logout clears authentication and removes cookie", async ({
57+
page,
58+
context,
59+
}) => {
60+
await loginWithKeycloak(page);
61+
await page.goto(`${BASE}/logout`);
62+
await page.waitForURL(/.*\/realms\/sqlpage_demo\/protocol\/openid-connect/);
63+
64+
const cookies = await context.cookies();
65+
const authCookie = cookies.find((c) => c.name === "sqlpage_auth");
66+
expect(authCookie).toBeUndefined();
67+
await page.goto(BASE);
68+
await expect(page.getByText("browsing as a guest")).toBeVisible();
69+
});
70+
71+
test("authenticated user sees personalized home page", async ({ page }) => {
72+
await loginWithKeycloak(page);
73+
await page.goto(BASE);
74+
75+
await expect(
76+
page.getByRole("heading", { name: /Welcome back, Demo User/ }),
77+
).toBeVisible();
78+
await expect(page.getByText("[email protected]")).toBeVisible();
79+
await expect(page.getByRole("link", { name: "log out" })).toBeVisible();
80+
});
81+
82+
test("protected public path is accessible without auth", async ({ page }) => {
83+
await page.goto(`${BASE}/protected/public/hello.jpeg`);
84+
const response = await page.waitForResponse((r) =>
85+
r.url().includes("/protected/public/hello.jpeg"),
86+
);
87+
expect(response.status()).toBe(200);
88+
expect(response.headers()["content-type"]).toContain("image/jpeg");
89+
});
90+
91+
test("invalid auth cookie is handled gracefully", async ({
92+
page,
93+
context,
94+
}) => {
95+
await context.addCookies([
96+
{
97+
name: "sqlpage_auth",
98+
value: "invalid.jwt.token",
99+
domain: "localhost",
100+
path: "/",
101+
},
102+
]);
103+
await page.goto(`${BASE}/protected`);
104+
await page.waitForURL(/.*\/realms\/sqlpage_demo\/protocol\/openid-connect/);
105+
await expect(page.locator("#username")).toBeVisible();
106+
});
107+
108+
test("expired token triggers re-authentication", async ({
109+
page,
110+
context,
111+
}) => {
112+
const expiredJwt =
113+
"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwiIiA6ICJQIn0." +
114+
"eyJleHAiOjEsImlhdCI6MSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MTgxL3JlYWxtcy9zcWxwYWdlX2RlbW8iLCJhdWQiOiJzcWxwYWdlIiwic3ViIjoiMTIzIn0." +
115+
"signature";
116+
await context.addCookies([
117+
{
118+
name: "sqlpage_auth",
119+
value: expiredJwt,
120+
domain: "localhost",
121+
path: "/",
122+
},
123+
]);
124+
await page.goto(`${BASE}/protected`);
125+
await page.waitForURL(/.*\/realms\/sqlpage_demo\/protocol\/openid-connect/);
126+
await expect(page.locator("#username")).toBeVisible();
127+
});
128+
129+
test("login preserves original target URL", async ({ page }) => {
130+
await page.goto(`${BASE}/protected?foo=bar`);
131+
await page.waitForURL(/.*\/realms\/sqlpage_demo\/protocol\/openid-connect/);
132+
133+
await page.locator("#username").fill(TEST_USER.username);
134+
await page.locator("#password").fill(TEST_USER.password);
135+
await page.locator("#kc-login").click();
136+
137+
await page.waitForURL(/.*\/protected\?foo=bar/);
138+
});
139+
140+
test("failed login stays on Keycloak login page", async ({ page }) => {
141+
await page.goto(`${BASE}/protected`);
142+
await page.waitForURL(/.*\/realms\/sqlpage_demo\/protocol\/openid-connect/);
143+
144+
await page.locator("#username").fill("wrong");
145+
await page.locator("#password").fill("credentials");
146+
await page.locator("#kc-login").click();
147+
148+
await expect(page.getByText(/Invalid username or password/)).toBeVisible();
149+
expect(page.url()).toContain(
150+
"/realms/sqlpage_demo/protocol/openid-connect",
151+
);
152+
});
153+
154+
test("CSRF state cookie is set during login flow", async ({
155+
page,
156+
context,
157+
}) => {
158+
await page.goto(`${BASE}/protected`);
159+
await page.waitForURL(/.*\/realms\/sqlpage_demo\/protocol\/openid-connect/);
160+
161+
const cookies = await context.cookies();
162+
const stateCookie = cookies.find((c) =>
163+
c.name.startsWith("sqlpage_oidc_state_"),
164+
);
165+
expect(stateCookie).toBeDefined();
166+
expect(stateCookie?.httpOnly).toBe(true);
167+
});
168+
169+
test("nonce cookie is set after successful login", async ({
170+
page,
171+
context,
172+
}) => {
173+
await loginWithKeycloak(page);
174+
175+
const cookies = await context.cookies();
176+
const nonceCookie = cookies.find((c) => c.name === "sqlpage_oidc_nonce");
177+
expect(nonceCookie).toBeDefined();
178+
expect(nonceCookie?.httpOnly).toBe(true);
179+
});
180+
181+
test("auth cookie has correct security attributes", async ({
182+
page,
183+
context,
184+
}) => {
185+
await loginWithKeycloak(page);
186+
187+
const cookies = await context.cookies();
188+
const authCookie = cookies.find((c) => c.name === "sqlpage_auth");
189+
expect(authCookie).toBeDefined();
190+
expect(authCookie?.httpOnly).toBe(true);
191+
expect(authCookie?.sameSite).toBe("Lax");
192+
expect(authCookie?.path).toBe("/");
193+
});
194+
195+
test("multiple protected pages work with single login", async ({ page }) => {
196+
await loginWithKeycloak(page);
197+
198+
await page.goto(`${BASE}/protected`);
199+
await expect(page.getByText("[email protected]")).toBeVisible();
200+
201+
await page.goto(BASE);
202+
await expect(
203+
page.getByRole("heading", { name: /Welcome back/ }),
204+
).toBeVisible();
205+
206+
await page.goto(`${BASE}/protected`);
207+
await expect(page.getByText("[email protected]")).toBeVisible();
208+
});
209+
210+
test("callback endpoint returns error for missing state", async ({
211+
page,
212+
}) => {
213+
const response = await page.goto(`${BASE}/sqlpage/oidc_callback?code=test`);
214+
expect(response?.status()).toBeGreaterThanOrEqual(300);
215+
});
216+
217+
test("callback endpoint with invalid code redirects to OIDC", async ({
218+
page,
219+
context,
220+
}) => {
221+
await context.addCookies([
222+
{
223+
name: "sqlpage_oidc_state_test_state",
224+
value: JSON.stringify({ n: "test_nonce", r: "/" }),
225+
domain: "localhost",
226+
path: "/",
227+
},
228+
]);
229+
await page.goto(
230+
`${BASE}/sqlpage/oidc_callback?code=invalid&state=test_state`,
231+
);
232+
await page.waitForURL(/.*\/realms\/sqlpage_demo\/protocol\/openid-connect/);
233+
});
234+
});
235+
236+
async function loginWithKeycloak(page: Page) {
237+
await page.goto(`${BASE}/protected`);
238+
await page.waitForURL(/.*\/realms\/sqlpage_demo\/protocol\/openid-connect/);
239+
await page.locator("#username").fill(TEST_USER.username);
240+
await page.locator("#password").fill(TEST_USER.password);
241+
await page.locator("#kc-login").click();
242+
await page.waitForURL(`${BASE}/protected`);
243+
}

0 commit comments

Comments
 (0)