From 4ac01335868d269182dd23cb0f82ed0e168669c1 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 14:50:00 +0900 Subject: [PATCH] feat(ap4): add oauth2-proxy OIDC flow --- .env.example | 1 + .gitignore | 2 + README.md | 16 +++ .../EdgeIdentityController.java | 47 ++++++++ .../keycloakpattern/SecurityConfig.java | 7 +- .../keycloakpattern/ApiSecurityTest.java | 16 +++ docker-compose.yml | 50 ++++++++ docs/ap4-edge-forward-auth.md | 31 +++++ e2e/package-lock.json | 28 +++++ e2e/package.json | 12 ++ e2e/pattern4.mjs | 111 ++++++++++++++++++ keycloak/import/keycloak-patterns-realm.json | 1 + scripts/verify-pattern4.sh | 21 ++++ 13 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/com/example/keycloakpattern/EdgeIdentityController.java create mode 100644 docs/ap4-edge-forward-auth.md create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/pattern4.mjs create mode 100755 scripts/verify-pattern4.sh diff --git a/.env.example b/.env.example index 1bc37f1..41b85fc 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ POSTGRES_PASSWORD=change-me-postgres-password TOKEN_MEDIATING_CLIENT_SECRET=change-me-token-mediating-client-secret BFF_CLIENT_SECRET=change-me-bff-client-secret EDGE_PROXY_CLIENT_SECRET=change-me-edge-proxy-client-secret +OAUTH2_PROXY_COOKIE_SECRET=generate-a-base64-encoded-32-byte-secret ADMIN_USER_PASSWORD=change-me-admin-user-password REGULAR_USER_PASSWORD=change-me-regular-user-password diff --git a/.gitignore b/.gitignore index 8f0f1cc..215efbe 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ *.iml backend/target/ +**/node_modules/ +frontend/dist/ build/ diff --git a/README.md b/README.md index ac1a49e..cf91526 100644 --- a/README.md +++ b/README.md @@ -96,3 +96,19 @@ Keycloak을 잠시 중지하고 export한 뒤 자동으로 다시 올립니다. runtime export에는 실제 client secret과 credential hash가 포함될 수 있어 gitignored `build/keycloak-export/`에 권한 `0600`으로만 저장됩니다. + +## AP4: oauth2-proxy Edge Forward Auth + +`develop-keycloak-pattern4`는 oauth2-proxy와 Nginx `auth_request`가 +인증을 edge에서 강제하는 패턴입니다. + +```bash +./scripts/verify-pattern4.sh +``` + +첫 feature에서는 oauth2-proxy를 `http://localhost:4180`에 직접 노출해 +OIDC redirect/PKCE/callback과 forwarded-user를 분리 확인합니다. 최종 +구성은 `http://localhost:8088` Nginx를 단일 진입점으로 사용합니다. +자세한 내용은 +[`docs/ap4-edge-forward-auth.md`](docs/ap4-edge-forward-auth.md)를 +참고하세요. diff --git a/backend/src/main/java/com/example/keycloakpattern/EdgeIdentityController.java b/backend/src/main/java/com/example/keycloakpattern/EdgeIdentityController.java new file mode 100644 index 0000000..733596a --- /dev/null +++ b/backend/src/main/java/com/example/keycloakpattern/EdgeIdentityController.java @@ -0,0 +1,47 @@ +package com.example.keycloakpattern; + +import java.util.LinkedHashMap; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class EdgeIdentityController { + + @GetMapping("/edge/me") + ResponseEntity> currentUser(HttpServletRequest request) { + String authRequestUser = request.getHeader("X-Auth-Request-User"); + String forwardedUser = request.getHeader("X-Forwarded-User"); + String user = hasText(authRequestUser) ? authRequestUser : forwardedUser; + if (!hasText(user)) { + return ResponseEntity.status(401).body(Map.of( + "error", + "trusted edge identity header is required" + )); + } + + Map response = new LinkedHashMap<>(); + response.put("pattern", "AP4-edge-forward-auth"); + response.put("user", user); + response.put("email", firstNonBlank( + request.getHeader("X-Auth-Request-Email"), + request.getHeader("X-Forwarded-Email") + )); + response.put("identityHeader", hasText(authRequestUser) + ? "X-Auth-Request-User" + : "X-Forwarded-User"); + return ResponseEntity.ok(response); + } + + private static String firstNonBlank(String first, String second) { + return hasText(first) ? first : second; + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java b/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java index d4c2129..afabd50 100644 --- a/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java +++ b/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java @@ -17,7 +17,12 @@ public class SecurityConfig { .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/actuator/health", "/actuator/health/**", "/api/public") + .requestMatchers( + "/actuator/health", + "/actuator/health/**", + "/api/public", + "/edge/**" + ) .permitAll() .anyRequest() .authenticated()) diff --git a/backend/src/test/java/com/example/keycloakpattern/ApiSecurityTest.java b/backend/src/test/java/com/example/keycloakpattern/ApiSecurityTest.java index 224287b..2df132b 100644 --- a/backend/src/test/java/com/example/keycloakpattern/ApiSecurityTest.java +++ b/backend/src/test/java/com/example/keycloakpattern/ApiSecurityTest.java @@ -40,4 +40,20 @@ class ApiSecurityTest { .andExpect(jsonPath("$.subject").value("test-subject")) .andExpect(jsonPath("$.username").value("regular-user")); } + + @Test + void edgeEndpointRejectsMissingIdentityHeader() throws Exception { + mockMvc.perform(get("/edge/me")) + .andExpect(status().isUnauthorized()); + } + + @Test + void edgeEndpointCurrentlyTrustsForwardedUserHeader() throws Exception { + mockMvc.perform(get("/edge/me") + .header("X-Forwarded-User", "regular-user") + .header("X-Forwarded-Email", "regular-user@example.test")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user").value("regular-user")) + .andExpect(jsonPath("$.identityHeader").value("X-Forwarded-User")); + } } diff --git a/docker-compose.yml b/docker-compose.yml index 585bebf..edfe0c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,6 +86,56 @@ services: - keycloak-net restart: unless-stopped + oauth2-proxy: + image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.2 + command: + - --http-address=0.0.0.0:4180 + - --provider=keycloak-oidc + - --oidc-issuer-url=http://localhost:8080/realms/keycloak-patterns + - --skip-oidc-discovery=true + - --login-url=http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/auth + - --redeem-url=http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/token + - --oidc-jwks-url=http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/certs + - --profile-url=http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/userinfo + - --validate-url=http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/userinfo + - --redirect-url=http://localhost:4180/oauth2/callback + - --upstream=http://app:8081 + - --email-domain=* + - --scope=openid profile email + - --code-challenge-method=S256 + - --cookie-name=AP4_SESSION + - --cookie-secure=false + - --cookie-samesite=lax + - --cookie-expire=1h + - --skip-provider-button=true + - --set-xauthrequest=true + - --pass-user-headers=true + - --whitelist-domain=localhost:4180 + - --whitelist-domain=localhost:8080 + environment: + OAUTH2_PROXY_CLIENT_ID: edge-proxy + OAUTH2_PROXY_CLIENT_SECRET: ${EDGE_PROXY_CLIENT_SECRET:?set EDGE_PROXY_CLIENT_SECRET in .env} + OAUTH2_PROXY_COOKIE_SECRET: ${OAUTH2_PROXY_COOKIE_SECRET:?set OAUTH2_PROXY_COOKIE_SECRET in .env} + ports: + - "127.0.0.1:4180:4180" + depends_on: + keycloak: + condition: service_healthy + app: + condition: service_healthy + healthcheck: + test: + - CMD + - /bin/oauth2-proxy + - --version + interval: 10s + timeout: 5s + retries: 3 + start_period: 5s + networks: + - keycloak-net + restart: unless-stopped + nginx: build: context: ./frontend diff --git a/docs/ap4-edge-forward-auth.md b/docs/ap4-edge-forward-auth.md new file mode 100644 index 0000000..4d34ab0 --- /dev/null +++ b/docs/ap4-edge-forward-auth.md @@ -0,0 +1,31 @@ +# AP4 · oauth2-proxy Edge Forward Auth + +## 첫 단계: oauth2-proxy 자체 OIDC 흐름 + +`feature/keycloak-oauth2-proxy-oidc-flow`에서는 oauth2-proxy를 +`http://localhost:4180`에 직접 노출해 구성 요소를 분리해서 확인합니다. + +1. `/edge/me` 미인증 요청이 Keycloak로 redirect됩니다. +2. oauth2-proxy는 confidential `edge-proxy` client와 PKCE S256을 사용합니다. +3. callback에서 code/token 교환과 ID/access token 검증은 서버끼리 + 수행합니다. +4. 브라우저에는 HttpOnly `AP4_SESSION` cookie만 남습니다. +5. oauth2-proxy가 backend 요청에 `X-Forwarded-User`를 붙여 200을 받습니다. + +Keycloak이 발급하는 issuer는 브라우저 기준 +`http://localhost:8080/realms/keycloak-patterns`입니다. 컨테이너 내부의 +`localhost`는 oauth2-proxy 자신이므로 discovery endpoint에 도달할 수 +없습니다. 그래서 이 로컬 Compose 구성은 issuer 검증값은 외부 URL로 +유지하되, login URL은 브라우저용 외부 주소, token/JWKS/userinfo는 +`http://keycloak:8080` 내부 주소로 각각 명시합니다. + +HTTP 로컬 시연이라 `cookie-secure=false`를 사용합니다. 운영 HTTPS에서는 +반드시 secure cookie로 되돌려야 합니다. + +## 다음 단계의 보안 전제 + +이 첫 feature의 backend는 전달된 사용자 헤더를 신뢰하며 8081도 +loopback에 publish되어 있습니다. 따라서 로컬에서 직접 +`X-Forwarded-User: spoofed-admin`을 보내면 우회가 재현됩니다. 이후 +Nginx `auth_request` 통합을 거쳐 최종 feature에서 backend no-publish와 +내부 shared-secret 검증을 함께 적용합니다. diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..78d42d6 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,28 @@ +{ + "name": "keycloak-pattern-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "keycloak-pattern-e2e", + "version": "1.0.0", + "devDependencies": { + "playwright-core": "1.62.0" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..4a4c1b4 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,12 @@ +{ + "name": "keycloak-pattern-e2e", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "test:pattern4": "node pattern4.mjs" + }, + "devDependencies": { + "playwright-core": "1.62.0" + } +} diff --git a/e2e/pattern4.mjs b/e2e/pattern4.mjs new file mode 100644 index 0000000..0f314ef --- /dev/null +++ b/e2e/pattern4.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import { chromium } from "playwright-core"; + +const password = process.env.E2E_PASSWORD; +assert.ok(password, "E2E_PASSWORD must be set"); + +async function completeKeycloakLogin(page) { + for (let attempt = 1; attempt <= 2; attempt += 1) { + await page.locator("#username").fill( + process.env.E2E_USERNAME ?? "regular-user", + ); + await page.locator("#password").fill(password); + await page.locator("#kc-login").click(); + await page.waitForLoadState("domcontentloaded"); + + if (page.url() === "http://localhost:4180/edge/me") { + return; + } + if (attempt === 1) { + await page.goto("http://localhost:4180/oauth2/start?rd=%2Fedge%2Fme"); + await page.waitForURL(/localhost:8080/u); + } + } + throw new Error(`Keycloak login did not return to AP4: ${page.url()}`); +} + +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({ method: request.method(), url: request.url() }), + ); + + const edgeResponsePromise = page.waitForResponse( + (response) => + response.url() === "http://localhost:4180/edge/me" && + response.status() === 302, + ); + const authorizationRequestPromise = page.waitForRequest((request) => + request.url().includes( + "/protocol/openid-connect/auth?approval_prompt=", + ), + ); + await page.goto("http://localhost:4180/edge/me"); + const unauthenticatedEdgeResponse = await edgeResponsePromise; + assert.equal(unauthenticatedEdgeResponse.status(), 302); + + const authorizationRequest = await authorizationRequestPromise; + const authorizationUrl = new URL(authorizationRequest.url()); + assert.equal(authorizationUrl.searchParams.get("client_id"), "edge-proxy"); + assert.equal(authorizationUrl.searchParams.get("code_challenge_method"), "S256"); + assert.ok(authorizationUrl.searchParams.get("code_challenge")); + + await page.waitForURL(/localhost:8080/u); + await completeKeycloakLogin(page); + const edgeIdentity = JSON.parse(await page.locator("body").innerText()); + assert.equal(edgeIdentity.pattern, "AP4-edge-forward-auth"); + assert.ok(edgeIdentity.user); + assert.equal(edgeIdentity.identityHeader, "X-Forwarded-User"); + + const callbackRequest = browserRequests.find(({ url }) => + url.startsWith("http://localhost:4180/oauth2/callback?"), + ); + assert.ok(callbackRequest); + assert.equal(callbackRequest.method, "GET"); + assert.equal( + browserRequests.some(({ url }) => + url.includes("/protocol/openid-connect/token"), + ), + false, + "the confidential token exchange must be server-to-server", + ); + + const cookies = await context.cookies("http://localhost:4180/"); + const sessionCookie = cookies.find((cookie) => cookie.name === "AP4_SESSION"); + assert.ok(sessionCookie); + assert.equal(sessionCookie.httpOnly, true); + assert.equal(sessionCookie.sameSite, "Lax"); + assert.equal(sessionCookie.secure, false); + + const storage = await page.evaluate(() => ({ + localStorage: Object.values(localStorage), + sessionStorage: Object.values(sessionStorage), + readableCookies: document.cookie, + })); + assert.deepEqual(storage.localStorage, []); + assert.deepEqual(storage.sessionStorage, []); + assert.equal(storage.readableCookies.includes("AP4_SESSION"), false); + + const missingHeader = await fetch("http://localhost:8081/edge/me"); + assert.equal(missingHeader.status, 401); + const directSpoof = await fetch("http://localhost:8081/edge/me", { + headers: { "X-Forwarded-User": "spoofed-admin" }, + }); + assert.equal(directSpoof.status, 200); + const spoofedIdentity = await directSpoof.json(); + assert.equal(spoofedIdentity.user, "spoofed-admin"); + + console.log( + "pattern4 oauth2-proxy verified: redirect, PKCE login, forwarded-user 200, direct spoof precondition", + ); +} finally { + await browser.close(); +} diff --git a/keycloak/import/keycloak-patterns-realm.json b/keycloak/import/keycloak-patterns-realm.json index 9a4fae9..0071289 100644 --- a/keycloak/import/keycloak-patterns-realm.json +++ b/keycloak/import/keycloak-patterns-realm.json @@ -120,6 +120,7 @@ ], "webOrigins": [], "attributes": { + "pkce.code.challenge.method": "S256", "post.logout.redirect.uris": "http://localhost:8088/*" } } diff --git a/scripts/verify-pattern4.sh b/scripts/verify-pattern4.sh new file mode 100755 index 0000000..2343dac --- /dev/null +++ b/scripts/verify-pattern4.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env sh +set -eu + +if [ ! -f .env ]; then + echo "missing .env; copy .env.example and set development values" >&2 + exit 1 +fi + +set -a +. ./.env +set +a + +docker compose down --volumes --remove-orphans +docker compose up --build -d --wait + +npm --prefix e2e ci +E2E_USERNAME=regular-user \ +E2E_PASSWORD="$REGULAR_USER_PASSWORD" \ + npm --prefix e2e run test:pattern4 + +echo "AP4 oauth2-proxy edge flow verified"