Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4aea65dc6 | ||
|
|
4f93a5c92a | ||
|
|
7c72abdfed | ||
|
|
48bdf0b5ff | ||
|
|
e03ec4eaed |
@@ -0,0 +1,35 @@
|
||||
http:
|
||||
routers:
|
||||
oauth:
|
||||
entryPoints:
|
||||
- web
|
||||
rule: PathPrefix(`/oauth2/`)
|
||||
service: oauth2-proxy
|
||||
priority: 100
|
||||
application:
|
||||
entryPoints:
|
||||
- web
|
||||
rule: PathPrefix(`/`)
|
||||
middlewares:
|
||||
- keycloak-forward-auth
|
||||
service: application
|
||||
|
||||
middlewares:
|
||||
keycloak-forward-auth:
|
||||
forwardAuth:
|
||||
address: http://oauth2-proxy:4180/oauth2/auth
|
||||
trustForwardHeader: false
|
||||
authResponseHeaders:
|
||||
- X-Auth-Request-User
|
||||
- X-Auth-Request-Email
|
||||
- Set-Cookie
|
||||
|
||||
services:
|
||||
oauth2-proxy:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: http://oauth2-proxy:4180
|
||||
application:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: http://app:8081
|
||||
@@ -0,0 +1,14 @@
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":8080"
|
||||
|
||||
providers:
|
||||
file:
|
||||
filename: /etc/traefik/dynamic.yml
|
||||
watch: false
|
||||
|
||||
api:
|
||||
dashboard: false
|
||||
|
||||
log:
|
||||
level: INFO
|
||||
@@ -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을 보관할 필요가 없다.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Traefik ForwardAuth alternative
|
||||
|
||||
Traefik의 `forwardAuth` middleware는 nginx `auth_request`와 같은 정책 지점을
|
||||
제공한다. 예제는 `/oauth2/auth`를 oauth2-proxy에 위임하고 성공 응답의
|
||||
허용된 identity headers만 application request로 복사한다.
|
||||
|
||||
중요한 차이:
|
||||
|
||||
- ForwardAuth 자체는 OIDC client나 session manager가 아니다. 이 예제에서도
|
||||
oauth2-proxy가 code 교환과 cookie를 담당한다.
|
||||
- `trustForwardHeader=false`로 외부 forwarded header를 신뢰하지 않는다.
|
||||
- `/oauth2/` router는 callback/start 경로를 oauth2-proxy에 연결해야 한다.
|
||||
- nginx의 `error_page 401 -> /oauth2/start`와 같은 로그인 redirect UX는
|
||||
Traefik errors middleware 또는 oauth2-proxy의 forward-auth redirect
|
||||
profile을 추가로 설계해야 한다.
|
||||
- Docker socket label discovery 대신 file provider를 사용해 socket 노출을
|
||||
피했다. Kubernetes에서는 Middleware/IngressRoute CRD라는 vendor-specific
|
||||
운영 객체가 추가된다.
|
||||
|
||||
이 repository의 실제 AP4 baseline은 학습 가시성이 높은 nginx 조합을
|
||||
유지한다. `verify-traefik-forwardauth-config.sh`는 대안 파일을 Traefik
|
||||
binary로 로드하고 즉시 발생하는 provider/config 오류가 없는지 확인한다.
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Executable
+15
@@ -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
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
config_dir="$PWD/deploy/traefik"
|
||||
output_file="$(mktemp)"
|
||||
cleanup() {
|
||||
rm -f "$output_file"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
status=0
|
||||
timeout 4 docker run --rm \
|
||||
-v "$config_dir:/etc/traefik:ro" \
|
||||
traefik:v3.5.3 \
|
||||
--configFile=/etc/traefik/traefik.yml >"$output_file" 2>&1 || status=$?
|
||||
|
||||
if [ "$status" -ne 0 ] && [ "$status" -ne 124 ]; then
|
||||
cat "$output_file" >&2
|
||||
exit "$status"
|
||||
fi
|
||||
if rg -qi 'error|failed' "$output_file"; then
|
||||
cat "$output_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
grep -q 'trustForwardHeader: false' deploy/traefik/dynamic.yml
|
||||
grep -q 'X-Auth-Request-User' deploy/traefik/dynamic.yml
|
||||
echo "Traefik file provider and ForwardAuth configuration verified"
|
||||
Reference in New Issue
Block a user