merge: AP4 Google federation profile

This commit is contained in:
donghyeon-ka
2026-07-25 16:49:29 +09:00
5 changed files with 143 additions and 1 deletions
+1
View File
@@ -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
@@ -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을 보관할 필요가 없다.
+2 -1
View File
@@ -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"
+99
View File
@@ -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();
}
+15
View File
@@ -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