From 48bdf0b5ff33abb46a52d71457b40f679ae29c3b Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 16:49:29 +0900 Subject: [PATCH] feat: verify Google federation through the AP4 edge --- docker-compose.yml | 1 + docs/edge-forwardauth-google-federation.md | 26 ++++++ e2e/package.json | 3 +- e2e/pattern4-google.mjs | 99 ++++++++++++++++++++++ scripts/verify-edge-google-federation.sh | 15 ++++ 5 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 docs/edge-forwardauth-google-federation.md create mode 100644 e2e/pattern4-google.mjs create mode 100755 scripts/verify-edge-google-federation.sh diff --git a/docker-compose.yml b/docker-compose.yml index 90b4f53..ee4d7e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -112,6 +112,7 @@ services: - --cookie-secure=false - --cookie-samesite=lax - --cookie-expire=1h + - --session-cookie-minimal=true - --skip-provider-button=true - --set-xauthrequest=true - --pass-user-headers=true diff --git a/docs/edge-forwardauth-google-federation.md b/docs/edge-forwardauth-google-federation.md new file mode 100644 index 0000000..c30ba74 --- /dev/null +++ b/docs/edge-forwardauth-google-federation.md @@ -0,0 +1,26 @@ +# AP4 edge forward-auth with Google federation + +Google federation은 AP4의 edge contract를 바꾸지 않는다. + +```text +Browser -> nginx -> oauth2-proxy -> Keycloak -> Google +Browser <- AP4_SESSION <- oauth2-proxy <- Keycloak +nginx -> trusted identity headers -> upstream app +``` + +oauth2-proxy가 신뢰하는 issuer는 Google이 아니라 Keycloak이다. Google ID +token은 Keycloak broker 경계 안에서 검증되고, oauth2-proxy는 Keycloak +authorization code/token과 session cookie만 다룬다. upstream 앱도 +broker 여부와 무관하게 동일한 trusted headers를 받는다. + +`verify-edge-google-federation.sh`는 mock Google 로그인, confidential +server-side token 교환(브라우저에 token 요청 없음), 미검증 broker email 거부, +Keycloak email verification 완료 후 HttpOnly edge cookie와 brokered +subject/email header를 실제 컨테이너와 브라우저로 검증한다. minimal session의 +`X-Auth-Request-User`는 표시용 username이 아니라 Keycloak의 안정적인 local +subject UUID이며, 화면 이름이 필요하면 별도 허용 header를 명시한다. + +brokered token/claims가 client-side session cookie의 4KB 한계를 넘지 않도록 +oauth2-proxy에는 `session-cookie-minimal=true`를 적용한다. AP4 upstream은 +token forwarding이 아니라 trusted identity headers만 사용하므로 cookie에 +access/refresh/ID token을 보관할 필요가 없다. diff --git a/e2e/package.json b/e2e/package.json index 4a4c1b4..53efd1f 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -4,7 +4,8 @@ "version": "1.0.0", "type": "module", "scripts": { - "test:pattern4": "node pattern4.mjs" + "test:pattern4": "node pattern4.mjs", + "test:pattern4-google": "node pattern4-google.mjs" }, "devDependencies": { "playwright-core": "1.62.0" diff --git a/e2e/pattern4-google.mjs b/e2e/pattern4-google.mjs new file mode 100644 index 0000000..c0ad9a6 --- /dev/null +++ b/e2e/pattern4-google.mjs @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import { chromium } from "playwright-core"; + +const password = process.env.MOCK_GOOGLE_USER_PASSWORD; +const adminUsername = process.env.KC_BOOTSTRAP_ADMIN_USERNAME; +const adminPassword = process.env.KC_BOOTSTRAP_ADMIN_PASSWORD; +assert.ok(password && adminUsername && adminPassword); + +async function verifyBrokeredEmail() { + const tokenResponse = await fetch( + "http://localhost:8080/realms/master/protocol/openid-connect/token", + { + method: "POST", + body: new URLSearchParams({ + client_id: "admin-cli", + grant_type: "password", + username: adminUsername, + password: adminPassword, + }), + }, + ); + assert.equal(tokenResponse.status, 200); + const token = (await tokenResponse.json()).access_token; + const headers = { Authorization: `Bearer ${token}` }; + const usersResponse = await fetch( + "http://localhost:8080/admin/realms/keycloak-patterns/users" + + "?email=broker-new-user%40example.test&exact=true", + { headers }, + ); + assert.equal(usersResponse.status, 200); + const users = await usersResponse.json(); + assert.equal(users.length, 1); + const userResponse = await fetch( + `http://localhost:8080/admin/realms/keycloak-patterns/users/${users[0].id}`, + { headers }, + ); + const user = await userResponse.json(); + assert.equal(user.emailVerified, false); + const updateResponse = await fetch( + `http://localhost:8080/admin/realms/keycloak-patterns/users/${user.id}`, + { + method: "PUT", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ ...user, emailVerified: true }), + }, + ); + assert.equal(updateResponse.status, 204); +} + +const browser = await chromium.launch({ + executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome", + headless: true, + args: ["--no-sandbox"], +}); + +try { + const context = await browser.newContext(); + const page = await context.newPage(); + const browserRequests = []; + page.on("request", (request) => browserRequests.push(request.url())); + + await page.goto("http://localhost:8088/"); + await page.waitForURL(/localhost:8080/u); + await page.locator('a[href*="/broker/mock-google/login"]').click(); + await page.waitForURL(/\/realms\/mock-google\//u); + await page.locator("#username").fill("mock-new-user"); + await page.locator("#password").fill(password); + await page.locator("#kc-login").click(); + await page.waitForURL(/\/oauth2\/callback/u); + assert.match(await page.locator("body").innerText(), /Internal Server Error/u); + + await verifyBrokeredEmail(); + await page.goto( + "http://localhost:8088/oauth2/start" + + "?rd=http%3A%2F%2Flocalhost%3A8088%2F", + ); + await page.waitForURL("http://localhost:8088/"); + + const identity = JSON.parse(await page.locator("body").innerText()); + assert.equal(identity.pattern, "AP4-edge-forward-auth"); + assert.match(identity.user, /^[0-9a-f-]{36}$/u); + assert.equal(identity.email, "broker-new-user@example.test"); + assert.equal( + browserRequests.some((url) => + url.includes("/protocol/openid-connect/token"), + ), + false, + ); + + const session = (await context.cookies()).find( + (cookie) => cookie.name === "AP4_SESSION", + ); + assert.ok(session?.httpOnly); + console.log( + "AP4 Google federation verified: unverified email rejected, verified identity -> edge session -> trusted headers", + ); +} finally { + await browser.close(); +} diff --git a/scripts/verify-edge-google-federation.sh b/scripts/verify-edge-google-federation.sh new file mode 100755 index 0000000..dfc2a47 --- /dev/null +++ b/scripts/verify-edge-google-federation.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env sh +set -eu + +set -a +. ./.env +set +a + +docker compose down --volumes --remove-orphans +docker compose up --build -d --wait +./scripts/set-first-broker-login-mode.sh secure +npm --prefix e2e ci +MOCK_GOOGLE_USER_PASSWORD="$MOCK_GOOGLE_USER_PASSWORD" \ +KC_BOOTSTRAP_ADMIN_USERNAME="$KC_BOOTSTRAP_ADMIN_USERNAME" \ +KC_BOOTSTRAP_ADMIN_PASSWORD="$KC_BOOTSTRAP_ADMIN_PASSWORD" \ + npm --prefix e2e run test:pattern4-google