Compare commits

..
Author SHA1 Message Date
donghyeon-ka 5e5badbffb feat(ap2): add confidential token mediator 2026-07-25 14:23:27 +09:00
24 changed files with 393 additions and 370 deletions
-1
View File
@@ -10,7 +10,6 @@ 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
+1
View File
@@ -4,6 +4,7 @@
*.iml
backend/target/
token-mediator/target/
**/node_modules/
frontend/dist/
build/
+7 -12
View File
@@ -97,20 +97,15 @@ Keycloak을 잠시 중지하고 export한 뒤 자동으로 다시 올립니다.
runtime export에는 실제 client secret과 credential hash가 포함될 수 있어
gitignored `build/keycloak-export/`에 권한 `0600`으로만 저장됩니다.
## AP4: oauth2-proxy Edge Forward Auth
## AP2: Token-Mediating Backend
`develop-keycloak-pattern4`는 oauth2-proxy와 Nginx `auth_request`
인증을 edge에서 강제하는 패턴입니다.
`develop-keycloak-pattern2`의 Spring confidential client는
`http://localhost:8082`에서 실행됩니다. 브라우저는 로그인 redirect와
HttpOnly `AP2_SESSION`만 사용하고, authorization code 교환과
access/refresh token 보관은 backend가 담당합니다.
```bash
./scripts/verify-pattern4.sh
./scripts/verify-pattern2.sh
```
첫 feature에서는 oauth2-proxy를 `http://localhost:4180`에 직접 노출해
OIDC redirect/PKCE/callback과 forwarded-user를 분리 확인합니다. 두 번째
feature부터 `http://localhost:8088` Nginx가 단일 진입점이며, 내부
`auth_request`는 브라우저 요청을 login 302로, API 요청을 JSON 401로
구분합니다. 최종 feature에서는 backend의 호스트 노출도 제거합니다.
자세한 내용은
[`docs/ap4-edge-forward-auth.md`](docs/ap4-edge-forward-auth.md)를
참고하세요.
`/token/boundary`는 실제 token 값을 반환하지 않고 서버 저장 여부만 보여줍니다.
@@ -1,47 +0,0 @@
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<Map<String, Object>> 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<String, Object> 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();
}
}
@@ -17,12 +17,7 @@ public class SecurityConfig {
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(
"/actuator/health",
"/actuator/health/**",
"/api/public",
"/edge/**"
)
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public")
.permitAll()
.anyRequest()
.authenticated())
@@ -40,20 +40,4 @@ 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"));
}
}
+13 -46
View File
@@ -86,54 +86,25 @@ 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:8088/oauth2/callback
- --upstream=http://app:8081
- --email-domain=*
- --scope=openid profile email
- --code-challenge-method=S256
- --reverse-proxy=true
- --trusted-proxy-ip=172.30.40.10/32
- --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:8088
- --whitelist-domain=localhost:8080
token-mediator:
build:
context: ./token-mediator
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}
expose:
- "4180"
SERVER_PORT: "8082"
KEYCLOAK_CLIENT_SECRET: ${TOKEN_MEDIATING_CLIENT_SECRET:?set TOKEN_MEDIATING_CLIENT_SECRET in .env}
ports:
- "127.0.0.1:8082:8082"
depends_on:
keycloak:
condition: service_healthy
app:
condition: service_healthy
healthcheck:
test:
- CMD
- /bin/oauth2-proxy
- --version
- CMD-SHELL
- wget -q -O - http://127.0.0.1:8082/actuator/health | grep -q '"status":"UP"'
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
retries: 12
start_period: 20s
networks:
- keycloak-net
restart: unless-stopped
@@ -144,7 +115,7 @@ services:
ports:
- "127.0.0.1:${NGINX_PORT:-8088}:80"
depends_on:
oauth2-proxy:
app:
condition: service_healthy
healthcheck:
test:
@@ -154,8 +125,7 @@ services:
timeout: 5s
retries: 12
networks:
keycloak-net:
ipv4_address: 172.30.40.10
- keycloak-net
restart: unless-stopped
volumes:
@@ -165,6 +135,3 @@ volumes:
networks:
keycloak-net:
driver: bridge
ipam:
config:
- subnet: 172.30.40.0/24
-51
View File
@@ -1,51 +0,0 @@
# 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 검증을 함께 적용합니다.
## 두 번째 단계: Nginx `auth_request`
`feature/keycloak-nginx-auth-request-integration`부터 외부 진입점은
`http://localhost:8088` Nginx 하나입니다. oauth2-proxy의 4180 포트는
Compose 네트워크에만 expose됩니다.
- Nginx의 정확 일치 `location = /oauth2/auth``internal`이라 외부에서
직접 호출할 수 없습니다.
- 인증 서브리퀘스트에는 본문을 보내지 않고 `Content-Length`
비웁니다.
- 일반 브라우저 요청의 401은 `/oauth2/start` 302로 변환합니다.
- API 요청 `/api/edge`는 redirect하지 않고 JSON 401을 반환합니다.
- 인증 성공 시 oauth2-proxy의 `X-Auth-Request-User`와 email만 backend로
전달합니다.
Nginx 컨테이너 IP를 전용 Compose subnet에서 고정하고 oauth2-proxy의
trusted proxy를 그 단일 IP로 제한합니다. 다만 이 단계에서는 backend
8081이 로컬 호스트에 열려 있어 신뢰 헤더를 직접 위조할 수 있습니다.
그 재현 조건은 마지막 feature에서 제거합니다.
+1 -1
View File
@@ -4,7 +4,7 @@
"version": "1.0.0",
"type": "module",
"scripts": {
"test:pattern4": "node pattern4.mjs"
"test:pattern2": "node pattern2.mjs"
},
"devDependencies": {
"playwright-core": "1.62.0"
+57
View File
@@ -0,0 +1,57 @@
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");
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();
await page.goto("http://localhost:8082");
await page.locator("#login").click();
await page.waitForURL(/localhost:8080/u);
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.waitForURL("http://localhost:8082/");
const boundaryResponsePromise = page.waitForResponse((response) =>
response.url().endsWith("/token/boundary"),
);
await page.locator("#inspect").click();
const boundaryResponse = await boundaryResponsePromise;
assert.equal(boundaryResponse.status(), 200);
const boundary = await boundaryResponse.json();
assert.equal(boundary.accessTokenStored, true);
assert.equal(boundary.refreshTokenStored, true);
assert.equal(boundary.browserReceivesRefreshToken, false);
assert.equal(JSON.stringify(boundary).includes("refresh_token"), false);
const cookies = await context.cookies("http://localhost:8082/");
const sessionCookie = cookies.find((cookie) => cookie.name === "AP2_SESSION");
assert.ok(sessionCookie);
assert.equal(sessionCookie.httpOnly, true);
assert.equal(sessionCookie.sameSite, "Lax");
const storage = await page.evaluate(() => ({
localStorage: Object.values(localStorage),
sessionStorage: Object.values(sessionStorage),
}));
assert.equal(JSON.stringify(storage).includes("refresh_token"), false);
console.log(
"pattern2 confidential client verified: server code exchange, server access/refresh custody, HttpOnly session",
);
} finally {
await browser.close();
}
-128
View File
@@ -1,128 +0,0 @@
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");
const edgeBaseUrl = "http://localhost:8088";
const edgeEntryUrl = `${edgeBaseUrl}/`;
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() === edgeEntryUrl) {
return;
}
if (attempt === 1) {
await page.goto(`${edgeBaseUrl}/oauth2/start?rd=${encodeURIComponent(edgeEntryUrl)}`);
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() === edgeEntryUrl &&
response.status() === 302,
);
const authorizationRequestPromise = page.waitForRequest((request) =>
request.url().includes(
"/protocol/openid-connect/auth?approval_prompt=",
),
);
await page.goto(edgeEntryUrl);
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-Auth-Request-User");
const callbackRequest = browserRequests.find(({ url }) =>
url.startsWith(`${edgeBaseUrl}/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(edgeEntryUrl);
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 externalAuthSubrequest = await fetch(`${edgeBaseUrl}/oauth2/auth`);
assert.equal(externalAuthSubrequest.status, 404);
const apiResponse = await fetch(`${edgeBaseUrl}/api/edge`, {
redirect: "manual",
});
assert.equal(apiResponse.status, 401);
assert.equal(apiResponse.headers.get("location"), null);
await assert.rejects(
fetch("http://localhost:4180/ping"),
"oauth2-proxy must not be published on the host",
);
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-Auth-Request-User": "spoofed-admin" },
});
assert.equal(directSpoof.status, 200);
const spoofedIdentity = await directSpoof.json();
assert.equal(spoofedIdentity.user, "spoofed-admin");
console.log(
"pattern4 nginx auth_request verified: internal subrequest, browser redirect, API 401, forwarded identity",
);
} finally {
await browser.close();
}
+6 -53
View File
@@ -2,7 +2,8 @@ server {
listen 80;
server_name _;
large_client_header_buffers 4 16k;
root /usr/share/nginx/html;
index index.html;
location = /health {
access_log off;
@@ -10,64 +11,16 @@ server {
return 200 "ok\n";
}
location = /oauth2/auth {
internal;
proxy_pass http://oauth2-proxy:4180;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Uri $request_uri;
}
location /oauth2/ {
proxy_pass http://oauth2-proxy:4180;
location /api/ {
proxy_pass http://app:8081;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Auth-Request-Redirect $scheme://$http_host$request_uri;
}
location = /api/edge {
auth_request /oauth2/auth;
error_page 401 = @api_unauthorized;
auth_request_set $auth_user $upstream_http_x_auth_request_user;
auth_request_set $auth_email $upstream_http_x_auth_request_email;
auth_request_set $auth_cookie $upstream_http_set_cookie;
add_header Set-Cookie $auth_cookie always;
proxy_pass http://app:8081/edge/me;
proxy_set_header X-Auth-Request-User $auth_user;
proxy_set_header X-Auth-Request-Email $auth_email;
}
location / {
auth_request /oauth2/auth;
error_page 401 = @oauth2_signin;
auth_request_set $auth_user $upstream_http_x_auth_request_user;
auth_request_set $auth_email $upstream_http_x_auth_request_email;
auth_request_set $auth_cookie $upstream_http_set_cookie;
add_header Set-Cookie $auth_cookie always;
proxy_pass http://app:8081/edge/me;
proxy_set_header X-Auth-Request-User $auth_user;
proxy_set_header X-Auth-Request-Email $auth_email;
}
location @oauth2_signin {
return 302 $scheme://$http_host/oauth2/start?rd=$scheme://$http_host$request_uri;
}
location @api_unauthorized {
default_type application/json;
return 401 '{"error":"authentication required"}';
try_files $uri $uri/ /index.html;
}
}
+1 -2
View File
@@ -116,11 +116,10 @@
"serviceAccountsEnabled": false,
"frontchannelLogout": true,
"redirectUris": [
"http://localhost:8088/oauth2/callback"
"http://localhost:4180/oauth2/callback"
],
"webOrigins": [],
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "http://localhost:8088/*"
}
}
@@ -13,14 +13,9 @@ set +a
docker compose down --volumes --remove-orphans
docker compose up --build -d --wait
docker compose exec -T nginx nginx -V 2>&1 |
grep -q -- '--with-http_auth_request_module'
docker compose exec -T nginx nginx -T 2>&1 |
grep -q 'proxy_pass_request_body off'
npm --prefix e2e ci
E2E_USERNAME=regular-user \
E2E_PASSWORD="$REGULAR_USER_PASSWORD" \
npm --prefix e2e run test:pattern4
npm --prefix e2e run test:pattern2
echo "AP4 Nginx auth_request edge flow verified"
echo "AP2 confidential client boundary verified"
+1
View File
@@ -0,0 +1 @@
target/
+17
View File
@@ -0,0 +1,17 @@
FROM maven:3.9.11-eclipse-temurin-21-alpine AS build
WORKDIR /workspace
COPY pom.xml .
RUN mvn --batch-mode dependency:go-offline
COPY src src
RUN mvn --batch-mode verify
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
WORKDIR /app
COPY --from=build /workspace/target/keycloak-token-mediator.jar app.jar
USER spring:spring
EXPOSE 8082
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
+58
View File
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>keycloak-token-mediator</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>keycloak-token-mediator</name>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>keycloak-token-mediator</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,29 @@
package com.example.keycloakpattern.mediator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain mediatorSecurity(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(
"/",
"/index.html",
"/app.js",
"/favicon.ico",
"/actuator/health",
"/actuator/health/**"
)
.permitAll()
.anyRequest()
.authenticated())
.oauth2Login(oauth2 -> oauth2.defaultSuccessUrl("/", true))
.build();
}
}
@@ -0,0 +1,44 @@
package com.example.keycloakpattern.mediator;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TokenBoundaryController {
private final OAuth2AuthorizedClientService authorizedClientService;
public TokenBoundaryController(
OAuth2AuthorizedClientService authorizedClientService
) {
this.authorizedClientService = authorizedClientService;
}
@GetMapping("/token/boundary")
ResponseEntity<Map<String, Object>> tokenBoundary(Authentication authentication) {
OAuth2AuthorizedClient client = authorizedClientService.loadAuthorizedClient(
"keycloak",
authentication.getName()
);
Map<String, Object> response = new LinkedHashMap<>();
response.put("pattern", "AP2-token-mediating-backend");
response.put("principal", authentication.getName());
response.put("accessTokenStored", client != null && client.getAccessToken() != null);
response.put("refreshTokenStored", client != null && client.getRefreshToken() != null);
response.put("browserReceivesRefreshToken", false);
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.header("Pragma", "no-cache")
.body(response);
}
}
@@ -0,0 +1,12 @@
package com.example.keycloakpattern.mediator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TokenMediatorApplication {
public static void main(String[] args) {
SpringApplication.run(TokenMediatorApplication.class, args);
}
}
@@ -0,0 +1,44 @@
server:
port: ${SERVER_PORT:8082}
servlet:
session:
cookie:
name: AP2_SESSION
http-only: true
same-site: lax
spring:
application:
name: keycloak-token-mediator
security:
oauth2:
client:
registration:
keycloak:
provider: keycloak
client-id: token-mediating-confidential
client-secret: ${KEYCLOAK_CLIENT_SECRET}
client-authentication-method: client_secret_basic
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope:
- openid
- profile
- email
provider:
keycloak:
authorization-uri: http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/auth
token-uri: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/token
jwk-set-uri: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/certs
user-info-uri: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/userinfo
user-name-attribute: preferred_username
management:
endpoint:
health:
probes:
enabled: true
endpoints:
web:
exposure:
include: health,info
@@ -0,0 +1,20 @@
const result = document.querySelector("#result");
function render(value) {
result.textContent = JSON.stringify(value, null, 2);
}
document.querySelector("#login").addEventListener("click", () => {
window.location.assign("/oauth2/authorization/keycloak");
});
document.querySelector("#inspect").addEventListener("click", async () => {
const response = await fetch("/token/boundary", {
headers: { Accept: "application/json" },
});
if (response.redirected || response.status === 401) {
window.location.assign("/oauth2/authorization/keycloak");
return;
}
render(await response.json());
});
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AP2 · Token-Mediating Backend</title>
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { max-width: 52rem; margin: 6vh auto; padding: 0 1.5rem; line-height: 1.6; }
button { margin-right: 0.5rem; padding: 0.6rem 0.9rem; cursor: pointer; }
pre { min-height: 8rem; padding: 1rem; border-radius: 0.4rem;
background: color-mix(in srgb, CanvasText 9%, Canvas); white-space: pre-wrap; }
</style>
</head>
<body>
<main>
<h1>AP2 · Token-Mediating Backend</h1>
<p>
confidential backend가 authorization code를 token으로 교환합니다.
refresh token은 서버의 <code>OAuth2AuthorizedClientService</code>에만
보관됩니다.
</p>
<button id="login" type="button">Keycloak 로그인</button>
<button id="inspect" type="button">서버 token 경계 확인</button>
<pre id="result" aria-live="polite"></pre>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>
@@ -0,0 +1,50 @@
package com.example.keycloakpattern.mediator;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(properties = "KEYCLOAK_CLIENT_SECRET=test-only-secret")
@AutoConfigureMockMvc
class TokenBoundaryControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private OAuth2AuthorizedClientService authorizedClientService;
@Test
void reportsServerSideTokensWithoutReturningTheirValues() throws Exception {
OAuth2AuthorizedClient client = mock(OAuth2AuthorizedClient.class);
when(client.getAccessToken()).thenReturn(mock(OAuth2AccessToken.class));
when(client.getRefreshToken()).thenReturn(mock(OAuth2RefreshToken.class));
when(authorizedClientService.loadAuthorizedClient("keycloak", "test-subject"))
.thenReturn(client);
mockMvc.perform(get("/token/boundary").with(oidcLogin()
.idToken(token -> token.subject("test-subject"))))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(jsonPath("$.accessTokenStored").value(true))
.andExpect(jsonPath("$.refreshTokenStored").value(true))
.andExpect(jsonPath("$.browserReceivesRefreshToken").value(false))
.andExpect(jsonPath("$.access_token").doesNotExist())
.andExpect(jsonPath("$.refresh_token").doesNotExist());
}
}