Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e5badbffb |
@@ -10,15 +10,8 @@ POSTGRES_PASSWORD=change-me-postgres-password
|
|||||||
TOKEN_MEDIATING_CLIENT_SECRET=change-me-token-mediating-client-secret
|
TOKEN_MEDIATING_CLIENT_SECRET=change-me-token-mediating-client-secret
|
||||||
BFF_CLIENT_SECRET=change-me-bff-client-secret
|
BFF_CLIENT_SECRET=change-me-bff-client-secret
|
||||||
EDGE_PROXY_CLIENT_SECRET=change-me-edge-proxy-client-secret
|
EDGE_PROXY_CLIENT_SECRET=change-me-edge-proxy-client-secret
|
||||||
MOCK_GOOGLE_BROKER_CLIENT_SECRET=change-me-mock-google-broker-client-secret
|
|
||||||
ADMIN_USER_PASSWORD=change-me-admin-user-password
|
ADMIN_USER_PASSWORD=change-me-admin-user-password
|
||||||
REGULAR_USER_PASSWORD=change-me-regular-user-password
|
REGULAR_USER_PASSWORD=change-me-regular-user-password
|
||||||
MOCK_GOOGLE_USER_PASSWORD=change-me-mock-google-user-password
|
|
||||||
|
|
||||||
# Optional real-Google profile. These are consumed only by
|
|
||||||
# scripts/configure-google-idp.sh and must never be committed with real values.
|
|
||||||
GOOGLE_CLIENT_ID=
|
|
||||||
GOOGLE_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# Port 80 is the single-EC2 target. 8088 avoids common local port conflicts.
|
# Port 80 is the single-EC2 target. 8088 avoids common local port conflicts.
|
||||||
NGINX_PORT=8088
|
NGINX_PORT=8088
|
||||||
|
|||||||
+3
-11
@@ -4,15 +4,7 @@
|
|||||||
*.iml
|
*.iml
|
||||||
|
|
||||||
backend/target/
|
backend/target/
|
||||||
build/
|
token-mediator/target/
|
||||||
e2e/node_modules/
|
**/node_modules/
|
||||||
google-e2e/node_modules/
|
|
||||||
frontend/node_modules/
|
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
build/
|
||||||
bff/target
|
|
||||||
token-mediator/target
|
|
||||||
|
|
||||||
# lab cloud-init contains a console password; keep the filled copy local
|
|
||||||
deploy/lab/cloud-init/kc-lab.yaml
|
|
||||||
deploy/lab/cloud-init/kc-lab-*.yaml
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
[ 295ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[ 125ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[ 95ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[ 91ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[ 88ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
- generic [ref=f7e3]:
|
|
||||||
- link "본문으로 건너뛰기" [ref=f7e4] [cursor=pointer]:
|
|
||||||
- /url: "#main-content"
|
|
||||||
- banner [ref=f7e5]:
|
|
||||||
- generic [ref=f7e6]:
|
|
||||||
- link "TechLog 홈" [ref=f7e8] [cursor=pointer]:
|
|
||||||
- /url: /
|
|
||||||
- text: TechLog
|
|
||||||
- generic [ref=f7e9]:
|
|
||||||
- button "TechLog 검색 열기" [ref=f7e11] [cursor=pointer]: 검색
|
|
||||||
- group [ref=f7e12]:
|
|
||||||
- generic "메뉴" [ref=f7e13] [cursor=pointer]
|
|
||||||
- generic [ref=f7e14]:
|
|
||||||
- paragraph [ref=f7e15]: 화면을 준비하고 있습니다.
|
|
||||||
- generic [ref=f7e16]: TechLog 로딩 중
|
|
||||||
- contentinfo [ref=f7e17]:
|
|
||||||
- generic [ref=f7e18]:
|
|
||||||
- generic [ref=f7e19]:
|
|
||||||
- paragraph [ref=f7e20]: 동현
|
|
||||||
- paragraph [ref=f7e21]: 문제를 재현하고 검증해 실제 운영에 적용할 수 있는 형태로 정리합니다.
|
|
||||||
- generic [ref=f7e22]:
|
|
||||||
- link "프로필" [ref=f7e23] [cursor=pointer]:
|
|
||||||
- /url: /profile
|
|
||||||
- link "변경 기록" [ref=f7e24] [cursor=pointer]:
|
|
||||||
- /url: /releases
|
|
||||||
@@ -1,12 +1,5 @@
|
|||||||
# Keycloak Authentication Patterns
|
# Keycloak Authentication Patterns
|
||||||
|
|
||||||
The 39-branch implementation registry is documented in
|
|
||||||
[`docs/keycloak-branch-index.md`](docs/keycloak-branch-index.md).
|
|
||||||
|
|
||||||
Google brokering has a credential-free local OIDC harness and an opt-in
|
|
||||||
real-Google profile described in
|
|
||||||
[`docs/google-idp-brokering.md`](docs/google-idp-brokering.md).
|
|
||||||
|
|
||||||
Keycloak을 중심으로 네 가지 브라우저 인증 통합 패턴을 같은 로컬
|
Keycloak을 중심으로 네 가지 브라우저 인증 통합 패턴을 같은 로컬
|
||||||
인프라에서 비교하는 학습 프로젝트입니다.
|
인프라에서 비교하는 학습 프로젝트입니다.
|
||||||
|
|
||||||
@@ -15,11 +8,6 @@ Keycloak을 중심으로 네 가지 브라우저 인증 통합 패턴을 같은
|
|||||||
- AP3: Backend-for-Frontend (BFF)
|
- AP3: Backend-for-Frontend (BFF)
|
||||||
- AP4: Edge forward-auth
|
- AP4: Edge forward-auth
|
||||||
|
|
||||||
세션 저장소·refresh token 경쟁·장애 복구는 네 패턴을 가로지르는 별도 축으로
|
|
||||||
`develop-keycloak-session-store` 브랜치에서 진행합니다. 계획과 진행 상황은
|
|
||||||
[`docs/session-store-lab-roadmap.md`](docs/session-store-lab-roadmap.md)에
|
|
||||||
있습니다.
|
|
||||||
|
|
||||||
현재 `develop`의 공통 baseline은 Keycloak, PostgreSQL, Spring Boot API,
|
현재 `develop`의 공통 baseline은 Keycloak, PostgreSQL, Spring Boot API,
|
||||||
nginx를 Docker Compose로 실행하는 토대입니다. 패턴별 구현은 이 baseline
|
nginx를 Docker Compose로 실행하는 토대입니다. 패턴별 구현은 이 baseline
|
||||||
위에서 별도 브랜치로 진행합니다.
|
위에서 별도 브랜치로 진행합니다.
|
||||||
@@ -108,3 +96,16 @@ Keycloak을 잠시 중지하고 export한 뒤 자동으로 다시 올립니다.
|
|||||||
|
|
||||||
runtime export에는 실제 client secret과 credential hash가 포함될 수 있어
|
runtime export에는 실제 client secret과 credential hash가 포함될 수 있어
|
||||||
gitignored `build/keycloak-export/`에 권한 `0600`으로만 저장됩니다.
|
gitignored `build/keycloak-export/`에 권한 `0600`으로만 저장됩니다.
|
||||||
|
|
||||||
|
## AP2: Token-Mediating Backend
|
||||||
|
|
||||||
|
`develop-keycloak-pattern2`의 Spring confidential client는
|
||||||
|
`http://localhost:8082`에서 실행됩니다. 브라우저는 로그인 redirect와
|
||||||
|
HttpOnly `AP2_SESSION`만 사용하고, authorization code 교환과
|
||||||
|
access/refresh token 보관은 backend가 담당합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/verify-pattern2.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`/token/boundary`는 실제 token 값을 반환하지 않고 서버 저장 여부만 보여줍니다.
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package com.example.keycloakpattern;
|
package com.example.keycloakpattern;
|
||||||
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
@@ -11,8 +9,6 @@ import org.springframework.web.bind.annotation.GetMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api")
|
@RequestMapping("/api")
|
||||||
public class ApiController {
|
public class ApiController {
|
||||||
@@ -22,38 +18,6 @@ public class ApiController {
|
|||||||
return Map.of("status", "ok", "service", "keycloak-pattern-api");
|
return Map.of("status", "ok", "service", "keycloak-pattern-api");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reflects what actually reached the application after the proxy chain.
|
|
||||||
*
|
|
||||||
* <p>The reverse proxy contract is defined in {@code docs/reverse-proxy-headers.md}
|
|
||||||
* for a single nginx hop. The lab runs {@code nginx -> Traefik -> pod}, so this
|
|
||||||
* endpoint exists to measure the two-hop result instead of assuming it.
|
|
||||||
*
|
|
||||||
* <p>{@code scheme}, {@code secure} and {@code requestUrl} are the values Keycloak
|
|
||||||
* uses to build the {@code iss} claim and redirect URLs. If forwarded headers are
|
|
||||||
* lost or rewritten, the mismatch shows up here first.
|
|
||||||
*/
|
|
||||||
@GetMapping("/echo")
|
|
||||||
public Map<String, Object> echo(HttpServletRequest request) {
|
|
||||||
Map<String, List<String>> headers = new LinkedHashMap<>();
|
|
||||||
for (String name : Collections.list(request.getHeaderNames())) {
|
|
||||||
headers.put(name.toLowerCase(), Collections.list(request.getHeaders(name)));
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> response = new LinkedHashMap<>();
|
|
||||||
response.put("headers", headers);
|
|
||||||
response.put("remoteAddr", request.getRemoteAddr());
|
|
||||||
// Pod IP. Identifies which replica answered, which is what makes the
|
|
||||||
// host nginx upstream distribution and the sticky-session switch observable.
|
|
||||||
response.put("localAddr", request.getLocalAddr());
|
|
||||||
response.put("scheme", request.getScheme());
|
|
||||||
response.put("secure", request.isSecure());
|
|
||||||
response.put("serverName", request.getServerName());
|
|
||||||
response.put("serverPort", request.getServerPort());
|
|
||||||
response.put("requestUrl", request.getRequestURL().toString());
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
public Map<String, Object> currentUser(@AuthenticationPrincipal Jwt jwt) {
|
public Map<String, Object> currentUser(@AuthenticationPrincipal Jwt jwt) {
|
||||||
Map<String, Object> response = new LinkedHashMap<>();
|
Map<String, Object> response = new LinkedHashMap<>();
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ public class SecurityConfig {
|
|||||||
.sessionManagement(session ->
|
.sessionManagement(session ->
|
||||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(authorize -> authorize
|
.authorizeHttpRequests(authorize -> authorize
|
||||||
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public",
|
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public")
|
||||||
"/api/echo")
|
|
||||||
.permitAll()
|
.permitAll()
|
||||||
.anyRequest()
|
.anyRequest()
|
||||||
.authenticated())
|
.authenticated())
|
||||||
|
|||||||
@@ -1,19 +1,9 @@
|
|||||||
server:
|
server:
|
||||||
port: ${SERVER_PORT:8081}
|
port: ${SERVER_PORT:8081}
|
||||||
# Spring ignores X-Forwarded-* unless this is set, so scheme/secure/requestUrl
|
|
||||||
# report the raw connection by default. Keycloak has the same opt-in as
|
|
||||||
# KC_PROXY_HEADERS. Flipping this to "native" is what the two-hop measurement
|
|
||||||
# compares against.
|
|
||||||
forward-headers-strategy: ${SERVER_FORWARD_HEADERS_STRATEGY:none}
|
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: keycloak-pattern-api
|
name: keycloak-pattern-api
|
||||||
jackson:
|
|
||||||
serialization:
|
|
||||||
# /api/echo is read by humans and captured as evidence screenshots, so the
|
|
||||||
# response is indented rather than relying on a browser's JSON viewer.
|
|
||||||
indent-output: true
|
|
||||||
security:
|
security:
|
||||||
oauth2:
|
oauth2:
|
||||||
resourceserver:
|
resourceserver:
|
||||||
|
|||||||
@@ -25,18 +25,6 @@ class ApiSecurityTest {
|
|||||||
.andExpect(jsonPath("$.status").value("ok"));
|
.andExpect(jsonPath("$.status").value("ok"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void echoEndpointReflectsForwardedHeadersWithoutAuthentication() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/echo")
|
|
||||||
.header("X-Forwarded-Proto", "https")
|
|
||||||
.header("X-Forwarded-Host", "app1.example.test"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.headers['x-forwarded-proto'][0]").value("https"))
|
|
||||||
.andExpect(jsonPath("$.headers['x-forwarded-host'][0]").value("app1.example.test"))
|
|
||||||
.andExpect(jsonPath("$.requestUrl").exists())
|
|
||||||
.andExpect(jsonPath("$.remoteAddr").exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void protectedEndpointRejectsAnonymousRequests() throws Exception {
|
void protectedEndpointRejectsAnonymousRequests() throws Exception {
|
||||||
mockMvc.perform(get("/api/me"))
|
mockMvc.perform(get("/api/me"))
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
# Session store lab
|
|
||||||
|
|
||||||
세션 저장소·refresh token 경쟁·장애 복구를 검증하는 2노드 k3s 실험대.
|
|
||||||
네 인증 패턴(AP1~AP4)을 가로지르는 공통층이므로 별도 축으로 관리한다.
|
|
||||||
|
|
||||||
이 문서는 **절차**만 담는다.
|
|
||||||
|
|
||||||
| 문서 | 내용 |
|
|
||||||
|---|---|
|
|
||||||
| [`docs/session-store-lab-roadmap.md`](../../docs/session-store-lab-roadmap.md) | 이 축의 계획과 진행 상황 |
|
|
||||||
| [`docs/session-lab-concepts.md`](../../docs/session-lab-concepts.md) | 등장 개념 전체 |
|
|
||||||
| [`docs/session-lab-operations.md`](../../docs/session-lab-operations.md) | 관측 도구 · 자주 쓰는 명령 · 훈련 |
|
|
||||||
| [`docs/two-hop-proxy-header-contract.md`](../../docs/two-hop-proxy-header-contract.md) | 첫 실험 결과 |
|
|
||||||
|
|
||||||
## 토폴로지
|
|
||||||
|
|
||||||
```
|
|
||||||
브라우저 / SSH (tailnet)
|
|
||||||
│ https://{auth,app1,app2}.hyeonworks.com → 100.83.212.4
|
|
||||||
▼
|
|
||||||
lab host ── nginx :443 TLS 종료 · X-Forwarded-* 주입
|
|
||||||
│ nginx :80 301 → https
|
|
||||||
│
|
|
||||||
│ virbr0 192.168.122.0/24 (libvirt NAT)
|
|
||||||
├──▶ kc-lab-1 .11 k3s server Traefik :80
|
|
||||||
└──▶ kc-lab-2 .12 k3s agent Traefik :80
|
|
||||||
└──▶ Pod
|
|
||||||
```
|
|
||||||
|
|
||||||
`nginx → Traefik` **2홉**이 운영 구조와 같다는 점이 이 배치의 핵심이다.
|
|
||||||
L7 프록시가 두 겹인 이유는 역할이 다르기 때문이다 — nginx는 바깥세상과의
|
|
||||||
접점(TLS·인증서·헤더)을, Traefik은 클러스터 내부의 동적 라우팅을 맡는다.
|
|
||||||
|
|
||||||
## 구성 요소
|
|
||||||
|
|
||||||
| 경로 | 역할 |
|
|
||||||
|---|---|
|
|
||||||
| `cloud-init/kc-lab.yaml.example` | 게스트 부트스트랩 템플릿 |
|
|
||||||
| `host/nginx-keycloak-lab.conf` | lab host의 `sites-available/keycloak-lab` |
|
|
||||||
| `k8s/echo.yaml` | 2홉 헤더 계약 측정용 워크로드 |
|
|
||||||
| `scripts/rebuild-seed.sh` | cloud-init 시드 ISO 재생성 + 풀 업로드 |
|
|
||||||
| `scripts/build-and-import.sh` | 이미지 빌드 → 각 노드 containerd 반입 |
|
|
||||||
| `scripts/measure-proxy-headers.sh` | 헤더 계약 실측 |
|
|
||||||
| `scripts/verify-lab.sh` | 인프라 상태 점검 |
|
|
||||||
|
|
||||||
## 상태 점검
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./deploy/lab/scripts/verify-lab.sh # lab host 에서
|
|
||||||
```
|
|
||||||
|
|
||||||
**`404`가 성공 신호다.** TLS가 종료되고 Traefik까지 도달했으나 매칭되는
|
|
||||||
Ingress 규칙이 없다는 뜻이다. `502`나 연결 거부면 체인이 끊긴 것이다.
|
|
||||||
|
|
||||||
## 첫 실험 — 2홉 헤더 계약
|
|
||||||
|
|
||||||
[`docs/reverse-proxy-headers.md`](../../docs/reverse-proxy-headers.md)의 계약은
|
|
||||||
nginx **1홉**을 가정하고 쓰였다. 실제 배치는 2홉이므로, nginx가 세팅한
|
|
||||||
`X-Forwarded-*`를 Traefik이 그대로 넘기는지 덮어쓰는지 **측정해서 확인한다.**
|
|
||||||
|
|
||||||
이 결론이 뒤의 모든 실험에 깔린다. Keycloak의 `iss` 클레임, redirect URL,
|
|
||||||
쿠키 도메인 검증이 전부 이 헤더에 의존하기 때문이다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 워크스테이션: 이미지 빌드 후 두 노드에 반입
|
|
||||||
./deploy/lab/scripts/build-and-import.sh
|
|
||||||
|
|
||||||
# lab host: 배포
|
|
||||||
kubectl apply -f deploy/lab/k8s/echo.yaml
|
|
||||||
kubectl -n header-lab rollout status deployment/echo
|
|
||||||
|
|
||||||
# 어디서든: 실측
|
|
||||||
./deploy/lab/scripts/measure-proxy-headers.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
관측 대상은 넷이다.
|
|
||||||
|
|
||||||
1. `X-Forwarded-For` — Traefik이 **덧붙이는가 덮어쓰는가**
|
|
||||||
2. `X-Forwarded-Proto` / `-Host` / `-Port` — 그대로 전달되는가
|
|
||||||
3. **위조 내성** — 클라이언트가 직접 넣은 `X-Forwarded-*`가 앱까지 도달하는가
|
|
||||||
4. `scheme` / `secure` / `requestUrl` — Keycloak이 URL을 만들 때 쓰는 값
|
|
||||||
|
|
||||||
3번이 신뢰 경계의 핵심이다. 이 헤더들은 누구나 위조할 수 있는 평범한 HTTP
|
|
||||||
헤더이므로, 신뢰 경계에 선 프록시가 **반드시 덮어써야** 한다.
|
|
||||||
|
|
||||||
## 이미지 배포 경로
|
|
||||||
|
|
||||||
k3s는 containerd를 쓰고 이 실험대에는 레지스트리가 없다.
|
|
||||||
|
|
||||||
```
|
|
||||||
워크스테이션 docker build → docker save
|
|
||||||
│ ssh (lab host 경유)
|
|
||||||
▼
|
|
||||||
게스트 sudo k3s ctr images import
|
|
||||||
매니페스트 imagePullPolicy: Never
|
|
||||||
```
|
|
||||||
|
|
||||||
**두 노드 모두에 반입해야 한다.** 스케줄러가 어느 노드에 배치할지 모른다.
|
|
||||||
Keycloak·PostgreSQL·Redis는 공식 이미지를 그대로 당겨오므로 이 경로가
|
|
||||||
필요한 것은 자체 빌드 이미지뿐이다.
|
|
||||||
|
|
||||||
**lab host에 Docker를 설치하지 않는다.** k3s의 containerd와 이미지 저장소가
|
|
||||||
갈려서 `docker build`한 이미지를 k3s가 보지 못하게 된다.
|
|
||||||
|
|
||||||
## 게스트 재생성
|
|
||||||
|
|
||||||
파괴적 실험 후 초기화하는 경로다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
virsh destroy kc-lab-1
|
|
||||||
virsh undefine kc-lab-1 # --remove-all-storage 는 시드 ISO 까지 지운다
|
|
||||||
virsh vol-delete --pool default kc-lab-1.qcow2
|
|
||||||
|
|
||||||
./deploy/lab/scripts/rebuild-seed.sh 1 # user-data 를 고쳤을 때만
|
|
||||||
|
|
||||||
virt-install --name kc-lab-1 --memory 3584 --vcpus 2 \
|
|
||||||
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
|
|
||||||
--disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \
|
|
||||||
--network network=default,mac=52:54:00:aa:bb:11 \
|
|
||||||
--import --os-variant debian12 --noautoconsole
|
|
||||||
```
|
|
||||||
|
|
||||||
시드는 **virtio 디스크**로 붙인다. `virt-install --cloud-init`은 시드를 SATA
|
|
||||||
CD-ROM으로 붙이는데, Debian `genericcloud` 이미지는 크기를 줄이려고 물리
|
|
||||||
하드웨어 드라이버를 제외해서 **AHCI 장치를 보지 못한다.** 그러면 cloud-init이
|
|
||||||
데이터소스를 찾지 못하고 아무 오류도 남기지 않은 채 종료한다. 증상은
|
|
||||||
hostname이 `localhost`로 남고 SSH가 `Permission denied (publickey)`로 거부되는
|
|
||||||
것뿐이다.
|
|
||||||
|
|
||||||
게스트에 들어갈 수 없을 때는 화면을 직접 뜬다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다
|
|
||||||
```
|
|
||||||
|
|
||||||
`localhost login:`이면 cloud-init 미실행, `kc-lab-1 login:`이면 실행된 것이다.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#cloud-config
|
|
||||||
# Template for both lab guests. scripts/rebuild-seed.sh substitutes __NODE__
|
|
||||||
# and bakes this into a CIDATA seed image.
|
|
||||||
#
|
|
||||||
# Copy to kc-lab.yaml and fill the two placeholders. The real file is ignored by
|
|
||||||
# git because plain_text_passwd is a credential, however disposable.
|
|
||||||
#
|
|
||||||
# Indentation is spaces only. YAML forbids tabs, and cloud-init fails silently
|
|
||||||
# on a parse error: the guest boots as "localhost" with no user and no way in.
|
|
||||||
hostname: kc-lab-__NODE__
|
|
||||||
fqdn: kc-lab-__NODE__
|
|
||||||
manage_etc_hosts: true
|
|
||||||
|
|
||||||
users:
|
|
||||||
- name: donghyeon
|
|
||||||
groups: [sudo]
|
|
||||||
shell: /bin/bash
|
|
||||||
# NOPASSWD is required: the k3s installer and the fault-injection scripts
|
|
||||||
# run non-interactively and would block on a password prompt.
|
|
||||||
sudo: ['ALL=(ALL) NOPASSWD:ALL']
|
|
||||||
# Console-only escape hatch. Without it, a cloud-init failure leaves a guest
|
|
||||||
# that cannot be logged into at all, so its own failure log is unreadable.
|
|
||||||
# ssh_pwauth stays false, so this never widens SSH exposure.
|
|
||||||
lock_passwd: false
|
|
||||||
plain_text_passwd: CHANGE_ME
|
|
||||||
ssh_authorized_keys:
|
|
||||||
# Lab host key: needed because automation runs from the lab host, where
|
|
||||||
# agent forwarding is not available.
|
|
||||||
- CHANGE_ME_LAB_HOST_PUBLIC_KEY
|
|
||||||
# Workstation key: lets ProxyJump reach the guest directly.
|
|
||||||
- CHANGE_ME_WORKSTATION_PUBLIC_KEY
|
|
||||||
|
|
||||||
ssh_pwauth: false
|
|
||||||
package_update: true
|
|
||||||
packages:
|
|
||||||
- curl
|
|
||||||
- nftables
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# Lab entry point. Deployed on the lab host as
|
|
||||||
# /etc/nginx/sites-available/keycloak-lab
|
|
||||||
# and symlinked from sites-enabled/.
|
|
||||||
#
|
|
||||||
# Arch does not ship the Debian sites-available convention, so nginx.conf needs
|
|
||||||
# include /etc/nginx/sites-enabled/*;
|
|
||||||
# inside its http { } block before this file has any effect.
|
|
||||||
#
|
|
||||||
# This is the outer of two L7 hops. It terminates TLS and hands plain HTTP to
|
|
||||||
# the Traefik instance running on each k3s node.
|
|
||||||
|
|
||||||
upstream k3s_traefik {
|
|
||||||
# Sticky-session switch. Keycloak recommends affinity on AUTH_SESSION_ID;
|
|
||||||
# ip_hash is the cheap stand-in for a single-browser lab. Leaving it off is
|
|
||||||
# the interesting case: Infinispan still routes correctly, only slower.
|
|
||||||
# ip_hash;
|
|
||||||
server 192.168.122.11:80;
|
|
||||||
server 192.168.122.12:80;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80 default_server;
|
|
||||||
server_name _;
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl default_server;
|
|
||||||
http2 on;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
# fullchain.pem, never cert.pem: omitting the intermediates passes on
|
|
||||||
# desktop browsers and fails on mobile and curl.
|
|
||||||
ssl_certificate /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem;
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://k3s_traefik;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
|
||||||
proxy_set_header X-Forwarded-Port 443;
|
|
||||||
|
|
||||||
# $remote_addr, not $proxy_add_x_forwarded_for. This is the trust
|
|
||||||
# boundary: a client-supplied X-Forwarded-For must be discarded, not
|
|
||||||
# extended, or nothing downstream can rely on the value.
|
|
||||||
proxy_set_header X-Forwarded-For $remote_addr;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
proxy_send_timeout 3600s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# Restrict who may reach the echo pods.
|
|
||||||
#
|
|
||||||
# Traefik is configured to trust X-Forwarded-* from the whole pod CIDR, and the
|
|
||||||
# app's Tomcat valve trusts every private range by default. Both are IP-range
|
|
||||||
# decisions, so any pod in the cluster can forge those headers by talking to the
|
|
||||||
# Service directly and bypassing Traefik entirely. Measured, not hypothetical:
|
|
||||||
#
|
|
||||||
# kubectl -n header-lab run t --rm -i --restart=Never --image=curlimages/curl -- \
|
|
||||||
# curl -s http://echo:8081/api/echo -H 'X-Forwarded-Host: evil.example.com'
|
|
||||||
# → serverName evil.example.com, remoteAddr 1.2.3.4
|
|
||||||
#
|
|
||||||
# A NetworkPolicy closes that path. It selects by label rather than IP, so it
|
|
||||||
# survives pod restarts and rescheduling — unlike the trustedIPs list, which
|
|
||||||
# could not name Traefik because its IP changes.
|
|
||||||
#
|
|
||||||
# "Trusting forwarded headers" and "guaranteeing a proxy sits in front" are a
|
|
||||||
# pair. Doing only the first leaves this hole.
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: NetworkPolicy
|
|
||||||
metadata:
|
|
||||||
name: echo-allow-traefik-only
|
|
||||||
namespace: header-lab
|
|
||||||
spec:
|
|
||||||
podSelector:
|
|
||||||
matchLabels:
|
|
||||||
app: echo
|
|
||||||
policyTypes:
|
|
||||||
- Ingress
|
|
||||||
ingress:
|
|
||||||
# The proxy itself. namespaceSelector and podSelector in one list item are
|
|
||||||
# ANDed, so this is "traefik pods in kube-system" and nothing else.
|
|
||||||
- from:
|
|
||||||
- namespaceSelector:
|
|
||||||
matchLabels:
|
|
||||||
kubernetes.io/metadata.name: kube-system
|
|
||||||
podSelector:
|
|
||||||
matchLabels:
|
|
||||||
app.kubernetes.io/name: traefik
|
|
||||||
ports:
|
|
||||||
- protocol: TCP
|
|
||||||
port: 8081
|
|
||||||
|
|
||||||
# kubelet readiness/liveness probes originate from the node, not from a pod,
|
|
||||||
# so they need their own rule. Without it the probes fail and the pods are
|
|
||||||
# restarted in a loop.
|
|
||||||
#
|
|
||||||
# The probe's source address is the node's flannel bridge (cni0), which
|
|
||||||
# holds the first address of that node's /24:
|
|
||||||
# kc-lab-1 10.42.0.1 kc-lab-2 10.42.1.1
|
|
||||||
# Listing them as /32 keeps this rule from re-admitting arbitrary pods,
|
|
||||||
# which a broader 10.42.0.0/16 block would do and would undo the policy.
|
|
||||||
#
|
|
||||||
# Adding a node means adding its gateway here. Verify with:
|
|
||||||
# kubectl get nodes -o jsonpath='{range .items[*]}{.spec.podCIDR}{"\n"}{end}'
|
|
||||||
- from:
|
|
||||||
- ipBlock:
|
|
||||||
cidr: 10.42.0.1/32
|
|
||||||
- ipBlock:
|
|
||||||
cidr: 10.42.1.1/32
|
|
||||||
ports:
|
|
||||||
- protocol: TCP
|
|
||||||
port: 8081
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
# Header echo workload for the two-hop proxy contract measurement.
|
|
||||||
#
|
|
||||||
# browser -> host nginx (TLS termination) -> Traefik -> this pod
|
|
||||||
#
|
|
||||||
# The image is built from backend/ and imported straight into each node's
|
|
||||||
# containerd, so imagePullPolicy must stay Never. See scripts/build-and-import.sh.
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Namespace
|
|
||||||
metadata:
|
|
||||||
name: header-lab
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: echo
|
|
||||||
namespace: header-lab
|
|
||||||
spec:
|
|
||||||
replicas: 2
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: echo
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: echo
|
|
||||||
spec:
|
|
||||||
# One replica per node so the sticky-session switch on the host nginx
|
|
||||||
# upstream has something observable to route between.
|
|
||||||
topologySpreadConstraints:
|
|
||||||
- maxSkew: 1
|
|
||||||
topologyKey: kubernetes.io/hostname
|
|
||||||
whenUnsatisfiable: ScheduleAnyway
|
|
||||||
labelSelector:
|
|
||||||
matchLabels:
|
|
||||||
app: echo
|
|
||||||
containers:
|
|
||||||
- name: echo
|
|
||||||
image: keycloak-pattern-api:lab
|
|
||||||
imagePullPolicy: Never
|
|
||||||
ports:
|
|
||||||
- containerPort: 8081
|
|
||||||
name: http
|
|
||||||
env:
|
|
||||||
- name: SERVER_PORT
|
|
||||||
value: "8081"
|
|
||||||
# "none" makes the app report the raw connection, so scheme/secure/
|
|
||||||
# requestUrl show what arrives without any forwarded-header handling.
|
|
||||||
# Set to "native" and redeploy to see the same request interpreted
|
|
||||||
# with X-Forwarded-* honoured. Keycloak's KC_PROXY_HEADERS is the
|
|
||||||
# same opt-in, which is why measuring both sides matters here.
|
|
||||||
- name: SERVER_FORWARD_HEADERS_STRATEGY
|
|
||||||
value: "native"
|
|
||||||
# The JVM sizes its heap from the container limit, not the host.
|
|
||||||
- name: JAVA_TOOL_OPTIONS
|
|
||||||
value: "-XX:MaxRAMPercentage=70"
|
|
||||||
# /api/echo is permitAll, so the JWT decoder is never exercised.
|
|
||||||
# These stay pointed at the future Keycloak service name.
|
|
||||||
- name: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI
|
|
||||||
value: "https://auth.hyeonworks.com/realms/keycloak-patterns"
|
|
||||||
- name: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI
|
|
||||||
value: "https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs"
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /actuator/health/readiness
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 15
|
|
||||||
periodSeconds: 5
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /actuator/health/liveness
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 45
|
|
||||||
periodSeconds: 15
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: 320Mi
|
|
||||||
cpu: 100m
|
|
||||||
limits:
|
|
||||||
memory: 512Mi
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: echo
|
|
||||||
namespace: header-lab
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: echo
|
|
||||||
ports:
|
|
||||||
- port: 8081
|
|
||||||
targetPort: http
|
|
||||||
name: http
|
|
||||||
---
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: echo
|
|
||||||
namespace: header-lab
|
|
||||||
spec:
|
|
||||||
# k3s ships Traefik as the default ingress controller. Keeping it is what
|
|
||||||
# makes this lab a faithful two-hop replica.
|
|
||||||
ingressClassName: traefik
|
|
||||||
rules:
|
|
||||||
- host: app1.hyeonworks.com
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /api
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: echo
|
|
||||||
port:
|
|
||||||
number: 8081
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# Make Traefik trust the X-Forwarded-* headers that the host nginx sets.
|
|
||||||
#
|
|
||||||
# Without this, Traefik rewrites every forwarded header from its own connection,
|
|
||||||
# which is plain HTTP on port 80. The application then sees scheme=http even
|
|
||||||
# though the browser connected over TLS. See docs/two-hop-proxy-header-contract.md.
|
|
||||||
#
|
|
||||||
# k3s installs Traefik through its bundled HelmChart, so values are overridden
|
|
||||||
# with a HelmChartConfig rather than by editing the deployment. k3s reconciles
|
|
||||||
# the chart and recreates the Traefik pod.
|
|
||||||
#
|
|
||||||
# kubectl apply -f deploy/lab/k8s/traefik-forwarded-headers.yaml
|
|
||||||
# kubectl -n kube-system rollout status deploy/traefik --timeout=180s
|
|
||||||
apiVersion: helm.cattle.io/v1
|
|
||||||
kind: HelmChartConfig
|
|
||||||
metadata:
|
|
||||||
name: traefik
|
|
||||||
namespace: kube-system
|
|
||||||
spec:
|
|
||||||
valuesContent: |-
|
|
||||||
ports:
|
|
||||||
web:
|
|
||||||
forwardedHeaders:
|
|
||||||
# Requests arriving from these sources keep their existing
|
|
||||||
# X-Forwarded-* values instead of having them rewritten.
|
|
||||||
#
|
|
||||||
# 10.42.0.0/16 is the pod CIDR. It is required because the traefik
|
|
||||||
# Service uses externalTrafficPolicy: Cluster, so svclb SNATs the
|
|
||||||
# traffic and Traefik sees a pod-network address rather than the
|
|
||||||
# host nginx address.
|
|
||||||
#
|
|
||||||
# The node/host range is deliberately absent. Because svclb SNATs,
|
|
||||||
# the host nginx address never reaches Traefik — measured, not assumed.
|
|
||||||
# Trusting a range that cannot appear only widens the surface.
|
|
||||||
#
|
|
||||||
# Trusting the whole pod CIDR still means any pod in the cluster could
|
|
||||||
# forge these headers, which is why echo-network-policy.yaml restricts
|
|
||||||
# who may reach the application at all.
|
|
||||||
trustedIPs:
|
|
||||||
- 10.42.0.0/16
|
|
||||||
websecure:
|
|
||||||
forwardedHeaders:
|
|
||||||
trustedIPs:
|
|
||||||
- 10.42.0.0/16
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Build the API image on this workstation and import it into each lab node's
|
|
||||||
# containerd.
|
|
||||||
#
|
|
||||||
# k3s does not run Docker and the lab has no registry, so images are shipped as
|
|
||||||
# a stream: docker save -> ssh through the lab host -> k3s ctr images import.
|
|
||||||
# Every node needs its own copy because the scheduler may place the pod anywhere.
|
|
||||||
#
|
|
||||||
# ./deploy/lab/scripts/build-and-import.sh
|
|
||||||
# IMAGE=keycloak-pattern-api:lab NODES="kc-lab-1" ./deploy/lab/scripts/build-and-import.sh
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
IMAGE="${IMAGE:-keycloak-pattern-api:lab}"
|
|
||||||
NODES="${NODES:-kc-lab-1 kc-lab-2}"
|
|
||||||
LAB_HOST="${LAB_HOST:-test-server}"
|
|
||||||
CONTEXT="${CONTEXT:-backend}"
|
|
||||||
|
|
||||||
repo_root="$(git rev-parse --show-toplevel)"
|
|
||||||
cd "$repo_root"
|
|
||||||
|
|
||||||
echo "==> building ${IMAGE} from ${CONTEXT}/"
|
|
||||||
docker build -t "$IMAGE" "$CONTEXT"
|
|
||||||
|
|
||||||
for node in $NODES; do
|
|
||||||
echo "==> importing into ${node}"
|
|
||||||
# Nested ssh: the workstation cannot reach the guests directly because they
|
|
||||||
# sit behind the lab host's libvirt NAT. The lab host's ~/.ssh/config holds
|
|
||||||
# the kc-lab-* aliases.
|
|
||||||
docker save "$IMAGE" \
|
|
||||||
| ssh "$LAB_HOST" "ssh ${node} 'sudo k3s ctr images import -'"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "==> verifying"
|
|
||||||
for node in $NODES; do
|
|
||||||
printf ' %-10s ' "$node"
|
|
||||||
ssh "$LAB_HOST" "ssh ${node} 'sudo k3s ctr images ls -q'" \
|
|
||||||
| grep -c "$IMAGE" \
|
|
||||||
| xargs -I{} echo "{} match(es)"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "next: kubectl rollout restart -n header-lab deployment/echo"
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Measure what the nginx -> Traefik chain actually delivers to the application.
|
|
||||||
#
|
|
||||||
# docs/reverse-proxy-headers.md documents a single-hop nginx contract. The lab
|
|
||||||
# runs two hops, so the forwarded headers are measured rather than assumed.
|
|
||||||
# Run from anywhere that can resolve the lab hostnames.
|
|
||||||
#
|
|
||||||
# ./deploy/lab/scripts/measure-proxy-headers.sh
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
HOST="${HOST:-app1.hyeonworks.com}"
|
|
||||||
URL="https://${HOST}/api/echo"
|
|
||||||
|
|
||||||
jqf() {
|
|
||||||
if command -v jq >/dev/null 2>&1; then jq "$@"; else python3 -m json.tool; fi
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "=== 1. baseline: what the app sees for a normal request ==="
|
|
||||||
curl -s "$URL" | jqf '{
|
|
||||||
scheme, secure, serverName, serverPort, requestUrl, remoteAddr,
|
|
||||||
forwarded: .headers | with_entries(select(.key | startswith("x-forwarded") or . == "x-real-ip" or . == "forwarded"))
|
|
||||||
}' 2>/dev/null || curl -s "$URL"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "=== 2. spoof test: client sends its own X-Forwarded-* ==="
|
|
||||||
echo " a trusted boundary must overwrite these, not append to them"
|
|
||||||
curl -s "$URL" \
|
|
||||||
-H 'X-Forwarded-For: 1.2.3.4' \
|
|
||||||
-H 'X-Forwarded-Proto: http' \
|
|
||||||
-H 'X-Forwarded-Host: evil.example.com' \
|
|
||||||
-H 'X-Real-IP: 1.2.3.4' \
|
|
||||||
| jqf '.headers | with_entries(select(.key | startswith("x-forwarded") or . == "x-real-ip"))' 2>/dev/null
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "=== 3. which pod answered (host nginx upstream distribution) ==="
|
|
||||||
for _ in 1 2 3 4; do
|
|
||||||
curl -s "$URL" | jqf -r '.headers["x-forwarded-server"] // "n/a"' 2>/dev/null
|
|
||||||
done
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "=== 4. plain HTTP is redirected, not proxied ==="
|
|
||||||
curl -s -o /dev/null -w ' http -> %{http_code} %{redirect_url}\n' "http://${HOST}/api/echo"
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Rebuild a guest's cloud-init seed image and publish it into the libvirt pool.
|
|
||||||
# Run on the lab host.
|
|
||||||
#
|
|
||||||
# ./rebuild-seed.sh 1
|
|
||||||
#
|
|
||||||
# The same content lives in three places: the source YAML, the ISO, and the
|
|
||||||
# uploaded pool volume. Editing the YAML alone changes nothing, which is why
|
|
||||||
# this is a script and not a set of remembered commands.
|
|
||||||
#
|
|
||||||
# A rebuilt seed only takes effect on a freshly created VM. cloud-init runs its
|
|
||||||
# per-instance modules once per instance-id, so an existing guest ignores it.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
N="${1:?usage: rebuild-seed.sh <1|2>}"
|
|
||||||
CLOUD_DIR="${CLOUD_DIR:-$HOME/workspace/cloud}"
|
|
||||||
POOL="${POOL:-default}"
|
|
||||||
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
|
|
||||||
|
|
||||||
cd "$CLOUD_DIR"
|
|
||||||
src="kc-lab-${N}.yaml"
|
|
||||||
iso="seed-kc-lab-${N}.iso"
|
|
||||||
meta="meta-kc-lab-${N}"
|
|
||||||
|
|
||||||
[ -f "$src" ] || { echo "missing $CLOUD_DIR/$src" >&2; exit 1; }
|
|
||||||
|
|
||||||
# A fresh instance-id makes cloud-init treat the guest as new and re-run the
|
|
||||||
# per-instance modules.
|
|
||||||
printf 'instance-id: kc-lab-%s-%s\nlocal-hostname: kc-lab-%s\n' \
|
|
||||||
"$N" "$(date +%s)" "$N" > "$meta"
|
|
||||||
|
|
||||||
# NoCloud looks for a volume labelled cidata holding files named exactly
|
|
||||||
# user-data and meta-data. -graft-points renames them inside the image so no
|
|
||||||
# staging directory is needed.
|
|
||||||
xorrisofs -quiet -output "$iso" -volid CIDATA -joliet -rock -graft-points \
|
|
||||||
"/user-data=${src}" "/meta-data=${meta}"
|
|
||||||
|
|
||||||
size="$(stat -c%s "$iso")"
|
|
||||||
virsh vol-delete --pool "$POOL" "$iso" >/dev/null 2>&1 || true
|
|
||||||
virsh vol-create-as "$POOL" "$iso" "$size" --format raw >/dev/null
|
|
||||||
virsh vol-upload --pool "$POOL" "$iso" "$iso"
|
|
||||||
|
|
||||||
echo "$iso published to pool '$POOL' ($size bytes)"
|
|
||||||
echo "attach it as a virtio disk, not a SATA cdrom:"
|
|
||||||
echo " --disk vol=${POOL}/${iso},device=disk,bus=virtio,readonly=on"
|
|
||||||
echo "Debian genericcloud images carry no AHCI driver, so a SATA cdrom is invisible"
|
|
||||||
echo "to the guest and cloud-init fails with no error anywhere."
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Confirm the lab infrastructure is intact. Run on the lab host.
|
|
||||||
#
|
|
||||||
# A 404 from the HTTPS entry point is the success signal: TLS terminated and the
|
|
||||||
# request reached Traefik, which simply had no matching ingress rule. A 502 or a
|
|
||||||
# refused connection means the chain is broken somewhere.
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
|
|
||||||
HOSTS="${HOSTS:-auth.hyeonworks.com app1.hyeonworks.com app2.hyeonworks.com}"
|
|
||||||
NODE_IPS="${NODE_IPS:-192.168.122.11 192.168.122.12}"
|
|
||||||
fail=0
|
|
||||||
|
|
||||||
check() { # description, expected, actual
|
|
||||||
if [ "$2" = "$3" ]; then printf ' ok %-34s %s\n' "$1" "$3"
|
|
||||||
else printf ' FAIL %-34s got %s, want %s\n' "$1" "$3" "$2"; fail=1; fi
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "== guests =="
|
|
||||||
for name in kc-lab-1 kc-lab-2; do
|
|
||||||
check "$name" running "$(virsh domstate "$name" 2>/dev/null || echo absent)"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "== k3s =="
|
|
||||||
ready="$(kubectl get nodes --no-headers 2>/dev/null | grep -c ' Ready ')"
|
|
||||||
check "nodes Ready" 2 "$ready"
|
|
||||||
lb="$(kubectl -n kube-system get svc traefik \
|
|
||||||
-o jsonpath='{.status.loadBalancer.ingress[*].ip}' 2>/dev/null | wc -w)"
|
|
||||||
check "traefik node IPs" 2 "$lb"
|
|
||||||
|
|
||||||
echo "== host nginx =="
|
|
||||||
check "service" active "$(systemctl is-active nginx)"
|
|
||||||
check "cert renew timer" active "$(systemctl is-active certbot-renew.timer)"
|
|
||||||
for ip in $NODE_IPS; do
|
|
||||||
check "traefik $ip" 404 "$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "http://${ip}/")"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "== public entry point =="
|
|
||||||
for h in $HOSTS; do
|
|
||||||
check "https://$h" 404 "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "https://${h}/")"
|
|
||||||
check "tls verify $h" 0 "$(curl -s -o /dev/null -w '%{ssl_verify_result}' --max-time 8 "https://${h}/")"
|
|
||||||
done
|
|
||||||
check "http redirect" 301 "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "http://${HOSTS%% *}/")"
|
|
||||||
|
|
||||||
echo
|
|
||||||
[ "$fail" -eq 0 ] && echo "lab is healthy" || echo "lab has failures"
|
|
||||||
exit "$fail"
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# Keycloak receives HTTP only from the trusted reverse proxy.
|
|
||||||
KC_HTTP_ENABLED=true
|
|
||||||
KC_PROXY_HEADERS=xforwarded
|
|
||||||
KC_HOSTNAME=https://auth.example.test
|
|
||||||
KC_HOSTNAME_STRICT=true
|
|
||||||
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 8080;
|
|
||||||
server_name auth.example.test;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://keycloak:8080;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
|
||||||
proxy_set_header X-Forwarded-Port 443;
|
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
auth.example.test {
|
|
||||||
tls /etc/tls/tls.crt /etc/tls/tls.key
|
|
||||||
|
|
||||||
reverse_proxy keycloak:8080 {
|
|
||||||
header_up Host {host}
|
|
||||||
header_up X-Forwarded-Host {host}
|
|
||||||
header_up X-Forwarded-Port 443
|
|
||||||
header_up X-Forwarded-Proto https
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
events {}
|
|
||||||
|
|
||||||
http {
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
server_name auth.example.test;
|
|
||||||
|
|
||||||
ssl_certificate /etc/tls/tls.crt;
|
|
||||||
ssl_certificate_key /etc/tls/tls.key;
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://keycloak:8080;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
|
||||||
proxy_set_header X-Forwarded-Port 443;
|
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
tunnel: 00000000-0000-0000-0000-000000000000
|
|
||||||
credentials-file: /etc/cloudflared/00000000-0000-0000-0000-000000000000.json
|
|
||||||
|
|
||||||
ingress:
|
|
||||||
- hostname: auth.example.test
|
|
||||||
service: http://reverse-proxy:8080
|
|
||||||
- service: http_status:404
|
|
||||||
+23
-2
@@ -38,10 +38,8 @@ services:
|
|||||||
TOKEN_MEDIATING_CLIENT_SECRET: ${TOKEN_MEDIATING_CLIENT_SECRET:?set TOKEN_MEDIATING_CLIENT_SECRET in .env}
|
TOKEN_MEDIATING_CLIENT_SECRET: ${TOKEN_MEDIATING_CLIENT_SECRET:?set TOKEN_MEDIATING_CLIENT_SECRET in .env}
|
||||||
BFF_CLIENT_SECRET: ${BFF_CLIENT_SECRET:?set BFF_CLIENT_SECRET in .env}
|
BFF_CLIENT_SECRET: ${BFF_CLIENT_SECRET:?set BFF_CLIENT_SECRET in .env}
|
||||||
EDGE_PROXY_CLIENT_SECRET: ${EDGE_PROXY_CLIENT_SECRET:?set EDGE_PROXY_CLIENT_SECRET in .env}
|
EDGE_PROXY_CLIENT_SECRET: ${EDGE_PROXY_CLIENT_SECRET:?set EDGE_PROXY_CLIENT_SECRET in .env}
|
||||||
MOCK_GOOGLE_BROKER_CLIENT_SECRET: ${MOCK_GOOGLE_BROKER_CLIENT_SECRET:?set MOCK_GOOGLE_BROKER_CLIENT_SECRET in .env}
|
|
||||||
ADMIN_USER_PASSWORD: ${ADMIN_USER_PASSWORD:?set ADMIN_USER_PASSWORD in .env}
|
ADMIN_USER_PASSWORD: ${ADMIN_USER_PASSWORD:?set ADMIN_USER_PASSWORD in .env}
|
||||||
REGULAR_USER_PASSWORD: ${REGULAR_USER_PASSWORD:?set REGULAR_USER_PASSWORD in .env}
|
REGULAR_USER_PASSWORD: ${REGULAR_USER_PASSWORD:?set REGULAR_USER_PASSWORD in .env}
|
||||||
MOCK_GOOGLE_USER_PASSWORD: ${MOCK_GOOGLE_USER_PASSWORD:?set MOCK_GOOGLE_USER_PASSWORD in .env}
|
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -88,6 +86,29 @@ services:
|
|||||||
- keycloak-net
|
- keycloak-net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
token-mediator:
|
||||||
|
build:
|
||||||
|
context: ./token-mediator
|
||||||
|
environment:
|
||||||
|
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
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- wget -q -O - http://127.0.0.1:8082/actuator/health | grep -q '"status":"UP"'
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
networks:
|
||||||
|
- keycloak-net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
# Federated account key: `sub`, not email
|
|
||||||
|
|
||||||
외부 IdP의 email은 표시·연락 속성이지 계정 식별자나 자동 연결 증명이 아니다.
|
|
||||||
Keycloak의 federated identity는 provider alias와 provider user ID(`sub`)를
|
|
||||||
로컬 사용자에 연결한다.
|
|
||||||
|
|
||||||
정책:
|
|
||||||
|
|
||||||
- 신규 identity의 email이 기존 로컬 계정과 충돌하면 기존 계정의 인증을 다시
|
|
||||||
요구하는 기본 First Broker Login flow를 사용한다.
|
|
||||||
- `Automatically Set Existing User`를 production flow에 넣지 않는다.
|
|
||||||
- upstream email 변경은 같은 `sub`의 계정 귀속을 바꾸지 않는다.
|
|
||||||
- 마지막 로그인 수단을 unlink하는 UI에서는 먼저 다른 인증 수단을 등록하도록
|
|
||||||
안내한다.
|
|
||||||
|
|
||||||
`verify-account-linking-sub-vs-email.sh`는 mock IdP 사용자의 email을 실제로
|
|
||||||
변경하고 다시 로그인한다. 로컬 사용자 ID가 유지되고 federated `userId`가
|
|
||||||
upstream `sub`와 같은지 확인한 후 원래 email을 복구한다.
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
수집 시각: 2026-09-03 15:01:30 KST
|
|
||||||
대상: https://app1.hyeonworks.com/api/echo
|
|
||||||
|
|
||||||
=== [1] 호스트 nginx 가 주입하는 헤더 ===
|
|
||||||
3: server 192.168.122.11:80;
|
|
||||||
4: server 192.168.122.12:80;
|
|
||||||
8: listen 80 default_server;
|
|
||||||
14: listen 443 ssl default_server;
|
|
||||||
26: proxy_set_header Host $host;
|
|
||||||
27: proxy_set_header X-Forwarded-Host $host;
|
|
||||||
28: proxy_set_header X-Forwarded-Proto http;
|
|
||||||
29: proxy_set_header X-Forwarded-Port 80;
|
|
||||||
30: proxy_set_header X-Forwarded-For $remote_addr;
|
|
||||||
31: proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
|
|
||||||
=== [2] Traefik entryPoint 인자 (forwardedHeaders 부재 확인) ===
|
|
||||||
["--entryPoints.metrics.address=:9100/tcp"
|
|
||||||
"--entryPoints.traefik.address=:8080/tcp"
|
|
||||||
"--entryPoints.web.address=:8000/tcp"
|
|
||||||
"--entryPoints.websecure.address=:8443/tcp"
|
|
||||||
"--metrics.prometheus.entrypoint=metrics"
|
|
||||||
"--entryPoints.websecure.http.tls=true"
|
|
||||||
→ forwardedHeaders.trustedIPs 인자가 없음 = 기본값(신뢰 안 함)
|
|
||||||
|
|
||||||
=== [3] Traefik 파드 수와 위치 ===
|
|
||||||
traefik-59b7647586-ftwf8 10.42.0.8 kc-lab-1
|
|
||||||
|
|
||||||
=== [4] traefik Service externalTrafficPolicy ===
|
|
||||||
Cluster
|
|
||||||
→ Cluster = svclb 가 SNAT 하여 클라이언트 IP 소실
|
|
||||||
|
|
||||||
=== [5] 앱 파드의 스위치 상태 ===
|
|
||||||
SERVER_PORT=8081
|
|
||||||
SERVER_FORWARD_HEADERS_STRATEGY=none
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
수집 시각: 2026-09-03 15:02:27 KST
|
|
||||||
|
|
||||||
=== [A] 정상 경로 — 브라우저와 같은 요청 ===
|
|
||||||
명령: curl -s https://app1.hyeonworks.com/api/echo
|
|
||||||
x-forwarded-proto http
|
|
||||||
x-forwarded-port 80
|
|
||||||
x-forwarded-for 10.42.0.1
|
|
||||||
x-forwarded-host app1.hyeonworks.com
|
|
||||||
x-real-ip 10.42.0.1
|
|
||||||
x-forwarded-server traefik-59b7647586-ftwf8
|
|
||||||
--- 앱이 해석한 값
|
|
||||||
scheme http
|
|
||||||
secure False
|
|
||||||
serverName app1.hyeonworks.com
|
|
||||||
serverPort 80
|
|
||||||
remoteAddr 10.42.0.8
|
|
||||||
localAddr 10.42.1.3
|
|
||||||
requestUrl http://app1.hyeonworks.com/api/echo
|
|
||||||
|
|
||||||
=== [B] 대조 실험 1 — nginx 우회, 헤더 없이 Traefik 직접 ===
|
|
||||||
명령: curl http://192.168.122.11/api/echo -H 'Host: app1.hyeonworks.com' (test-server 에서)
|
|
||||||
x-forwarded-proto http
|
|
||||||
x-forwarded-port 80
|
|
||||||
x-forwarded-for 10.42.0.1
|
|
||||||
x-forwarded-host app1.hyeonworks.com
|
|
||||||
x-real-ip 10.42.0.1
|
|
||||||
x-forwarded-server traefik-59b7647586-ftwf8
|
|
||||||
--- 앱이 해석한 값
|
|
||||||
scheme http
|
|
||||||
secure False
|
|
||||||
serverName app1.hyeonworks.com
|
|
||||||
serverPort 80
|
|
||||||
remoteAddr 10.42.0.8
|
|
||||||
localAddr 10.42.0.9
|
|
||||||
requestUrl http://app1.hyeonworks.com/api/echo
|
|
||||||
|
|
||||||
=== [C] 대조 실험 2 — nginx 우회, 올바른 헤더를 명시해서 ===
|
|
||||||
명령: 위와 동일 + -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Port: 443' -H 'X-Forwarded-For: 203.0.113.7'
|
|
||||||
x-forwarded-proto http
|
|
||||||
x-forwarded-port 80
|
|
||||||
x-forwarded-for 10.42.0.1
|
|
||||||
x-forwarded-host app1.hyeonworks.com
|
|
||||||
x-real-ip 10.42.0.1
|
|
||||||
x-forwarded-server traefik-59b7647586-ftwf8
|
|
||||||
--- 앱이 해석한 값
|
|
||||||
scheme http
|
|
||||||
secure False
|
|
||||||
serverName app1.hyeonworks.com
|
|
||||||
serverPort 80
|
|
||||||
remoteAddr 10.42.0.8
|
|
||||||
localAddr 10.42.1.3
|
|
||||||
requestUrl http://app1.hyeonworks.com/api/echo
|
|
||||||
|
|
||||||
★ [C] 에서 https/443/203.0.113.7 을 명시했음에도 http/80/10.42.0.1 이 도달했다.
|
|
||||||
→ Traefik 이 들어온 X-Forwarded-* 를 신뢰하지 않고 재작성한다는 독립적 증거.
|
|
||||||
|
|
||||||
=== [D] 위조 테스트 — 클라이언트가 직접 헤더 주입 ===
|
|
||||||
명령: curl https://app1.hyeonworks.com/api/echo -H 'X-Forwarded-Host: evil.example.com' -H 'X-Forwarded-For: 1.2.3.4'
|
|
||||||
x-forwarded-proto http
|
|
||||||
x-forwarded-port 80
|
|
||||||
x-forwarded-for 10.42.1.0
|
|
||||||
x-forwarded-host app1.hyeonworks.com
|
|
||||||
x-real-ip 10.42.1.0
|
|
||||||
x-forwarded-server traefik-59b7647586-ftwf8
|
|
||||||
--- 앱이 해석한 값
|
|
||||||
scheme http
|
|
||||||
secure False
|
|
||||||
serverName app1.hyeonworks.com
|
|
||||||
serverPort 80
|
|
||||||
remoteAddr 10.42.0.8
|
|
||||||
localAddr 10.42.0.9
|
|
||||||
requestUrl http://app1.hyeonworks.com/api/echo
|
|
||||||
|
|
||||||
★ evil.example.com 과 1.2.3.4 가 도달하지 않았다 = 신뢰 경계는 작동.
|
|
||||||
|
|
||||||
=== [E] 파드 분배 8회 ===
|
|
||||||
pod 10.42.1.3 | traefik traefik-59b7647586-ftwf8
|
|
||||||
pod 10.42.0.9 | traefik traefik-59b7647586-ftwf8
|
|
||||||
pod 10.42.1.3 | traefik traefik-59b7647586-ftwf8
|
|
||||||
pod 10.42.0.9 | traefik traefik-59b7647586-ftwf8
|
|
||||||
pod 10.42.1.3 | traefik traefik-59b7647586-ftwf8
|
|
||||||
pod 10.42.0.9 | traefik traefik-59b7647586-ftwf8
|
|
||||||
pod 10.42.1.3 | traefik traefik-59b7647586-ftwf8
|
|
||||||
pod 10.42.0.9 | traefik traefik-59b7647586-ftwf8
|
|
||||||
|
|
||||||
=== [F] HTTP → HTTPS 리다이렉트 ===
|
|
||||||
status=301 location=https://app1.hyeonworks.com/api/echo
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
수집 시각: 2026-09-03 15:32:04 KST
|
|
||||||
단계: A(nginx) + B(Traefik) + C(앱) 모두 적용 후
|
|
||||||
|
|
||||||
=== [1] nginx 가 보내는 값 ===
|
|
||||||
28: proxy_set_header X-Forwarded-Proto https;
|
|
||||||
29: proxy_set_header X-Forwarded-Port 443;
|
|
||||||
30: proxy_set_header X-Forwarded-For $remote_addr;
|
|
||||||
31: proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
|
|
||||||
=== [2] Traefik entryPoint 인자 ===
|
|
||||||
"--entryPoints.web.forwardedHeaders.trustedIPs=10.42.0.0/16
|
|
||||||
"--entryPoints.websecure.forwardedHeaders.trustedIPs=10.42.0.0/16
|
|
||||||
|
|
||||||
=== [3] 앱 스위치 ===
|
|
||||||
SERVER_FORWARD_HEADERS_STRATEGY=native
|
|
||||||
|
|
||||||
=== [4] 최종 측정 ===
|
|
||||||
x-forwarded-proto https
|
|
||||||
x-forwarded-port 443
|
|
||||||
x-forwarded-host app1.hyeonworks.com
|
|
||||||
x-real-ip 100.123.124.30
|
|
||||||
x-forwarded-server traefik-697889c85-g7xpp
|
|
||||||
--- 앱이 해석한 값
|
|
||||||
scheme https
|
|
||||||
secure True
|
|
||||||
serverName app1.hyeonworks.com
|
|
||||||
serverPort 443
|
|
||||||
remoteAddr 100.123.124.30
|
|
||||||
localAddr 10.42.0.10
|
|
||||||
requestUrl https://app1.hyeonworks.com/api/echo
|
|
||||||
|
|
||||||
=== [5] 위조 테스트 — 클라이언트가 http/evil/1.2.3.4 를 주입 ===
|
|
||||||
x-forwarded-proto https
|
|
||||||
x-forwarded-port 443
|
|
||||||
x-forwarded-host app1.hyeonworks.com
|
|
||||||
x-real-ip 100.123.124.30
|
|
||||||
x-forwarded-server traefik-697889c85-g7xpp
|
|
||||||
--- 앱이 해석한 값
|
|
||||||
scheme https
|
|
||||||
secure True
|
|
||||||
serverName app1.hyeonworks.com
|
|
||||||
serverPort 443
|
|
||||||
remoteAddr 100.123.124.30
|
|
||||||
localAddr 10.42.1.6
|
|
||||||
requestUrl https://app1.hyeonworks.com/api/echo
|
|
||||||
|
|
||||||
★ 주입값이 하나도 반영되지 않았다. nginx 의 $remote_addr 덮어쓰기가 방어한다.
|
|
||||||
|
|
||||||
=== [6] 파드 분배 6회 ===
|
|
||||||
pod 10.42.0.10 | remoteAddr 100.123.124.30 | scheme https
|
|
||||||
pod 10.42.1.6 | remoteAddr 100.123.124.30 | scheme https
|
|
||||||
pod 10.42.0.10 | remoteAddr 100.123.124.30 | scheme https
|
|
||||||
pod 10.42.1.6 | remoteAddr 100.123.124.30 | scheme https
|
|
||||||
pod 10.42.0.10 | remoteAddr 100.123.124.30 | scheme https
|
|
||||||
pod 10.42.1.6 | remoteAddr 100.123.124.30 | scheme https
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
수집 시각: 2026-09-03 16:16:48 KST
|
|
||||||
주제: 프록시 우회 경로 차단 (NetworkPolicy)
|
|
||||||
|
|
||||||
=== [1] 차단 전 — 클러스터 안에서 앱에 직접 요청 ===
|
|
||||||
명령: kubectl run ... -- curl http://echo:8081/api/echo \
|
|
||||||
-H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: evil.example.com' -H 'X-Forwarded-For: 1.2.3.4'
|
|
||||||
|
|
||||||
scheme https
|
|
||||||
secure True
|
|
||||||
serverName evil.example.com ← 위조 성공
|
|
||||||
remoteAddr 1.2.3.4 ← 위조 성공
|
|
||||||
requestUrl https://evil.example.com/api/echo
|
|
||||||
|
|
||||||
★ Traefik 을 거치지 않으면 헤더 위조가 그대로 통한다.
|
|
||||||
trustedIPs 와 internalProxies 가 둘 다 '대역'을 믿기 때문.
|
|
||||||
|
|
||||||
=== [2] 적용한 것 ===
|
|
||||||
deploy/lab/k8s/traefik-forwarded-headers.yaml — 192.168.122.0/24 제거
|
|
||||||
"--entryPoints.web.forwardedHeaders.trustedIPs=10.42.0.0/16"
|
|
||||||
"--entryPoints.websecure.forwardedHeaders.trustedIPs=10.42.0.0/16"
|
|
||||||
|
|
||||||
deploy/lab/k8s/echo-network-policy.yaml — Traefik 파드에서만 8081 허용
|
|
||||||
[{"from":[{"namespaceSelector":{"matchLabels":{"kubernetes.io/metadata.name":"kube-system"}},"podSelector":{"matchLabels":{"app.kubernetes.io/name":"traefik"}}}],"ports":[{"port":8081,"protocol":"TCP"}]},{"from":[{"ipBlock":{"cidr":"10.42.0.1/32"}},{"ipBlock":{"cidr":"10.42.1.1/32"}}],"ports":[{"port":8081,"protocol":"TCP"}]}]
|
|
||||||
|
|
||||||
=== [3] 차단 후 — 정상 경로 (계속 동작해야 함) ===
|
|
||||||
x-forwarded-proto https
|
|
||||||
x-forwarded-port 443
|
|
||||||
x-forwarded-host app1.hyeonworks.com
|
|
||||||
x-real-ip 100.123.124.30
|
|
||||||
x-forwarded-server traefik-5d6fcf895-wpfhr
|
|
||||||
--- 앱이 해석한 값
|
|
||||||
scheme https
|
|
||||||
secure True
|
|
||||||
serverName app1.hyeonworks.com
|
|
||||||
serverPort 443
|
|
||||||
remoteAddr 100.123.124.30
|
|
||||||
localAddr 10.42.0.14
|
|
||||||
requestUrl https://app1.hyeonworks.com/api/echo
|
|
||||||
|
|
||||||
=== [4] 차단 후 — 우회 시도 ===
|
|
||||||
HTTP 000 / curl exit 7
|
|
||||||
HTTP 000 / curl exit 7
|
|
||||||
|
|
||||||
★ curl exit 7 = Failed to connect. 연결 자체가 성립하지 않는다.
|
|
||||||
|
|
||||||
=== [5] 파드 건강 상태 (probe 가 차단되지 않았는지) ===
|
|
||||||
echo-54dbd94986-8jmdb 1/1 Running restarts=0
|
|
||||||
echo-54dbd94986-lfltk 1/1 Running restarts=0
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
# 증거 — 2홉 프록시 헤더 계약 (수정 전 상태)
|
|
||||||
|
|
||||||
`docs/two-hop-proxy-header-contract.md`의 진단을 뒷받침하는 원자료.
|
|
||||||
**모두 수정 전 상태에서 수집**했으며, 수정 후 재수집하여 대조한다.
|
|
||||||
|
|
||||||
수집 시각: 2026-09-03 15:01~15:03 KST
|
|
||||||
|
|
||||||
| 파일 | 내용 |
|
|
||||||
|---|---|
|
|
||||||
| `01-environment.txt` | 수정 전 세 계층의 설정 스냅샷 |
|
|
||||||
| `02-measurements.txt` | 수정 전 측정 · 대조 실험 · 위조 테스트 · 분배 |
|
|
||||||
| `stage-a-nginx-fixed.png` | A 단계 브라우저 화면 |
|
|
||||||
| `stage-b-traefik-trusts.png` | B 단계 브라우저 화면 |
|
|
||||||
| `stage-c-resolved.png` | C 단계 브라우저 화면 |
|
|
||||||
| `04-after-fix.txt` | 수정 후 측정 · 위조 테스트 · 분배 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 확인된 문제는 둘이다
|
|
||||||
|
|
||||||
최초 진단은 "Traefik이 덮어쓴다" 하나였으나, 증거 수집 과정에서
|
|
||||||
**독립된 원인이 두 개**임이 드러났다.
|
|
||||||
|
|
||||||
### 문제 1 — nginx가 애초에 틀린 값을 보낸다
|
|
||||||
|
|
||||||
`01-environment.txt`
|
|
||||||
|
|
||||||
```
|
|
||||||
26: proxy_set_header Host $host;
|
|
||||||
27: proxy_set_header X-Forwarded-Host $host;
|
|
||||||
28: proxy_set_header X-Forwarded-Proto http; ← https 여야 한다
|
|
||||||
29: proxy_set_header X-Forwarded-Port 80; ← 443 이어야 한다
|
|
||||||
30: proxy_set_header X-Forwarded-For $remote_addr;
|
|
||||||
31: proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
```
|
|
||||||
|
|
||||||
`listen 443 ssl` 서버 블록 안인데 `X-Forwarded-Proto`가 `http`다.
|
|
||||||
TLS를 종료하는 서버가 "원래 요청은 평문이었다"고 알리고 있다.
|
|
||||||
|
|
||||||
HTTP 전용으로 먼저 세운 뒤 TLS를 얹는 과정에서 **이 두 줄을 함께 바꾸지
|
|
||||||
않아 남은 값**이다. 설정 자체는 문법 오류가 없으므로 `nginx -t`도 통과하고,
|
|
||||||
**아무 경고 없이 잘못된 값이 전파된다.**
|
|
||||||
|
|
||||||
### 문제 2 — Traefik이 올바른 값이 와도 덮어쓴다
|
|
||||||
|
|
||||||
`02-measurements.txt`의 **대조 실험 [C]** 가 이를 독립적으로 증명한다.
|
|
||||||
nginx를 우회해 Traefik에 직접 요청하면서 올바른 헤더를 명시했다.
|
|
||||||
|
|
||||||
```
|
|
||||||
보낸 것 : X-Forwarded-Proto: https
|
|
||||||
X-Forwarded-Port: 443
|
|
||||||
X-Forwarded-For: 203.0.113.7
|
|
||||||
|
|
||||||
도달한 것: x-forwarded-proto http
|
|
||||||
x-forwarded-port 80
|
|
||||||
x-forwarded-for 10.42.0.1
|
|
||||||
```
|
|
||||||
|
|
||||||
**세 값 모두 재작성됐다.** Traefik entryPoint에
|
|
||||||
`forwardedHeaders.trustedIPs`가 설정되지 않아 들어온 헤더를 신뢰하지 않는다.
|
|
||||||
|
|
||||||
`01-environment.txt`의 Traefik 인자 목록에 `forwardedHeaders` 관련 항목이
|
|
||||||
하나도 없는 것이 그 근거다.
|
|
||||||
|
|
||||||
**문제 1만 고쳐서는 해결되지 않는다.** 두 원인이 직렬로 걸려 있다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 브라우저 증거
|
|
||||||
|
|
||||||
스크린샷은 모두 **브라우저가 `/api/echo` 응답을 렌더링한 실제 화면**이다.
|
|
||||||
앱이 정렬된 JSON을 내보내도록 `spring.jackson.serialization.indent-output`을
|
|
||||||
켜두었으므로 브라우저의 JSON 뷰어 설정과 무관하게 동일하게 읽힌다.
|
|
||||||
|
|
||||||
세 장은 **같은 요청을 세 가지 설정 상태에서** 찍은 것이다.
|
|
||||||
|
|
||||||
| 파일 | 켜진 스위치 | 화면에서 확인할 것 |
|
|
||||||
|---|---|---|
|
|
||||||
| `stage-a-nginx-fixed.png` | nginx 만 | `x-forwarded-proto: http` — Traefik 이 덮어씀 |
|
|
||||||
| `stage-b-traefik-trusts.png` | nginx + Traefik | **헤더는 `https`인데 `scheme: http`** |
|
|
||||||
| `stage-c-resolved.png` | 셋 다 | `scheme: https`, `secure: true` |
|
|
||||||
|
|
||||||
**`stage-b`가 가장 중요한 한 장이다.** `x-forwarded-proto: https`가 앱에
|
|
||||||
도착해 있는데도 `scheme: http`, `secure: false`, `requestUrl: http://...`다.
|
|
||||||
**헤더가 도착하는 것과 앱이 그것을 읽는 것은 다른 문제**임을 한 화면이
|
|
||||||
보여준다.
|
|
||||||
|
|
||||||
## 정상으로 확인된 것
|
|
||||||
|
|
||||||
증거 수집에서 **문제가 아니라고 확인된 항목**도 함께 남긴다.
|
|
||||||
|
|
||||||
| 항목 | 결과 |
|
|
||||||
|---|---|
|
|
||||||
| TLS 종료 | 정상. 실인증서, `isSecureContext=true` |
|
|
||||||
| `X-Forwarded-Host` | 유지됨 — Traefik이 이것만은 덮어쓰지 않는다 |
|
|
||||||
| 위조 차단 | 클라이언트가 넣은 `evil.example.com`, `1.2.3.4`가 앱에 도달하지 않음 |
|
|
||||||
| 파드 분배 | 8회 요청이 두 파드에 정확히 번갈아 도달 |
|
|
||||||
| HTTP 리다이렉트 | `301 → https://app1.hyeonworks.com/api/echo` |
|
|
||||||
|
|
||||||
**위조가 차단되는 것은 nginx가 막아서가 아니라 Traefik이 전부 덮어쓰기
|
|
||||||
때문**이다. 문제 2를 고치면 이 방어가 nginx의 `$remote_addr` 덮어쓰기로
|
|
||||||
옮겨간다. 수정 후 재측정에서 **위조가 여전히 막히는지 반드시 확인**해야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 재수집 방법
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 터미널 증거
|
|
||||||
./deploy/lab/scripts/measure-proxy-headers.sh
|
|
||||||
|
|
||||||
# 개별 확인
|
|
||||||
curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool
|
|
||||||
|
|
||||||
# 대조 실험 (test-server 에서, nginx 우회)
|
|
||||||
curl -s http://192.168.122.11/api/echo \
|
|
||||||
-H 'Host: app1.hyeonworks.com' \
|
|
||||||
-H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Port: 443' \
|
|
||||||
-H 'X-Forwarded-For: 203.0.113.7' | python3 -m json.tool
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 수정 후 (2026-09-03 15:32 KST)
|
|
||||||
|
|
||||||
세 스위치를 순서대로 켜며 각 단계를 측정했다. 상세 절차는
|
|
||||||
`docs/two-hop-proxy-header-contract.md` 9~11절.
|
|
||||||
|
|
||||||
| 파일 | 단계 |
|
|
||||||
|---|---|
|
|
||||||
| `stage-a-nginx-fixed.png` | A — nginx 만 고침 |
|
|
||||||
| `stage-b-traefik-trusts.png` | B — Traefik `trustedIPs` 추가 |
|
|
||||||
| `stage-c-resolved.png` | C — 앱 `strategy=native` |
|
|
||||||
| `04-after-fix.txt` | 최종 측정 · 위조 테스트 · 분배 |
|
|
||||||
|
|
||||||
스크린샷은 브라우저가 `/api/echo` 응답을 렌더링한 **실제 화면**이다.
|
|
||||||
|
|
||||||
### 단계별 결과
|
|
||||||
|
|
||||||
| 항목 | 최초 | A | B | C |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `x-forwarded-proto` | `http` | **`http`** | `https` | `https` |
|
|
||||||
| `x-real-ip` | `10.42.1.0` | `10.42.1.0` | `100.123.124.30` | `100.123.124.30` |
|
|
||||||
| `scheme` (앱 해석) | `http` | `http` | **`http`** | **`https`** |
|
|
||||||
| `requestUrl` | `http://…` | `http://…` | `http://…` | **`https://…`** |
|
|
||||||
|
|
||||||
**A 이후 아무 변화가 없는 것**이 Traefik 덮어쓰기의 증거이고,
|
|
||||||
**B 이후 헤더는 살아났으나 앱 해석은 그대로인 것**이 2번과 3번 스위치가
|
|
||||||
다른 일을 한다는 증거다.
|
|
||||||
|
|
||||||
### 위조 차단 재확인
|
|
||||||
|
|
||||||
`04-after-fix.txt` [5]. 클라이언트가 `X-Forwarded-Proto: http`,
|
|
||||||
`X-Forwarded-Host: evil.example.com`, `X-Forwarded-For: 1.2.3.4`를 주입했으나
|
|
||||||
**하나도 반영되지 않았다.**
|
|
||||||
|
|
||||||
**방어 주체가 바뀌었다.** 수정 전에는 Traefik이 전부 덮어써서 막았고,
|
|
||||||
수정 후에는 nginx의 `$remote_addr`가 막는다. 그래서 nginx에서
|
|
||||||
`$proxy_add_x_forwarded_for`(덧붙이기)로 바꾸면 안 된다.
|
|
||||||
|
|
||||||
### 겪은 함정
|
|
||||||
|
|
||||||
`kubectl rollout status`가 완료를 알려도 **helm-controller의 Job이 차트를
|
|
||||||
업그레이드하는 동안 구 Traefik 파드가 함께 살아 있다.** 이 시점에 측정하면
|
|
||||||
옛 파드가 응답해 "고쳤는데 안 바뀌었다"고 오해하게 된다. `x-forwarded-server`
|
|
||||||
값의 파드 이름으로 어느 파드가 응답했는지 확인해야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 프록시 우회 차단 (2026-09-03 16:16 KST)
|
|
||||||
|
|
||||||
`05-networkpolicy.txt`
|
|
||||||
|
|
||||||
헤더 신뢰를 켠 뒤 남아 있던 구멍을 실증하고 막았다.
|
|
||||||
|
|
||||||
**차단 전** — 클러스터 안에서 Traefik을 우회해 앱에 직접 요청하면
|
|
||||||
`serverName: evil.example.com`, `remoteAddr: 1.2.3.4`로 **위조가 성립했다.**
|
|
||||||
|
|
||||||
**적용한 것**
|
|
||||||
|
|
||||||
| 파일 | 변경 |
|
|
||||||
|---|---|
|
|
||||||
| `traefik-forwarded-headers.yaml` | `192.168.122.0/24` 제거 (SNAT 때문에 도달 불가한 대역) |
|
|
||||||
| `echo-network-policy.yaml` | Traefik 파드에서만 8081 허용 (라벨 기준) |
|
|
||||||
|
|
||||||
**차단 후**
|
|
||||||
|
|
||||||
```
|
|
||||||
정상 경로 scheme=https, remoteAddr=100.123.124.30 동작
|
|
||||||
우회 시도 HTTP 000 / curl exit 7 연결 거부
|
|
||||||
파드 상태 1/1 Running, restarts=0 probe 정상
|
|
||||||
```
|
|
||||||
|
|
||||||
`exit 7`은 curl의 "Failed to connect"다. HTTP 403이 아니라
|
|
||||||
**TCP 연결 자체가 성립하지 않았다**는 뜻이다.
|
|
||||||
|
|
||||||
`restarts=0`이 중요하다. NetworkPolicy에서 kubelet probe 경로를 빠뜨리면
|
|
||||||
probe가 실패해 파드가 재시작 루프에 빠진다. 노드의 cni0 주소
|
|
||||||
(`10.42.0.1`, `10.42.1.1`)를 `/32`로 허용해 이를 피했다.
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 91 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 93 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 90 KiB |
@@ -1,27 +0,0 @@
|
|||||||
# First Broker Login security
|
|
||||||
|
|
||||||
Keycloak 26.7.0's built-in `first broker login` flow does **not** silently
|
|
||||||
auto-link by email. It contains:
|
|
||||||
|
|
||||||
- `Create User If Unique`
|
|
||||||
- `Handle Existing Account`
|
|
||||||
- `Confirm link existing account`
|
|
||||||
- email verification or re-authentication ownership proof
|
|
||||||
|
|
||||||
`Automatically set existing user` is an explicit, dangerous opt-in. The local
|
|
||||||
acceptance harness copies the built-in flow, enables AutoLink, disables the
|
|
||||||
ownership-proof branch, and signs in through a controllable OIDC account whose
|
|
||||||
email collides with `regular-user`. It verifies that the external identity is
|
|
||||||
attached without proof. The harness then assigns the original built-in flow,
|
|
||||||
repeats the login, observes the existing-account confirmation page, and verifies
|
|
||||||
that no federated identity was attached.
|
|
||||||
|
|
||||||
Run after the stack is healthy:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/verify-first-broker-login.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The vulnerable flow remains only as a disabled learning artifact. The
|
|
||||||
`mock-google` provider is always returned to the secure built-in flow at the end
|
|
||||||
of the verification.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# Four Keycloak integration patterns
|
|
||||||
|
|
||||||
| 축 | AP1 SPA direct | AP2 token mediator | AP3 BFF | AP4 edge auth |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| OAuth client | public | confidential | confidential | confidential proxy |
|
|
||||||
| browser 보유물 | access/refresh token | 짧은 handoff code 또는 app token | HttpOnly session cookie | proxy session cookie |
|
|
||||||
| OAuth code 교환 | browser + PKCE | mediator backend | BFF | oauth2-proxy |
|
|
||||||
| API bearer 검증 | Spring resource server | mediator/downstream API | BFF 내부 또는 downstream | edge가 인증 후 trusted header |
|
|
||||||
| server session | 없음 | handoff 상태만 짧게 | 필수 | proxy cookie/session |
|
|
||||||
| XSS token 탈취면 | 가장 큼 | 축소 | browser token 제거 | browser token 제거 |
|
|
||||||
| CSRF 주의 | token endpoint/refresh 설계 | app cookie 사용 시 | 필수 방어 | proxy cookie 사용 시 |
|
|
||||||
| 수평 확장 상태 | 단순 | handoff store 공유 가능 | session store 필요 | proxy 설정에 따름 |
|
|
||||||
| 주 학습 포인트 | PKCE/JWT/RS | token 경계·one-time handoff | oauth2Login/session/CSRF | auth_request/header trust |
|
|
||||||
|
|
||||||
## 선택 기준
|
|
||||||
|
|
||||||
- 브라우저에서 OAuth와 token 수명주기를 직접 학습하려면 AP1.
|
|
||||||
- 브라우저에 upstream token을 주지 않되 API 호출은 bearer 중심으로 유지하려면
|
|
||||||
AP2.
|
|
||||||
- token을 browser에서 완전히 제거하고 애플리케이션 단위 인가·세션을
|
|
||||||
중앙화하려면 AP3.
|
|
||||||
- 기존 upstream을 수정하기 어렵고 경계에서 일괄 인증하려면 AP4.
|
|
||||||
|
|
||||||
Google federation은 다섯 번째 인증 패턴이 아니다. 네 패턴 모두 최종적으로
|
|
||||||
Keycloak token/session을 소비하며, Google은 Keycloak 앞의 upstream IdP
|
|
||||||
hop으로 추가된다.
|
|
||||||
|
|
||||||
## 이 repository의 실행 증거
|
|
||||||
|
|
||||||
- AP1: PKCE SPA, issuer/audience, token storage, refresh/logout 검증
|
|
||||||
- AP2: confidential client와 one-time access handoff 검증
|
|
||||||
- AP3: `oauth2Login` session과 CSRF/SameSite 검증
|
|
||||||
- AP4: oauth2-proxy, nginx `auth_request`, spoofed header 제거 검증
|
|
||||||
- 공통: local mock Google brokering, First Broker Login, claim/role mapping 검증
|
|
||||||
|
|
||||||
각 근거 브랜치와 병합 여부는 `keycloak-branch-manifest.tsv` 및
|
|
||||||
`audit-keycloak-branches.sh`로 추적한다.
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Google claim and identity mapping
|
|
||||||
|
|
||||||
The broker uses the upstream OIDC `sub` as the stable federated identity key.
|
|
||||||
Email is a mutable profile attribute and is never the external identity key.
|
|
||||||
|
|
||||||
The default mapping policy is:
|
|
||||||
|
|
||||||
| Upstream claim | Keycloak target |
|
|
||||||
|---|---|
|
|
||||||
| `sub` | stable username `${ALIAS}.${CLAIM.sub}` and federated identity ID |
|
|
||||||
| `email` | email |
|
|
||||||
| `given_name` | first name |
|
|
||||||
| `family_name` | last name |
|
|
||||||
| `picture` | custom `picture` attribute |
|
|
||||||
| `hd` | custom `hd` attribute |
|
|
||||||
|
|
||||||
The Identity Provider uses `syncMode=IMPORT`: profile values are imported on
|
|
||||||
first login and later local edits are not overwritten on every login. `FORCE`
|
|
||||||
is an explicit alternative when upstream freshness is more important.
|
|
||||||
|
|
||||||
`./scripts/verify-google-claim-mapping.sh` signs in through the controllable
|
|
||||||
OIDC realm and verifies the resulting Keycloak user, custom attributes, stable
|
|
||||||
subject-derived username, and federated identity record.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Google claim-to-role mapping
|
|
||||||
|
|
||||||
`hd=example.test`인 upstream OIDC identity에는 Keycloak realm role
|
|
||||||
`employee-role`을 부여한다. 매핑 키는 email이 아니라 Google subject이며,
|
|
||||||
role 조건에 쓰는 `hd` claim은 mock provider와 실제 Google provider에서 같은
|
|
||||||
계약을 사용한다.
|
|
||||||
|
|
||||||
Realm import는 `oidc-role-idp-mapper`를 선언한다. 실제 Google 설정 스크립트도
|
|
||||||
같은 mapper를 upsert한다. 따라서 재실행해도 mapper가 중복되지 않는다.
|
|
||||||
|
|
||||||
검증:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
./scripts/verify-google-claim-to-role.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
검증기는 mock Google 로그인, Authorization Code + PKCE 교환, 최종 Keycloak
|
|
||||||
access token의 `realm_access.roles`를 차례로 확인한다.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Google IdP brokering
|
|
||||||
|
|
||||||
Keycloak is the only issuer trusted by AP1–AP4. Google is an upstream Identity
|
|
||||||
Provider; applications do not receive or validate a Google token.
|
|
||||||
|
|
||||||
## Two verification profiles
|
|
||||||
|
|
||||||
The default local profile imports a second Keycloak realm named `mock-google`.
|
|
||||||
It acts as a controllable OIDC provider and allows tests to choose claims such
|
|
||||||
as a duplicate email, `email_verified=false`, `hd`, and `picture`. This is the
|
|
||||||
safe way to reproduce an unsafe email auto-link without impersonating a real
|
|
||||||
Google account.
|
|
||||||
|
|
||||||
The real-Google profile is configured explicitly:
|
|
||||||
|
|
||||||
1. Create a Google OAuth **Web application**.
|
|
||||||
2. Register the exact redirect URI printed by
|
|
||||||
`./scripts/configure-google-idp.sh`.
|
|
||||||
3. Put `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in ignored `.env`.
|
|
||||||
4. Start the stack and run the configuration script.
|
|
||||||
|
|
||||||
The script writes `providerId=google`, `trustEmail=false`, minimal
|
|
||||||
`openid profile email` scopes, and `syncMode=IMPORT` through the Keycloak Admin
|
|
||||||
API. Credentials are never written to the realm export or repository.
|
|
||||||
|
|
||||||
Google requires a public HTTPS redirect for non-local deployments. Local mock
|
|
||||||
verification proves the Keycloak brokering boundary; a real Google login is a
|
|
||||||
separate credentialed acceptance profile.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Google redirect URI policy
|
|
||||||
|
|
||||||
Google에 등록하는 redirect URI는 애플리케이션 SPA callback이 아니라 Keycloak
|
|
||||||
broker endpoint다.
|
|
||||||
|
|
||||||
```text
|
|
||||||
https://auth.example.test/realms/keycloak-patterns/broker/google/endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
규칙:
|
|
||||||
|
|
||||||
- production URI는 HTTPS와 고정된 public Keycloak origin을 사용한다.
|
|
||||||
- wildcard, path prefix, 임시 tunnel hostname을 production OAuth client에
|
|
||||||
등록하지 않는다.
|
|
||||||
- 개발·스테이징·운영은 Google OAuth client를 분리한다.
|
|
||||||
- reverse proxy가 있더라도 Google이 보는 URI와 Keycloak이 생성하는 URI가
|
|
||||||
byte-for-byte 같아야 한다.
|
|
||||||
- `configure-google-idp.sh`가 출력하는 URI를 Google Console의 Authorized
|
|
||||||
redirect URI와 대조한다.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
PUBLIC_KEYCLOAK_URL=https://auth.example.test \
|
|
||||||
./scripts/verify-google-redirect-uri-policy.sh
|
|
||||||
```
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# HTTPS termination: nginx or Caddy
|
|
||||||
|
|
||||||
두 예제 모두 public `443`에서 TLS를 종료하고 private Docker network의
|
|
||||||
`keycloak:8080`으로 전달한다. Keycloak 쪽 설정은
|
|
||||||
`deploy/reverse-proxy/keycloak.env.example`의 hostname/proxy contract를
|
|
||||||
같이 사용한다.
|
|
||||||
|
|
||||||
- nginx: 인증서 배포·갱신을 운영자가 담당할 때 적합하다.
|
|
||||||
- Caddy: ACME를 통한 인증서 수명주기를 proxy가 담당하게 할 때 간단하다.
|
|
||||||
- 둘을 동시에 production entry point로 띄우지 않는다.
|
|
||||||
- 인증서와 private key는 repository 또는 image에 포함하지 않는다.
|
|
||||||
- HTTP challenge/redirect 및 방화벽의 80/443 허용은 배포 환경에서 별도로
|
|
||||||
결정한다.
|
|
||||||
|
|
||||||
검증 스크립트는 임시 자체 서명 인증서를 만들고 두 vendor image에서 설정을
|
|
||||||
각각 validate한 뒤 임시 파일을 제거한다.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
./scripts/verify-https-termination-config.sh
|
|
||||||
```
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# Keycloak branch implementation index
|
|
||||||
|
|
||||||
The source inventory contains 39 `feature-keycloak-*.md` branch notes. This
|
|
||||||
repository preserves one local Git feature branch for every note and merges it
|
|
||||||
with `--no-ff` into either the common `develop` baseline or one of the four
|
|
||||||
authentication-pattern branches.
|
|
||||||
|
|
||||||
| Target | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| `common` | Shared realm, federation, deployment, or governance contract. Merge into `develop`, then propagate to AP1–AP4. |
|
|
||||||
| `ap1` | Browser-based OAuth client: vanilla SPA, Authorization Code + PKCE, Resource Server. |
|
|
||||||
| `ap2` | Token-mediating confidential backend: browser receives access token only. |
|
|
||||||
| `ap3` | BFF: backend owns every OAuth token and browser owns only a session cookie. |
|
|
||||||
| `ap4` | Edge forward-auth: oauth2-proxy/Nginx owns login and backend trusts an isolated identity header. |
|
|
||||||
|
|
||||||
The machine-readable registry is
|
|
||||||
[`keycloak-branch-manifest.tsv`](keycloak-branch-manifest.tsv). Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/audit-keycloak-branches.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The audit succeeds only when all 39 note names have matching local feature
|
|
||||||
branches and each feature tip is reachable from its declared target branch.
|
|
||||||
|
|
||||||
Google credentials are never committed. The default local acceptance harness
|
|
||||||
uses a second Keycloak realm as a controllable OIDC provider so claim mapping
|
|
||||||
and unsafe-linking failure paths can be reproduced. A real Google login remains
|
|
||||||
an explicit credentialed/public-HTTPS verification profile.
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
branch target delivery
|
|
||||||
feature/keycloak-account-linking-spa-ux ap1 documented-and-contract-tested
|
|
||||||
feature/keycloak-account-linking-sub-vs-email common documented-and-contract-tested
|
|
||||||
feature/keycloak-bff-csrf-samesite-defense ap3 locally-verified
|
|
||||||
feature/keycloak-bff-oauth2login-session ap3 locally-verified
|
|
||||||
feature/keycloak-bff-vs-spa-direct ap3 documented
|
|
||||||
feature/keycloak-docker-compose-stack common locally-verified
|
|
||||||
feature/keycloak-edge-forwardauth-google-federation ap4 documented-and-config-tested
|
|
||||||
feature/keycloak-edge-forwardauth-no-google ap4 documented-and-config-tested
|
|
||||||
feature/keycloak-federation-spa-zero-change ap1 contract-tested
|
|
||||||
feature/keycloak-first-broker-login-flow common locally-verified-with-mock-idp
|
|
||||||
feature/keycloak-four-pattern-tradeoff-matrix common documented-and-evidence-linked
|
|
||||||
feature/keycloak-google-claim-attribute-mapping common locally-verified-with-mock-idp
|
|
||||||
feature/keycloak-google-redirect-uri-policy common config-tested
|
|
||||||
feature/keycloak-header-spoofing-defense ap4 locally-verified
|
|
||||||
feature/keycloak-https-termination-caddy-nginx common config-tested
|
|
||||||
feature/keycloak-idp-brokering-google-client common locally-verified-with-mock-idp
|
|
||||||
feature/keycloak-idp-mappers-claim-to-role common locally-verified-with-mock-idp
|
|
||||||
feature/keycloak-internal-spa-direct-google-federation ap1 documented-and-contract-tested
|
|
||||||
feature/keycloak-internal-spa-direct-no-google ap1 documented-and-contract-tested
|
|
||||||
feature/keycloak-iss-claim-hostname-mismatch ap1 locally-verified
|
|
||||||
feature/keycloak-nginx-auth-request-integration ap4 locally-verified
|
|
||||||
feature/keycloak-oauth2-proxy-oidc-flow ap4 locally-verified
|
|
||||||
feature/keycloak-patterns common governance
|
|
||||||
feature/keycloak-pkce-flow-stages ap1 contract-tested
|
|
||||||
feature/keycloak-public-domain-tunneling common config-tested
|
|
||||||
feature/keycloak-realm-client-export common locally-verified
|
|
||||||
feature/keycloak-refresh-rotation-and-logout ap1 locally-verified
|
|
||||||
feature/keycloak-refresh-token-rotation ap1 contract-tested
|
|
||||||
feature/keycloak-reverse-proxy-headers common config-tested
|
|
||||||
feature/keycloak-single-ec2-google-federation ap1 documented-and-config-tested
|
|
||||||
feature/keycloak-single-ec2-no-google ap1 documented-and-contract-tested
|
|
||||||
feature/keycloak-spa-token-storage-tradeoff ap1 locally-verified
|
|
||||||
feature/keycloak-spring-rs-audience-validator ap1 locally-verified
|
|
||||||
feature/keycloak-spring-rs-role-mapping ap1 locally-verified
|
|
||||||
feature/keycloak-three-leg-trust-chain ap1 documented-and-contract-tested
|
|
||||||
feature/keycloak-token-mediating-access-handoff ap2 locally-verified
|
|
||||||
feature/keycloak-token-mediating-confidential-client ap2 locally-verified
|
|
||||||
feature/keycloak-traefik-forwardauth-alternative ap4 config-tested
|
|
||||||
feature/keycloak-vanilla-js-spa-pkce ap1 locally-verified
|
|
||||||
|
@@ -1,283 +0,0 @@
|
|||||||
# 열린 질문 커버리지 — 이 실험대로 답할 수 있는가
|
|
||||||
|
|
||||||
공개 기록(`hyeonworks.com/questions`)에 등록된 KeyCloak Patterns 열린 질문
|
|
||||||
네 개를, 이 실험대가 실제로 검증할 수 있는지 대조한 결과.
|
|
||||||
|
|
||||||
**결론 — 네 개 모두 이 실험대에서 재현 가능하다. 다만 로드맵에 빠진 항목이
|
|
||||||
있고, 순서가 한 곳 뒤집혀 있다.**
|
|
||||||
|
|
||||||
| # | 질문 | 게시 | 로드맵 커버 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Q1 | [서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가](https://hyeonworks.com/questions/server-session-pattern-multi-instance) | 2026.08.29 | **부분** |
|
|
||||||
| Q2 | [Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가](https://hyeonworks.com/questions/refresh-rotation-replica-contention) | 2026.08.26 | **부분** |
|
|
||||||
| Q3 | [BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가](https://hyeonworks.com/questions/bff-session-authorized-client-store) | 2026.08.30 | **부분** |
|
|
||||||
| Q4 | [Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가](https://hyeonworks.com/questions/edge-authorization-scope) | 2026.08.31 | **없음** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 발견한 구조적 문제
|
|
||||||
|
|
||||||
### 1. 순서가 뒤집혀 있다
|
|
||||||
|
|
||||||
Q2가 명시한다.
|
|
||||||
|
|
||||||
> 이 경쟁은 **저장소를 공유한 뒤에야 재현**되기 때문에 저장소 결정을 하고
|
|
||||||
> 나서 해당 문제를 이어서 풀어보자.
|
|
||||||
|
|
||||||
즉 **Q3(저장소 결정) → Q2(경쟁 재현)** 순서다. 그런데 로드맵은
|
|
||||||
`refresh-token-concurrency`를 `redis-app-session-store`보다 **앞**에 두었다.
|
|
||||||
|
|
||||||
**Q2를 먼저 시도하면 재현 자체가 불가능하다.** 저장소가 process-local이면
|
|
||||||
두 replica가 같은 refresh token 항목을 보지 않기 때문이다.
|
|
||||||
|
|
||||||
→ 로드맵 순서를 교정한다.
|
|
||||||
|
|
||||||
### 2. Session과 Authorized Client는 조회 키가 다르다
|
|
||||||
|
|
||||||
Q3의 핵심이며 로드맵에 이 구분이 없었다.
|
|
||||||
|
|
||||||
| 상태 | 조회 키 | 저장 위치(현재) |
|
|
||||||
|---|---|---|
|
|
||||||
| Application Session | **session ID** | 서블릿 컨테이너 in-memory |
|
|
||||||
| OAuth2AuthorizedClient | **client registration 이름 + principal name** | 자동구성 in-memory |
|
|
||||||
|
|
||||||
**`session ID`가 조회 키에 없다.** 그래서 같은 사용자가 두 브라우저에서
|
|
||||||
로그인하면 **동일한 authorized client 항목을 공유**한다.
|
|
||||||
|
|
||||||
Q1의 제약이 이를 그대로 지적한다.
|
|
||||||
|
|
||||||
> 여러 인스턴스가 같은 세션을 사용할 수 있도록 Session Store를 공유
|
|
||||||
> 저장소로 변경하는 것만으로는 **충분하지 않다.**
|
|
||||||
|
|
||||||
→ 실험을 "Redis 도입" 하나로 뭉뚱그리면 안 된다. **두 저장소를 각각 설계하고
|
|
||||||
각각 검증해야 한다.**
|
|
||||||
|
|
||||||
### 3. 이미 해결한 문제가 질문에도 있다
|
|
||||||
|
|
||||||
Q1의 제약:
|
|
||||||
|
|
||||||
> Resource Server의 8081이 host에도 열려 있어서 모든 client가 BFF만 거치도록
|
|
||||||
> **network에서 강제된 상태가 아니다.**
|
|
||||||
|
|
||||||
이는 2홉 헤더 실험에서 마주친 **프록시 우회 경로**와 같은 문제이며,
|
|
||||||
NetworkPolicy로 닫는 방법을 이미 확립했다
|
|
||||||
([`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) 11절).
|
|
||||||
|
|
||||||
→ Q1에 답할 때 그 패턴을 그대로 재사용한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q1. 다중 인스턴스 운영
|
|
||||||
|
|
||||||
**질문이 요구하는 검증 5단계**
|
|
||||||
|
|
||||||
| # | 검증 | 실험대 가능 | 로드맵 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | 한쪽에서 로그인 후 **다른 인스턴스로 요청 시 200 유지** | 가능 | 없음 |
|
|
||||||
| 2 | 한 인스턴스 재시작 후 **같은 session cookie로 상태 유지** | 가능 | 없음 |
|
|
||||||
| 3 | 같은 사용자 두 브라우저 → **authorized client 덮어쓰는가** | 가능 | **없음** |
|
|
||||||
| 4 | 한쪽 logout 후 **다른 쪽 요청** | 가능 | 부분 (백채널 로그아웃) |
|
|
||||||
| 5 | **session 만료 ≠ token 만료** 각 경우의 응답과 화면 | 가능 | **없음** |
|
|
||||||
|
|
||||||
**실험대 준비 상태** — BFF를 2 replica로 띄우면 전부 재현된다. 호스트 nginx의
|
|
||||||
`ip_hash` 주석을 켜고 끄면 **스티키 유무 비교**까지 같은 구성에서 된다.
|
|
||||||
|
|
||||||
**추가로 필요한 것**
|
|
||||||
|
|
||||||
- BFF 이미지 (아직 `bff/` 디렉터리에 소스 없음)
|
|
||||||
- 로그아웃 전파를 관찰할 두 번째 앱 (`app2.hyeonworks.com` 이름은 확보)
|
|
||||||
|
|
||||||
**3번이 특히 중요하다.** "Redis만 붙이면 해결"이라는 착각을 깨는 항목이고,
|
|
||||||
조회 키가 다르다는 사실의 실증이다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q2. Refresh Token Rotation 경쟁
|
|
||||||
|
|
||||||
**질문이 요구하는 검증 5단계**
|
|
||||||
|
|
||||||
| # | 검증 | 실험대 가능 | 로드맵 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | replica 두 대에서 **access token 만료 직후 동시 요청** | 가능 | 있음 |
|
|
||||||
| 2 | **이긴 쪽/지는 쪽 응답** 각각 기록 | 가능 | 부분 |
|
|
||||||
| 3 | 지는 쪽이 **저장된 새 token으로 재시도해 성공하는가** | 가능 | **없음** |
|
|
||||||
| 4 | **지는 쪽 사용자 화면**에 무엇이 보이는가 | 가능 | **없음** |
|
|
||||||
| 5 | **lock 유무를 같은 입력으로 비교** (실패율·지연) | 가능 | **없음** |
|
|
||||||
|
|
||||||
**5번이 결론을 내는 기준이다.**
|
|
||||||
|
|
||||||
> 실패가 사용자에게 노출되면 lock을 고르고, 노출되지 않으면 재시도로 둔다.
|
|
||||||
|
|
||||||
로드맵에 없던 항목인데, **이것이 없으면 질문에 답할 수 없다.**
|
|
||||||
|
|
||||||
**제약을 지켜야 한다**
|
|
||||||
|
|
||||||
- rotation + 재사용 0회는 **전제로 고정**한다. 바꾸지 않고 답한다
|
|
||||||
- 이미 발급된 access token은 만료 전까지 통하므로 **재현은 access token 만료
|
|
||||||
직후에 맞춰 실행**한다. 그렇지 않으면 실패가 화면에 보이지 않는다
|
|
||||||
|
|
||||||
**선행 조건** — Q3의 저장소 공유가 먼저다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q3. BFF 저장소 결정
|
|
||||||
|
|
||||||
**질문이 요구하는 검증 5단계**
|
|
||||||
|
|
||||||
| # | 검증 | 실험대 가능 | 로드맵 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | 인스턴스 두 대에서 **로그인 유지와 재시작 복구** | 가능 | 부분 |
|
|
||||||
| 2 | 저장소를 열어 **refresh token이 평문인가** | 가능 | **없음** |
|
|
||||||
| 3 | **session TTL ≠ token 만료** 그 순간의 응답과 화면 | 가능 | **없음** |
|
|
||||||
| 4 | logout 뒤 **두 store에 잔여 항목이 없는가** | 가능 | 부분 |
|
|
||||||
| 5 | **저장소를 끊은 상태**에서 로그인·API 호출 오류 | 가능 | 있음 |
|
|
||||||
|
|
||||||
**로드맵에 없던 큰 항목 — 후보 비교**
|
|
||||||
|
|
||||||
질문은 "Redis로 간다"가 아니라 **"Redis와 JDBC 중 무엇이 이 접근 패턴에
|
|
||||||
맞는가"** 를 묻는다.
|
|
||||||
|
|
||||||
> 요청마다 읽는 값과 가끔 읽는 값이 섞여 있다.
|
|
||||||
|
|
||||||
이 실험대에는 PostgreSQL이 이미 있으므로 **JDBC 후보를 같은 조건에서 비교할
|
|
||||||
수 있다.** Redis만 붙이면 질문의 절반만 답하는 셈이다.
|
|
||||||
|
|
||||||
**2번(평문 확인)의 실행 방법**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl -n <ns> exec -it deploy/redis -- redis-cli --scan --pattern 'spring:session:*'
|
|
||||||
kubectl -n <ns> exec -it deploy/redis -- redis-cli GET <key>
|
|
||||||
```
|
|
||||||
|
|
||||||
저장소를 직접 열어 refresh token이 그대로 읽히는지 본다. 읽힌다면
|
|
||||||
암호화 설계가 필요하고, 그 key 교체 절차는 별도 과제다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q4. Edge 인가 범위 — 로드맵에 전혀 없다
|
|
||||||
|
|
||||||
이 축을 A층(Keycloak)·B층(앱 세션) 중심으로 잡으면서 **AP4의 인가 범위
|
|
||||||
질문을 빠뜨렸다.**
|
|
||||||
|
|
||||||
**질문이 요구하는 검증**
|
|
||||||
|
|
||||||
| 검증 | 실험대 가능 |
|
|
||||||
|---|---|
|
|
||||||
| role을 헤더에 담고 **다중 값 구분자·escaping** 확인 | 가능 |
|
|
||||||
| **헤더 크기 상한** 초과 시 proxy가 자르는가 요청이 거부되는가 | 가능 |
|
|
||||||
| role 변경 후 **몇 번째 요청부터 반영되는가** | 가능 |
|
|
||||||
| upstream이 헤더 존재만 보는가 값과 service identity까지 보는가 | 가능 |
|
|
||||||
|
|
||||||
**이 실험대에서 특히 잘 맞는 이유**
|
|
||||||
|
|
||||||
nginx의 헤더 처리 특성을 이미 실측했다. 질문이 지적한
|
|
||||||
|
|
||||||
> Nginx는 client가 보낸 동명 헤더를 merge하지 않고 **덮어쓴다.**
|
|
||||||
|
|
||||||
는 2홉 헤더 실험에서 `proxy_set_header X-Forwarded-For $remote_addr`로
|
|
||||||
확인한 그 동작이다. **`X-Auth-Request-*`도 같은 규칙을 따르는지**를 같은
|
|
||||||
방법으로 검증할 수 있다.
|
|
||||||
|
|
||||||
그리고 질문의 제약
|
|
||||||
|
|
||||||
> internal token 검사가 controller 한 곳에만 있다. 헤더를 늘리기 전에 이
|
|
||||||
> 검사를 **공통 경계로 옮겨야** 된다.
|
|
||||||
|
|
||||||
는 코드 변경이므로 `backend/`에서 진행한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 교정된 실험 순서
|
|
||||||
|
|
||||||
기존 로드맵의 순서를 질문의 의존 관계에 맞춰 조정한다.
|
|
||||||
|
|
||||||
```
|
|
||||||
✅ 환경 구축
|
|
||||||
✅ 2홉 프록시 헤더 계약
|
|
||||||
──────────────────────────────────────────────────────────
|
|
||||||
1. Keycloak 멀티노드 클러스터 형성 (선행 인프라)
|
|
||||||
2. persistent vs volatile 세션 (A층)
|
|
||||||
3. BFF 저장소 결정 → Q3 ★ Q2 의 선행 조건
|
|
||||||
4. 다중 인스턴스 운영 → Q1
|
|
||||||
5. Refresh Token 경쟁 → Q2 ★ 3 이후여야 재현됨
|
|
||||||
6. Edge 인가 범위 → Q4 ← 새로 추가
|
|
||||||
7. 장애 주입과 복구 (전 항목 공통)
|
|
||||||
```
|
|
||||||
|
|
||||||
**바뀐 점**
|
|
||||||
|
|
||||||
- `refresh-token-concurrency`가 `redis-app-session-store` **뒤로** 이동
|
|
||||||
- 저장소 결정이 **Redis 도입**이 아니라 **Redis vs JDBC 비교**로 확장
|
|
||||||
- **Edge 인가 범위(Q4)** 신규 추가
|
|
||||||
|
|
||||||
## 브랜치 매핑
|
|
||||||
|
|
||||||
| 실험 | 브랜치 | 상태 |
|
|
||||||
|---|---|---|
|
|
||||||
| 멀티노드 클러스터 | `feature/keycloak-multinode-cluster-jdbc-ping` | 존재 |
|
|
||||||
| persistent vs volatile | `feature/keycloak-persistent-vs-volatile-sessions` | 존재 |
|
|
||||||
| BFF 저장소 (Q3) | `feature/keycloak-redis-app-session-store` | 존재 — **범위 확장 필요** |
|
|
||||||
| 다중 인스턴스 (Q1) | — | **없음** |
|
|
||||||
| refresh 경쟁 (Q2) | `feature/keycloak-refresh-token-concurrency` | 존재 |
|
|
||||||
| Edge 인가 (Q4) | — | **없음** |
|
|
||||||
| 장애 주입 | `feature/keycloak-failure-injection-recovery` | 존재 |
|
|
||||||
|
|
||||||
**두 개를 새로 만들어야 한다.**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git checkout develop-keycloak-session-store
|
|
||||||
git checkout -b feature/keycloak-multi-instance-session-operation
|
|
||||||
git checkout -b feature/keycloak-edge-authorization-scope
|
|
||||||
```
|
|
||||||
|
|
||||||
## 공통 선행 조건 — BFF 구현은 이미 있다
|
|
||||||
|
|
||||||
세 질문(Q1·Q2·Q3)이 모두 **BFF를 2 replica로 띄우는 것**을 전제한다.
|
|
||||||
`develop-keycloak-session-store`의 `bff/`에는 빌드 산출물만 있지만,
|
|
||||||
**`develop-keycloak-pattern3`에 구현이 완성되어 있다.**
|
|
||||||
|
|
||||||
```
|
|
||||||
bff/Dockerfile
|
|
||||||
bff/pom.xml
|
|
||||||
bff/src/main/java/com/example/keycloakpattern/bff/
|
|
||||||
├ BffApplication.java
|
|
||||||
├ BffController.java
|
|
||||||
├ CsrfController.java
|
|
||||||
├ SecurityConfig.java
|
|
||||||
└ SpaCsrfTokenRequestHandler.java
|
|
||||||
bff/src/main/resources/application.yml
|
|
||||||
bff/src/main/resources/static/{index.html,app.js}
|
|
||||||
bff/src/test/java/.../BffControllerTest.java
|
|
||||||
```
|
|
||||||
|
|
||||||
→ 새로 구현할 필요가 없다. **AP3 브랜치에서 이 실험대로 가져온다.**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git checkout develop-keycloak-session-store
|
|
||||||
git checkout develop-keycloak-pattern3 -- bff/
|
|
||||||
```
|
|
||||||
|
|
||||||
가져온 뒤 확인할 것 — 질문들이 지목한 부분이 코드에 그대로 있는지.
|
|
||||||
|
|
||||||
| 확인 | 어디를 볼 것인가 |
|
|
||||||
|---|---|
|
|
||||||
| Session 저장소가 in-memory 자동구성인가 | `SecurityConfig.java`, `application.yml`에 Spring Session 설정 부재 |
|
|
||||||
| `OAuth2AuthorizedClientService`가 in-memory인가 | Bean 정의 부재 → 자동구성 결과 확인 필요 |
|
|
||||||
| authorized client 조회에 session ID가 없는가 | Spring Security 기본 계약 |
|
|
||||||
|
|
||||||
Q3가 "어떤 구현체가 실제로 쓰이는지는 자동구성 결과까지 확인해야 정확히
|
|
||||||
알 수 있다"고 남긴 미지수를, **기동 후 Bean을 실제로 조회해서** 확정할 수 있다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl -n <ns> exec deploy/bff -- \
|
|
||||||
curl -s localhost:8082/actuator/beans | grep -i authorizedClientService
|
|
||||||
```
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
|
|
||||||
| 문서 | 관계 |
|
|
||||||
|---|---|
|
|
||||||
| [`session-store-lab-roadmap.md`](session-store-lab-roadmap.md) | 이 문서가 그 순서를 교정한다 |
|
|
||||||
| [`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) | Q1의 우회 경로 제약, Q4의 헤더 덮어쓰기 근거 |
|
|
||||||
| [`session-lab-operations.md`](session-lab-operations.md) | 실행 도구와 명령 |
|
|
||||||
| [`four-pattern-tradeoff-matrix.md`](four-pattern-tradeoff-matrix.md) | Q4가 되돌아가는 선택지(BFF)의 비교표 |
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# Public HTTPS domain for broker callbacks
|
|
||||||
|
|
||||||
Google brokering을 반복 테스트할 때는 Cloudflare **named tunnel + 관리
|
|
||||||
도메인**을 기본 profile로 사용한다. `trycloudflare.com` quick tunnel과
|
|
||||||
임의 ngrok URL은 일회성 데모용이며 고정 callback으로 간주하지 않는다.
|
|
||||||
|
|
||||||
설정 순서:
|
|
||||||
|
|
||||||
1. `cloudflared tunnel login`
|
|
||||||
2. `cloudflared tunnel create keycloak-patterns`
|
|
||||||
3. 예제 config의 tunnel UUID와 credentials path를 실제 값으로 교체
|
|
||||||
4. `cloudflared tunnel route dns keycloak-patterns auth.example.test`
|
|
||||||
5. `cloudflared tunnel run keycloak-patterns`
|
|
||||||
6. Keycloak `KC_HOSTNAME`과 Google redirect URI를 같은 public host로 설정
|
|
||||||
|
|
||||||
컨테이너 안의 `127.0.0.1`은 cloudflared 컨테이너 자신이므로 origin에는
|
|
||||||
`reverse-proxy:8080` 같은 Compose service DNS를 사용한다. 마지막 catch-all
|
|
||||||
ingress는 알 수 없는 hostname을 404로 끝낸다.
|
|
||||||
|
|
||||||
실 tunnel 생성과 DNS 변경에는 사용자 소유 계정·도메인이 필요하므로 자동
|
|
||||||
검증은 ingress 파일의 구조까지만 수행한다.
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Reverse proxy headers
|
|
||||||
|
|
||||||
TLS를 reverse proxy에서 종료하면 Keycloak은 브라우저가 사용한 외부 origin을
|
|
||||||
정확히 알아야 한다. 배포 예제는 다음 계약을 함께 적용한다.
|
|
||||||
|
|
||||||
- nginx는 `Host`, `X-Forwarded-Host`, `X-Forwarded-Port`,
|
|
||||||
`X-Forwarded-Proto`, `X-Forwarded-For`를 덮어쓴다.
|
|
||||||
- Keycloak은 `KC_PROXY_HEADERS=xforwarded`로 그 헤더 형식을 명시한다.
|
|
||||||
- `KC_HOSTNAME`은 외부 HTTPS URL로 고정하고 strict hostname 검증을 켠다.
|
|
||||||
- Keycloak의 8080 포트는 public으로 publish하지 않고 proxy network에서만
|
|
||||||
접근시킨다. 신뢰되지 않은 클라이언트가 forwarded header를 직접 넣을 수
|
|
||||||
있으면 안 된다.
|
|
||||||
|
|
||||||
`scripts/verify-reverse-proxy-headers.sh`는 양쪽 설정의 짝과 nginx 구문을
|
|
||||||
검증한다.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,434 +0,0 @@
|
|||||||
# 실험대 운영 — 도구 · 명령 · 훈련
|
|
||||||
|
|
||||||
실험을 돌리는 데 반복해서 쓰는 것들. 개념은
|
|
||||||
[`session-lab-concepts.md`](session-lab-concepts.md), 계획은
|
|
||||||
[`session-store-lab-roadmap.md`](session-store-lab-roadmap.md)에 있다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 관측 도구
|
|
||||||
|
|
||||||
### htop
|
|
||||||
|
|
||||||
**설치** — 세 대 모두 배포판이 다르다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# lab host (Arch)
|
|
||||||
sudo pacman -S htop
|
|
||||||
|
|
||||||
# 게스트 (Debian) — lab host 에서 한 번에
|
|
||||||
for n in kc-lab-1 kc-lab-2; do
|
|
||||||
ssh $n 'sudo apt-get update -qq && sudo apt-get install -y -qq htop'
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
**lab host에서 htop을 읽는 법 — VM은 프로세스로 보인다**
|
|
||||||
|
|
||||||
가장 중요한 관점이다. 게스트는 **호스트 입장에서 `qemu-system-x86_64`
|
|
||||||
프로세스 하나**다. 그래서 VM의 메모리 사용량이 곧 그 프로세스의 RES다.
|
|
||||||
|
|
||||||
```
|
|
||||||
pid=4677 RSS=3765MB qemu-system-x86 ← kc-lab-1 (할당 3584M)
|
|
||||||
pid=4770 RSS=2670MB qemu-system-x86 ← kc-lab-2 (할당 2560M)
|
|
||||||
```
|
|
||||||
|
|
||||||
**RSS가 할당량보다 조금 큰 이유**는 QEMU 자체의 에뮬레이션 오버헤드
|
|
||||||
(장치 모델, 버퍼)가 더해지기 때문이다. 게스트가 터치한 페이지만큼만
|
|
||||||
RSS로 잡히므로, 게스트가 메모리를 더 쓰면 RSS도 할당 상한까지 올라간다.
|
|
||||||
|
|
||||||
**htop 안에서 쓸 키**
|
|
||||||
|
|
||||||
| 키 | 동작 | 이 실험대에서 |
|
|
||||||
|---|---|---|
|
|
||||||
| `F5` | 트리 뷰 | `libvirtd` → `qemu-system` 계층 확인 |
|
|
||||||
| `F4` | 필터 | `qemu`만 보기 |
|
|
||||||
| `F6` | 정렬 기준 | `PERCENT_MEM`으로 정렬 |
|
|
||||||
| `M` / `P` | 메모리/CPU 정렬 | |
|
|
||||||
| `u` | 사용자 필터 | `libvirt-qemu`로 VM만 |
|
|
||||||
| `H` | 스레드 숨김 | QEMU는 vCPU마다 스레드라 켜두면 지저분하다 |
|
|
||||||
|
|
||||||
**게스트 안에서 htop을 읽을 때** 보이는 것은 `k3s`(server 또는 agent),
|
|
||||||
`containerd`, `containerd-shim`, 그리고 각 파드의 `java` 프로세스다.
|
|
||||||
Java 힙 상한은 컨테이너 limit의 70%(`-XX:MaxRAMPercentage=70`)이므로
|
|
||||||
512Mi limit이면 약 358Mi다.
|
|
||||||
|
|
||||||
### k9s
|
|
||||||
|
|
||||||
설치는 되어 있다. 별도 구성 없이 `~/.kube/config`를 읽는다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
k9s
|
|
||||||
```
|
|
||||||
|
|
||||||
| 키 | 동작 |
|
|
||||||
|---|---|
|
|
||||||
| `:` | 명령 모드 — `:pods` `:svc` `:ing` `:nodes` `:events` |
|
|
||||||
| `0` | 전체 네임스페이스 |
|
|
||||||
| `/` | 필터 |
|
|
||||||
| `l` | 로그 |
|
|
||||||
| `d` | describe |
|
|
||||||
| `y` | YAML |
|
|
||||||
| `s` | 파드 안 셸 |
|
|
||||||
| `Ctrl+d` | 파드 삭제 ← **장애 주입에 씀** |
|
|
||||||
| `esc` / `q` | 뒤로 / 종료 |
|
|
||||||
|
|
||||||
`~/.config/k9s/config.yaml`의 `refreshRate`를 2초로 낮추면 노드를 죽였을 때
|
|
||||||
파드 재배치가 실시간으로 보인다.
|
|
||||||
|
|
||||||
### kubectl top
|
|
||||||
|
|
||||||
k3s가 metrics-server를 기본 배포하므로 바로 쓸 수 있다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl top nodes
|
|
||||||
kubectl -n header-lab top pods
|
|
||||||
```
|
|
||||||
|
|
||||||
**htop과 보는 층이 다르다.**
|
|
||||||
|
|
||||||
| | 보는 것 |
|
|
||||||
|---|---|
|
|
||||||
| `htop` (lab host) | VM 프로세스 = 게스트 전체 |
|
|
||||||
| `htop` (게스트) | 게스트 안의 프로세스 |
|
|
||||||
| `kubectl top` | 파드·노드 단위, 클러스터 관점 |
|
|
||||||
|
|
||||||
### 상태 점검 스크립트
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./deploy/lab/scripts/verify-lab.sh # lab host 에서
|
|
||||||
./deploy/lab/scripts/measure-proxy-headers.sh # 어디서든
|
|
||||||
```
|
|
||||||
|
|
||||||
`verify-lab.sh`는 게스트·k3s·nginx·인증서·공개 진입점을 한 번에 확인하고
|
|
||||||
`lab is healthy`를 출력한다. **`404`가 성공 신호**다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 자원 실측과 예산
|
|
||||||
|
|
||||||
**2026-09-03 기준, Keycloak 배포 전**
|
|
||||||
|
|
||||||
### 호스트 여유와 게스트 여유는 다르다
|
|
||||||
|
|
||||||
가장 오해하기 쉬운 지점이다. 호스트만 보면 절망적으로 보인다.
|
|
||||||
|
|
||||||
```
|
|
||||||
lab host 총 7628MB · 사용 7189MB · 여유 439MB
|
|
||||||
├ qemu #1 RSS 3765MB kc-lab-1 (할당 3584MB) → 상한 도달
|
|
||||||
└ qemu #2 RSS 2633MB kc-lab-2 (할당 2560MB) → 상한 도달
|
|
||||||
```
|
|
||||||
|
|
||||||
그런데 게스트 안을 보면 여유가 있다.
|
|
||||||
|
|
||||||
```
|
|
||||||
kc-lab-1 총 3423MB · used 1464 · buff/cache 2020 · available 1959MB
|
|
||||||
kc-lab-2 총 2480MB · used 580 · buff/cache 1714 · available 1899MB
|
|
||||||
─────────────────
|
|
||||||
게스트 여유 합계 약 3.8GB
|
|
||||||
```
|
|
||||||
|
|
||||||
**왜 이런가** — QEMU의 RSS는 게스트가 **터치한 페이지**만큼이다. 게스트가
|
|
||||||
메모리를 페이지 캐시로 다 채우면 QEMU RSS도 할당 상한까지 올라간다.
|
|
||||||
지금이 그 상태다.
|
|
||||||
|
|
||||||
**그래서 앞으로 워크로드를 올려도 호스트 압박은 늘지 않는다.** 게스트 안의
|
|
||||||
페이지 캐시가 밀려날 뿐이다. **QEMU RSS는 이미 천장이다.**
|
|
||||||
|
|
||||||
```
|
|
||||||
확인 방법:
|
|
||||||
ps -eo rss,args --sort=-rss | grep '[q]emu-system' # 호스트에서 본 VM
|
|
||||||
ssh kc-lab-1 free -m # 게스트 안 실제
|
|
||||||
kubectl top nodes # working set
|
|
||||||
```
|
|
||||||
|
|
||||||
세 값이 다른 것을 보는 것이 이 실험대의 메모리 감각이다.
|
|
||||||
|
|
||||||
### 배포 예산
|
|
||||||
|
|
||||||
| 워크로드 | 예상 | 배치 |
|
|
||||||
|---|---|---|
|
|
||||||
| Keycloak × 2 | 각 700Mi | 노드당 1개 |
|
|
||||||
| PostgreSQL | 300Mi | kc-lab-1 |
|
|
||||||
| Redis | 100Mi | kc-lab-2 |
|
|
||||||
| BFF × 2 | 각 400Mi | 노드당 1개 |
|
|
||||||
| **합계** | **약 2600Mi** | |
|
|
||||||
|
|
||||||
**게스트 여유 3.8GB 중 2.6GB → 가능하다.** 다만 여기에
|
|
||||||
Prometheus/Grafana(로드맵 10번 관측성)를 얹을 여유는 없다.
|
|
||||||
|
|
||||||
### 대응 — 비용이 없는 것부터
|
|
||||||
|
|
||||||
**1. 끝난 실험은 지운다**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl delete ns header-lab # 파드 2개 × 150Mi 회수
|
|
||||||
```
|
|
||||||
|
|
||||||
증거는 `docs/evidence/`에 남아 있으므로 워크로드를 유지할 이유가 없다.
|
|
||||||
|
|
||||||
**2. Keycloak 힙을 명시적으로 제한한다**
|
|
||||||
|
|
||||||
Keycloak은 기본값이 넉넉해 그냥 두면 1GB를 넘긴다.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
env:
|
|
||||||
- name: JAVA_OPTS_KC_HEAP
|
|
||||||
value: "-Xms256m -Xmx512m"
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 768Mi
|
|
||||||
```
|
|
||||||
|
|
||||||
**모든 워크로드에 `resources.limits`를 반드시 건다.** 안 걸면 한 파드가
|
|
||||||
게스트 메모리를 다 먹고 다른 파드까지 OOMKilled된다.
|
|
||||||
|
|
||||||
**3. 실험을 순차로 돌린다 — 동시에 다 띄우지 않는다**
|
|
||||||
|
|
||||||
```
|
|
||||||
A층(Keycloak + PostgreSQL) → 결과 기록 → 정리
|
|
||||||
↓
|
|
||||||
B층(BFF + Redis) → 결과 기록 → 정리
|
|
||||||
↓
|
|
||||||
관측성(Prometheus) → 필요할 때만
|
|
||||||
```
|
|
||||||
|
|
||||||
절약책이 아니라 **정상적인 실험 운영 방식**이다. 동시에 띄우면 변수가
|
|
||||||
섞여서 원인 분리가 어려워진다.
|
|
||||||
|
|
||||||
### swap은 쓰지 않는다
|
|
||||||
|
|
||||||
호스트에는 8GB swap이 있지만 **게스트에는 0MB이며, 그것이 맞다.**
|
|
||||||
|
|
||||||
| 이유 | |
|
|
||||||
|---|---|
|
|
||||||
| k3s/kubelet | 기본적으로 swap 을 거부한다 |
|
|
||||||
| 성능 | 호스트 swap 으로 QEMU 페이지가 밀리면 급락한다 |
|
|
||||||
| **측정 오염** | 이 실험대는 **타이밍**(refresh 경쟁, Infinispan 복제 지연)을 잰다. swap 이 끼면 측정이 통째로 무의미해진다 |
|
|
||||||
|
|
||||||
### 근본 해결 — 메모리 증설
|
|
||||||
|
|
||||||
남은 실험이 10개이고 관측성까지 하려면 증설이 가장 확실하다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo pacman -S dmidecode
|
|
||||||
sudo dmidecode -t memory | grep -E "Maximum Capacity|Number Of Devices|Size:|Locator:|Type:|Speed:"
|
|
||||||
```
|
|
||||||
|
|
||||||
| 슬롯 상태 | 조치 |
|
|
||||||
|---|---|
|
|
||||||
| 2슬롯 중 1개만 사용 | 동일 규격 8GB 추가 → 16GB |
|
|
||||||
| 온보드 8GB + 슬롯 1개 | 16GB 추가 → 24GB |
|
|
||||||
| 2슬롯 모두 사용 | 8GB × 2 를 16GB × 2 로 교체 |
|
|
||||||
|
|
||||||
i5-1135G7(Tiger Lake)은 DDR4-3200 SO-DIMM을 쓰며 최대 용량은 보드마다
|
|
||||||
다르므로 `Maximum Capacity` 값을 확인한다. **비용 대비 효과가 가장 크다** —
|
|
||||||
증설하면 Prometheus·Grafana·BFF 2 replica를 동시에 띄우고도 남는다.
|
|
||||||
|
|
||||||
## 3. 자주 쓰는 명령
|
|
||||||
|
|
||||||
### VM (lab host, `LIBVIRT_DEFAULT_URI=qemu:///system`)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
virsh list --all # 상태
|
|
||||||
virsh domstate kc-lab-1
|
|
||||||
virsh domblklist kc-lab-1 # 붙은 디스크
|
|
||||||
virsh net-dhcp-leases default # 게스트 IP
|
|
||||||
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 화면 (PNG 로 저장됨)
|
|
||||||
virsh send-key kc-lab-1 --codeset linux KEY_ENTER
|
|
||||||
|
|
||||||
virsh destroy kc-lab-1 # 전원 강제 차단 = 노드 상실
|
|
||||||
virsh start kc-lab-1 # 재기동
|
|
||||||
virsh shutdown kc-lab-1 # ACPI 정상 종료
|
|
||||||
```
|
|
||||||
|
|
||||||
**`destroy`는 파일을 지우지 않는다.** 전원 코드를 뽑는 것에 해당한다.
|
|
||||||
정의와 디스크를 지우는 것은 `undefine`이다.
|
|
||||||
|
|
||||||
### 클러스터
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl get nodes -o wide
|
|
||||||
kubectl get pods -A -o wide
|
|
||||||
kubectl -n <ns> logs -f deployment/<name>
|
|
||||||
kubectl -n <ns> describe pod <pod>
|
|
||||||
kubectl -n <ns> rollout status deployment/<name>
|
|
||||||
kubectl -n <ns> rollout restart deployment/<name>
|
|
||||||
kubectl -n <ns> rollout undo deployment/<name> # 직전 버전으로
|
|
||||||
|
|
||||||
# 설정 스위치 껐다 켜기 — 실험의 기본 동작
|
|
||||||
kubectl -n <ns> set env deployment/<name> KEY=VALUE
|
|
||||||
|
|
||||||
# 임시 파드로 클러스터 안에서 테스트
|
|
||||||
kubectl -n <ns> run t --rm -i --restart=Never --image=curlimages/curl:8.11.1 -- \
|
|
||||||
curl -s http://<svc>:<port>/path
|
|
||||||
```
|
|
||||||
|
|
||||||
### 이미지 반입
|
|
||||||
|
|
||||||
k3s는 containerd를 쓰고 레지스트리가 없다. **자체 빌드 이미지는 매번 이
|
|
||||||
경로를 탄다.**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 워크스테이션에서
|
|
||||||
docker build -t keycloak-pattern-api:lab backend
|
|
||||||
docker save keycloak-pattern-api:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
|
|
||||||
docker save keycloak-pattern-api:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
|
|
||||||
|
|
||||||
# 확인
|
|
||||||
ssh test-server "ssh kc-lab-1 'sudo k3s ctr images ls -q | grep keycloak-pattern'"
|
|
||||||
```
|
|
||||||
|
|
||||||
**두 노드 모두**에 넣는다. 스케줄러가 어디에 배치할지 모른다.
|
|
||||||
매니페스트는 `imagePullPolicy: Never`여야 한다.
|
|
||||||
|
|
||||||
`ctr`이 아니라 **`k3s ctr`** 이다. 시스템에 별도 `ctr`이 있으면 다른 소켓을
|
|
||||||
보게 되어 "성공했는데 파드는 못 찾는" 상태가 된다.
|
|
||||||
|
|
||||||
### 저장소·브랜치
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 워크스테이션 — 작성·커밋
|
|
||||||
git add -A && git commit -m "..." && git push origin develop-keycloak-session-store
|
|
||||||
|
|
||||||
# lab host — 받기만 (읽기 전용으로 운용)
|
|
||||||
cd ~/workspace/keycloak-pattern && git pull
|
|
||||||
|
|
||||||
# 실험별 브랜치 이동
|
|
||||||
git checkout feature/keycloak-multinode-cluster-jdbc-ping
|
|
||||||
```
|
|
||||||
|
|
||||||
**lab host의 저장소는 읽기 전용으로 쓴다.** 거기서 편집하면 드리프트가
|
|
||||||
생긴다 — nginx 설정에서 실제로 겪었다
|
|
||||||
([`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) 9절).
|
|
||||||
|
|
||||||
### 호스트 nginx
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo cp deploy/lab/host/nginx-keycloak-lab.conf /etc/nginx/sites-available/keycloak-lab
|
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
|
||||||
sudo nginx -T | grep -n 'upstream\|server_name' # 최종 병합 설정
|
|
||||||
```
|
|
||||||
|
|
||||||
**`nginx -t`를 통과한 뒤에만 reload한다.** 깨진 설정으로 reload하면 서비스가
|
|
||||||
내려간다. `-T`(대문자)는 include까지 펼친 최종 설정을 출력하므로
|
|
||||||
"파일을 고쳤는데 반영이 안 된다" 상황의 확인 수단이다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 손에 익혀야 할 훈련
|
|
||||||
|
|
||||||
개념은 알지만 직접 해보지 않은 조작들. **남은 실험 5개 중 4개가 훈련 3
|
|
||||||
위에 서 있다.**
|
|
||||||
|
|
||||||
### 훈련 1 — 이미지 반입
|
|
||||||
|
|
||||||
앞으로 BFF·token-mediator를 올릴 때마다 반복된다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t keycloak-pattern-api:lab backend
|
|
||||||
docker save keycloak-pattern-api:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
|
|
||||||
docker save keycloak-pattern-api:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
|
|
||||||
ssh test-server "ssh kc-lab-1 'sudo k3s ctr images ls -q | grep keycloak-pattern'"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 훈련 2 — 설정 스위치 껐다 켜기
|
|
||||||
|
|
||||||
**설정을 바꿔가며 비교하는 것이 이 실험대의 본체**다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl -n header-lab set env deployment/echo SERVER_FORWARD_HEADERS_STRATEGY=none
|
|
||||||
kubectl -n header-lab rollout status deployment/echo
|
|
||||||
curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool | grep -E '"scheme"|"secure"'
|
|
||||||
# → "http" / false 로 바뀐다
|
|
||||||
|
|
||||||
kubectl -n header-lab set env deployment/echo SERVER_FORWARD_HEADERS_STRATEGY=native
|
|
||||||
kubectl -n header-lab rollout status deployment/echo
|
|
||||||
curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool | grep -E '"scheme"|"secure"'
|
|
||||||
# → "https" / true 로 돌아온다
|
|
||||||
```
|
|
||||||
|
|
||||||
### 훈련 3 — 노드를 죽였다 살리기
|
|
||||||
|
|
||||||
**가장 중요하다.** 장애 실험의 전제 조작이다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 죽이기 — 전원 차단에 해당
|
|
||||||
virsh destroy kc-lab-2
|
|
||||||
|
|
||||||
# 관찰 (NotReady 로 바뀌는 데 40초 안팎)
|
|
||||||
kubectl get nodes
|
|
||||||
kubectl -n header-lab get pods -o wide
|
|
||||||
kubectl get events -A --sort-by=.lastTimestamp | tail -20
|
|
||||||
|
|
||||||
# 서비스가 살아있는지
|
|
||||||
curl -sI https://app1.hyeonworks.com/api/echo | head -1
|
|
||||||
|
|
||||||
# 되살리기
|
|
||||||
virsh start kc-lab-2
|
|
||||||
kubectl get nodes # Ready 복귀
|
|
||||||
kubectl -n header-lab get pods -o wide
|
|
||||||
```
|
|
||||||
|
|
||||||
**이번에 특별히 확인할 것** — 현재 **Traefik은 replica 1**이고
|
|
||||||
`kc-lab-2`에 있다. 그 노드를 죽이면 **진입점 자체가 사라지는지**,
|
|
||||||
아니면 다른 노드로 재배치되어 복구되는지 관찰한다.
|
|
||||||
|
|
||||||
| 관찰 | 의미 |
|
|
||||||
|---|---|
|
|
||||||
| `curl`이 계속 200 | svclb가 남은 노드로 흘려보냄 + Traefik 재배치 성공 |
|
|
||||||
| `curl`이 실패했다가 복구 | 재배치에 걸린 시간만큼 다운타임 |
|
|
||||||
| `curl`이 계속 실패 | Traefik replica를 2로 늘려야 한다 |
|
|
||||||
|
|
||||||
이 결과에 따라 **Keycloak 배포 전에 Traefik replica를 조정할지** 결정한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 필요하다면
|
|
||||||
kubectl -n kube-system scale deployment/traefik --replicas=2
|
|
||||||
```
|
|
||||||
|
|
||||||
**망가져도 된다.** `virt-install` 한 줄로 재생성되며
|
|
||||||
([`deploy/lab/README.md`](../deploy/lab/README.md) 게스트 재생성),
|
|
||||||
그러라고 만든 실험대다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 실행 주체 감사 (2026-09-03)
|
|
||||||
|
|
||||||
무엇을 직접 했고 무엇을 대행했는지의 기록. **대행한 항목은 언젠가 직접
|
|
||||||
해야 하는 것들**이다.
|
|
||||||
|
|
||||||
### 직접 수행
|
|
||||||
|
|
||||||
VM 생성 · cloud-init 작성 · SSH 키와 config · k3s server/agent 설치 ·
|
|
||||||
kubeconfig 구성 · 호스트 nginx 설치와 설정 · certbot DNS-01 인증서 발급 ·
|
|
||||||
Cloudflare DNS 레코드 · git 저장소 클론과 브랜치 · `echo.yaml` 최초 배포 ·
|
|
||||||
nginx `X-Forwarded-Proto` 수정
|
|
||||||
|
|
||||||
### 대행 수행
|
|
||||||
|
|
||||||
| 작업 | 언제 다시 필요한가 |
|
|
||||||
|---|---|
|
|
||||||
| 이미지 빌드 → 노드 반입 (2회차 이후) | 자체 이미지를 올릴 때마다 |
|
|
||||||
| `kubectl apply` (traefik HelmChartConfig, NetworkPolicy) | 모든 배포 |
|
|
||||||
| `kubectl set env` / `rollout restart` | 설정 비교 실험마다 |
|
|
||||||
| HelmChartConfig 재조정 대기와 파드 확인 | Traefik 설정 변경 시 |
|
|
||||||
| git 브랜치 생성 · 커밋 · push | 실험마다 |
|
|
||||||
| Playwright 증거 수집 | 브라우저 관점이 필요할 때마다 |
|
|
||||||
| `kubectl run` 임시 파드 위조 테스트 | 클러스터 내부 검증 시 |
|
|
||||||
| 설정 되돌렸다 복구 (단계별 스크린샷) | 비교 증거를 만들 때 |
|
|
||||||
|
|
||||||
### 아직 한 번도 하지 않은 것
|
|
||||||
|
|
||||||
| 항목 | 필요해지는 시점 |
|
|
||||||
|---|---|
|
|
||||||
| **`virsh destroy` + 재생성** | **장애 실험 전부** |
|
|
||||||
| `rebuild-seed.sh` | cloud-init 을 바꿀 때 |
|
|
||||||
| k9s 실사용 | 장애 중 상태 관찰 |
|
|
||||||
| `kubectl delete ns` | 실험 정리, 메모리 회수 |
|
|
||||||
|
|
||||||
### 만들었지만 미검증이었던 것 → 2026-09-03 확인 완료
|
|
||||||
|
|
||||||
| 스크립트 | 결과 |
|
|
||||||
|---|---|
|
|
||||||
| `verify-lab.sh` | 정상 — `lab is healthy` |
|
|
||||||
| `measure-proxy-headers.sh` | 정상 — 4개 항목 모두 출력 |
|
|
||||||
@@ -1,388 +0,0 @@
|
|||||||
# 세션 저장소 실험 축 — 계획과 진행
|
|
||||||
|
|
||||||
`develop-keycloak-session-store` 브랜치가 담당하는 작업의 전체 지도.
|
|
||||||
**무엇이 끝났고 무엇이 남았는지**를 여기서 추적한다.
|
|
||||||
|
|
||||||
## 왜 별도 축인가
|
|
||||||
|
|
||||||
네 인증 패턴(AP1~AP4)은 **브라우저와 토큰의 관계**를 비교한다. 이 축은
|
|
||||||
그것과 직교하는 질문을 다룬다 — **세션과 토큰이 서버 쪽 어디에 저장되고,
|
|
||||||
그 저장소가 죽으면 무슨 일이 벌어지는가.**
|
|
||||||
|
|
||||||
초기 검토에서 전제 하나가 교정됐다. **Keycloak은 Redis를 세션 저장소로
|
|
||||||
지원하지 않는다.** 그래서 이 축은 두 계층으로 갈린다.
|
|
||||||
|
|
||||||
| 계층 | 저장소 | 해당 패턴 |
|
|
||||||
|---|---|---|
|
|
||||||
| **A. Keycloak 자체** | 임베디드 Infinispan + PostgreSQL | 네 패턴 공통 |
|
|
||||||
| **B. 애플리케이션 세션** | **Redis** | AP2 / AP3 / AP4 |
|
|
||||||
|
|
||||||
A층은 네 패턴과 무관하게 공통이고, B층은 서버 세션을 갖는 세 패턴에만
|
|
||||||
존재한다. 그래서 이 축을 AP1~AP4 어디에도 넣지 않고 별도로 둔다.
|
|
||||||
|
|
||||||
## 기존 브랜치 레지스트리에 넣지 않는 이유
|
|
||||||
|
|
||||||
`docs/keycloak-branch-manifest.tsv`와 `scripts/audit-keycloak-branches.sh`는
|
|
||||||
**정확히 39개** 브랜치를 강제하고, 각 브랜치가 외부 노트 파일과 1:1로
|
|
||||||
대응하는지 검사한다.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
if [ "$expected_count" -ne 39 ]; then
|
|
||||||
echo "manifest must contain exactly 39 Keycloak branches" >&2
|
|
||||||
```
|
|
||||||
|
|
||||||
이 축의 브랜치를 manifest에 추가하면 그 감사가 깨진다. 원래 39개는
|
|
||||||
**완결된 인벤토리**이므로 건드리지 않고, 이 축은 이 문서로 추적한다.
|
|
||||||
|
|
||||||
## 진행 상황
|
|
||||||
|
|
||||||
```
|
|
||||||
✅ 환경 구축
|
|
||||||
✅ 2홉 프록시 헤더 계약
|
|
||||||
──────────────────────────────────────────────────────────
|
|
||||||
A층 — Keycloak 자체 (공개 질문에 없는 영역)
|
|
||||||
1. 멀티노드 클러스터 형성
|
|
||||||
2. persistent vs volatile 세션
|
|
||||||
|
|
||||||
B층 — 애플리케이션 세션 (공개 열린 질문 대응)
|
|
||||||
3. BFF 저장소 결정 → Q3
|
|
||||||
4. 다중 인스턴스 운영 → Q1
|
|
||||||
5. Refresh Token 경쟁 → Q2 ★ 3 이후여야 재현됨
|
|
||||||
6. Edge 인가 범위 → Q4
|
|
||||||
|
|
||||||
공통 — 운영 역량
|
|
||||||
7. 장애 주입과 복구
|
|
||||||
8. 백업과 복구 리허설
|
|
||||||
9. 버전 업그레이드
|
|
||||||
10. 관측성
|
|
||||||
11. 비밀 관리
|
|
||||||
12. 인증서 갱신 실측
|
|
||||||
```
|
|
||||||
|
|
||||||
**순서 근거는 [`open-questions-coverage.md`](open-questions-coverage.md)에 있다.**
|
|
||||||
특히 5번(refresh 경쟁)은 3번(저장소 공유) 이후여야 **재현 자체가 성립한다.**
|
|
||||||
|
|
||||||
### ✅ 완료 — 환경 구축
|
|
||||||
|
|
||||||
2노드 k3s 실험대. 상세는 [`deploy/lab/README.md`](../deploy/lab/README.md),
|
|
||||||
개념은 [`session-lab-concepts.md`](session-lab-concepts.md),
|
|
||||||
운영 도구는 [`session-lab-operations.md`](session-lab-operations.md).
|
|
||||||
|
|
||||||
```
|
|
||||||
브라우저 ─https─▶ 호스트 nginx(TLS 종료) ─▶ Traefik ─▶ Pod
|
|
||||||
kc-lab-1 / kc-lab-2
|
|
||||||
```
|
|
||||||
|
|
||||||
**왜 Docker Compose가 아닌가** — 한 커널에서 "노드 죽이기"는 프로세스
|
|
||||||
죽이기일 뿐이다. 노드 간 방화벽·비대칭 파티션·진짜 노드 상실은 **독립된
|
|
||||||
커널 두 개**가 있어야 성립한다.
|
|
||||||
|
|
||||||
### ✅ 완료 — 2홉 프록시 헤더 계약
|
|
||||||
|
|
||||||
[`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) ·
|
|
||||||
증거 [`evidence/two-hop-proxy-headers/`](evidence/two-hop-proxy-headers/)
|
|
||||||
|
|
||||||
**확인한 것** — `docs/reverse-proxy-headers.md`의 1홉 계약이 2홉에서
|
|
||||||
성립하지 않는다. 원인이 둘이었고 스위치가 셋이었다.
|
|
||||||
|
|
||||||
| 스위치 | 하는 일 |
|
|
||||||
|---|---|
|
|
||||||
| nginx `proxy_set_header` | 헤더를 **만든다** |
|
|
||||||
| Traefik `forwardedHeaders.trustedIPs` | 받은 헤더를 **전달할지 버릴지** |
|
|
||||||
| 앱 `forward-headers-strategy` / `KC_PROXY_HEADERS` | 도착한 헤더를 **읽을지** |
|
|
||||||
|
|
||||||
**발견한 취약점 — 헤더 신뢰를 켠 순간 위조가 통했다**
|
|
||||||
|
|
||||||
세 스위치를 다 켜고 나니 새 구멍이 생겼다. Traefik을 거치지 않고 앱에 직접
|
|
||||||
요청하면서 헤더를 붙이자 **그대로 통과했다.**
|
|
||||||
|
|
||||||
```
|
|
||||||
serverName evil.example.com ← 위조 성공
|
|
||||||
remoteAddr 1.2.3.4 ← 위조 성공
|
|
||||||
requestUrl https://evil.example.com/api/echo
|
|
||||||
```
|
|
||||||
|
|
||||||
두 신뢰 설정이 모두 **"대역"을 믿기 때문**이다. IP로는 Traefik을 특정할 수
|
|
||||||
없다 — 파드 IP가 재시작마다 바뀐다(측정 중 `10.42.0.8` → `10.42.1.12`로,
|
|
||||||
노드까지 옮겨갔다). **NetworkPolicy는 IP가 아니라 라벨로 지정**하므로 이를
|
|
||||||
닫는다.
|
|
||||||
|
|
||||||
**"헤더를 믿는다"와 "앞에 반드시 프록시가 있다"는 한 쌍이다.**
|
|
||||||
이 교훈이 6번(Edge 인가)에서 결정적이 된다 — `X-Auth-Request-*`가 위조되면
|
|
||||||
그것은 쿠키 속성이 아니라 **신원 위조**다.
|
|
||||||
|
|
||||||
**이 결과가 뒤에 미치는 영향** — Keycloak을 올릴 때
|
|
||||||
`KC_PROXY_HEADERS=xforwarded`와 `KC_HOSTNAME=https://auth.hyeonworks.com`을
|
|
||||||
근거를 갖고 넣을 수 있고, 로그인이 깨져도 프록시 원인은 배제하고 볼 수 있다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A층 — Keycloak 자체
|
|
||||||
|
|
||||||
**공개 열린 질문 네 개는 전부 애플리케이션 계층(B층)이다.** Keycloak 자체의
|
|
||||||
클러스터링과 세션 저장을 다루는 질문은 아직 등록되어 있지 않다.
|
|
||||||
|
|
||||||
**그러나 이 두 항목이 이 실험대의 존재 이유에 더 가깝다.** B층 실험은 replica
|
|
||||||
2개면 되므로 Docker Compose로도 상당 부분 가능하지만, A층은 **독립된 커널
|
|
||||||
두 개**를 요구한다. 실험 후 결과를 **새 열린 질문으로 등록할 후보**다.
|
|
||||||
|
|
||||||
### 1. Keycloak 멀티노드 클러스터 형성
|
|
||||||
|
|
||||||
브랜치: `feature/keycloak-multinode-cluster-jdbc-ping`
|
|
||||||
|
|
||||||
**확인할 것**
|
|
||||||
|
|
||||||
- Keycloak 2개 파드가 **서로를 발견해 하나의 클러스터를 이루는가**
|
|
||||||
- Keycloak 26의 기본 디스커버리는 `jdbc-ping` — PostgreSQL의 `JGROUPS_PING`
|
|
||||||
테이블로 서로를 찾는다. 멀티캐스트가 필요 없다
|
|
||||||
- **TCP 7800이 막히면 무엇이 먼저 보이는가** — 디스커버리는 DB로 하지만 실제
|
|
||||||
클러스터 통신은 7800이다. 막으면 "DB에는 서로 등록되는데 클러스터가 안 붙는"
|
|
||||||
증상이 나온다. **단일 노드에서는 재현 불가능한 고장**이며, 이 실험대를
|
|
||||||
2노드로 만든 이유 중 하나다
|
|
||||||
|
|
||||||
**주의** — Traefik이 replica 1이므로 그 파드가 있는 노드를 죽이면 진입점
|
|
||||||
자체가 사라질 수 있다. **훈련 3([`session-lab-operations.md`](session-lab-operations.md))에서
|
|
||||||
먼저 확인**하고 replica 조정 여부를 정한다.
|
|
||||||
|
|
||||||
**설정 근거** — 헤더 계약에서 확정한 값을 그대로 쓴다.
|
|
||||||
|
|
||||||
```
|
|
||||||
KC_HOSTNAME=https://auth.hyeonworks.com
|
|
||||||
KC_HOSTNAME_STRICT=true
|
|
||||||
KC_PROXY_HEADERS=xforwarded
|
|
||||||
KC_HTTP_ENABLED=true
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. persistent vs volatile 세션
|
|
||||||
|
|
||||||
브랜치: `feature/keycloak-persistent-vs-volatile-sessions`
|
|
||||||
|
|
||||||
**확인할 것**
|
|
||||||
|
|
||||||
- Keycloak 26 기본값은 `persistent-user-sessions` — 세션이 **DB가 진실의
|
|
||||||
원천**이다
|
|
||||||
- `--features-disabled=persistent-user-sessions`로 volatile 전환 시 비교
|
|
||||||
- **PostgreSQL을 죽이면** 각각 어떻게 되는가
|
|
||||||
- **노드 하나를 죽이면** 세션이 살아남는가
|
|
||||||
- **롤링 배포 시 로그아웃되는가** ← 운영에서 가장 자주 겪는 시나리오이며,
|
|
||||||
사실상 persistent를 켜는 진짜 이유다
|
|
||||||
|
|
||||||
이것이 "세션을 DB에 둘 때 vs 안 둘 때"의 Keycloak 버전이다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B층 — 애플리케이션 세션
|
|
||||||
|
|
||||||
공개 열린 질문 네 개에 대응한다. 각 질문이 요구하는 검증 단계는
|
|
||||||
[`open-questions-coverage.md`](open-questions-coverage.md)에 항목별로 있다.
|
|
||||||
|
|
||||||
**공통 선행 조건** — BFF 2 replica. 구현은 `develop-keycloak-pattern3`에
|
|
||||||
이미 있으므로 가져온다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git checkout develop-keycloak-pattern3 -- bff/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. BFF 저장소 결정 → [Q3](https://hyeonworks.com/questions/bff-session-authorized-client-store)
|
|
||||||
|
|
||||||
브랜치: `feature/keycloak-redis-app-session-store`
|
|
||||||
|
|
||||||
**질문의 핵심은 "Redis 도입"이 아니라 "Redis와 JDBC 중 무엇이 맞는가"다.**
|
|
||||||
PostgreSQL이 이미 있으므로 같은 조건에서 비교할 수 있다.
|
|
||||||
|
|
||||||
**Session과 Authorized Client는 조회 키가 다르다.**
|
|
||||||
|
|
||||||
| 상태 | 조회 키 |
|
|
||||||
|---|---|
|
|
||||||
| Application Session | **session ID** |
|
|
||||||
| OAuth2AuthorizedClient | **registration 이름 + principal name** |
|
|
||||||
|
|
||||||
`session ID`가 없으므로 **같은 사용자의 여러 브라우저가 동일한 authorized
|
|
||||||
client를 공유**한다. 따라서 **두 저장소를 각각 설계해야 한다.**
|
|
||||||
|
|
||||||
검증 5단계 — 로그인 유지·재시작 복구 / **refresh token 평문 여부** /
|
|
||||||
**session TTL ≠ token 만료** / logout 후 잔여 항목 / 저장소 끊김 시 오류.
|
|
||||||
|
|
||||||
### 4. 다중 인스턴스 운영 → [Q1](https://hyeonworks.com/questions/server-session-pattern-multi-instance)
|
|
||||||
|
|
||||||
브랜치: `feature/keycloak-multi-instance-session-operation` **(생성 필요)**
|
|
||||||
|
|
||||||
검증 5단계 — 다른 인스턴스로 요청 시 200 유지 / 재시작 후 session cookie /
|
|
||||||
**authorized client 덮어쓰기** / logout 전파 / 만료 어긋남.
|
|
||||||
|
|
||||||
**3번(덮어쓰기)이 특히 중요하다.** "Redis만 붙이면 해결"이라는 착각을 깨는
|
|
||||||
항목이다.
|
|
||||||
|
|
||||||
**질문의 제약 하나는 이미 해결법을 안다** — "Resource Server의 8081이 host에도
|
|
||||||
열려 있어 BFF만 거치도록 강제되지 않았다"는 2홉 실험의 **프록시 우회 경로와
|
|
||||||
같은 문제**이며, NetworkPolicy 패턴을 그대로 재사용한다.
|
|
||||||
|
|
||||||
호스트 nginx의 `ip_hash` 주석을 켜고 끄면 **스티키 유무 비교**까지 같은
|
|
||||||
구성에서 된다.
|
|
||||||
|
|
||||||
### 5. Refresh Token 경쟁 → [Q2](https://hyeonworks.com/questions/refresh-rotation-replica-contention)
|
|
||||||
|
|
||||||
브랜치: `feature/keycloak-refresh-token-concurrency`
|
|
||||||
|
|
||||||
**★ 3번 이후여야 한다.** 저장소가 process-local이면 두 replica가 같은 refresh
|
|
||||||
token 항목을 보지 않아 **경쟁 자체가 재현되지 않는다.**
|
|
||||||
|
|
||||||
검증 5단계 — 만료 직후 동시 요청 / 이긴 쪽·지는 쪽 응답 / **지는 쪽이 새
|
|
||||||
token으로 재시도해 성공하는가** / **지는 쪽 사용자 화면** /
|
|
||||||
**lock 유무를 같은 입력으로 비교**.
|
|
||||||
|
|
||||||
**마지막이 결론 기준이다** — *실패가 사용자에게 노출되면 lock, 노출되지 않으면
|
|
||||||
재시도.*
|
|
||||||
|
|
||||||
**제약** — rotation + 재사용 0회는 전제로 고정한다. 그리고 이미 발급된 access
|
|
||||||
token은 만료 전까지 통하므로 **재현은 access token 만료 직후에 맞춰 실행**한다.
|
|
||||||
|
|
||||||
### 6. Edge 인가 범위 → [Q4](https://hyeonworks.com/questions/edge-authorization-scope)
|
|
||||||
|
|
||||||
브랜치: `feature/keycloak-edge-authorization-scope` **(생성 필요)**
|
|
||||||
|
|
||||||
**확인할 것** — role을 헤더에 담고 **다중 값 구분자·escaping** / **헤더 크기
|
|
||||||
상한** 초과 시 자르는가 거부하는가 / role 변경이 **몇 번째 요청부터 반영**되는가
|
|
||||||
/ upstream이 헤더 존재만 보는가 값과 service identity까지 보는가.
|
|
||||||
|
|
||||||
2홉 실험에서 확인한 **nginx가 동명 헤더를 merge하지 않고 덮어쓴다**는 동작이
|
|
||||||
`X-Auth-Request-*`에도 적용되는지 같은 방법으로 검증한다.
|
|
||||||
|
|
||||||
질문의 제약 — internal token 검사가 controller 한 곳에만 있어 **공통 경계로
|
|
||||||
옮겨야** 한다. 코드 변경이므로 `backend/`에서 진행한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 공통 — 운영 역량
|
|
||||||
|
|
||||||
**여기부터는 "구성했다"가 아니라 "운영해봤다"에 필요한 항목이다.**
|
|
||||||
백업과 업그레이드는 빠지면 티가 난다.
|
|
||||||
|
|
||||||
### 7. 장애 주입과 복구
|
|
||||||
|
|
||||||
브랜치: `feature/keycloak-failure-injection-recovery`
|
|
||||||
|
|
||||||
| 주입 | 방법 |
|
|
||||||
|---|---|
|
|
||||||
| 노드 상실 | `virsh destroy` — 프로세스 kill 이 아닌 진짜 상실 |
|
|
||||||
| 비대칭 파티션 | 한쪽 게스트의 인바운드만 nftables 로 차단 |
|
|
||||||
| JGroups 7800 차단 | NetworkPolicy — 운영에서 쓸 방식 그대로 |
|
|
||||||
| DB 상실 | PostgreSQL 파드 정지 |
|
|
||||||
| Redis 상실 | Redis 파드 정지 |
|
|
||||||
| 지연 주입 | 게스트 안에서 `tc netem` — 커널이 분리돼 있어 안전 |
|
|
||||||
|
|
||||||
**복구 절차**를 각각 기록한다. 실제 장애의 대부분은 완전 사망이 아니라
|
|
||||||
**부분 장애**(느려짐, 일부 실패)이므로 netem 지연을 기본값으로 둔다.
|
|
||||||
|
|
||||||
### 8. 백업과 복구 리허설
|
|
||||||
|
|
||||||
**"백업이 있다"와 "복구해봤다"는 완전히 다르다.**
|
|
||||||
|
|
||||||
- `pg_dump`로 realm·세션·JGROUPS_PING 포함 전체 덤프
|
|
||||||
- **일부러 파괴** — PVC 삭제 또는 DB 드롭
|
|
||||||
- 덤프에서 복구하고 **로그인이 되는지, 기존 세션이 살아나는지** 확인
|
|
||||||
- 복구에 걸린 시간을 기록한다 (RTO)
|
|
||||||
- 백업 시점 이후 데이터가 무엇을 잃는지 확인한다 (RPO)
|
|
||||||
|
|
||||||
Redis 쪽은 `appendonly` 유무에 따른 차이를 함께 본다.
|
|
||||||
|
|
||||||
### 9. Keycloak 버전 업그레이드
|
|
||||||
|
|
||||||
**운영에서 가장 무서운 작업 중 하나다.** realm 마이그레이션과 **DB 스키마
|
|
||||||
변경이 자동으로 실행**되며, 실패하면 되돌리기 어렵다.
|
|
||||||
|
|
||||||
- 현재 26.7.0 → 다음 마이너로 이미지 태그 변경
|
|
||||||
- **업그레이드 전 백업**을 먼저 확보한다 (8번의 전제)
|
|
||||||
- 롤링 중 **기존 세션이 유지되는가** (2번의 persistent 설정과 연결된다)
|
|
||||||
- 스키마 변경 로그를 확인한다
|
|
||||||
- **롤백이 되는가** — 스키마가 바뀐 뒤에는 이전 버전이 뜨지 않을 수 있다
|
|
||||||
|
|
||||||
### 10. 관측성
|
|
||||||
|
|
||||||
지금은 `kubectl top`뿐이라 **장애 중 무슨 일이 있었는지 사후 추적이 안 된다.**
|
|
||||||
|
|
||||||
- `KC_METRICS_ENABLED=true` + `KC_HEALTH_ENABLED=true`
|
|
||||||
- Prometheus + Grafana 배포
|
|
||||||
- 볼 지표 — Infinispan 캐시 항목 수·축출, DB 커넥션 풀 사용률,
|
|
||||||
로그인 성공/실패율, **클러스터 멤버 수**
|
|
||||||
- 장애 주입(7번) 중에 **어떤 지표가 먼저 움직이는지** 기록한다
|
|
||||||
|
|
||||||
### 11. 비밀 관리
|
|
||||||
|
|
||||||
지금 방식대로면 client secret과 DB 비밀번호가 **매니페스트에 평문**으로 들어간다.
|
|
||||||
|
|
||||||
- k8s `Secret`으로 분리
|
|
||||||
- 저장소에는 `.example`만 커밋 (기존 `.env.example` 관례 그대로)
|
|
||||||
- 평문 Secret은 etcd에 base64로만 저장되므로 실제로는 감춰지지 않는다는 점을
|
|
||||||
확인한다 — `kubectl get secret -o yaml`로 직접 본다
|
|
||||||
- SealedSecret 또는 외부 저장소가 필요한 지점을 판단한다
|
|
||||||
|
|
||||||
### 12. 인증서 갱신 실측
|
|
||||||
|
|
||||||
90일을 기다리지 않고 강제로 겪는다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo certbot renew --force-renewal
|
|
||||||
```
|
|
||||||
|
|
||||||
- nginx reload 타이밍에 **무중단인가**
|
|
||||||
- 갱신 중 진행 중이던 요청은 어떻게 되는가
|
|
||||||
- `certbot-renew.timer`가 실제로 동작하는가 (`--dry-run`이 아니라 실제 갱신)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 스코프에서 제외한 것
|
|
||||||
|
|
||||||
### 이 실험대가 재현하지 못하는 것
|
|
||||||
|
|
||||||
| 항목 | 이유 |
|
|
||||||
|---|---|
|
|
||||||
| 성능·처리량 측정 | 단일 물리 머신의 숫자는 운영에 대해 아무것도 말해주지 않는다 |
|
|
||||||
| 실제 AZ 간 지연 | 한 박스 안이라 재현 불가. `tc netem` 으로 근사만 |
|
|
||||||
| ALB 고유 동작 | 자체 스티키 쿠키·60초 idle timeout 은 실물 ALB 가 있어야 한다 |
|
|
||||||
| PostgreSQL HA | 스코프 폭발. "죽으면 어떻게 되나"까지가 현실적 선 |
|
|
||||||
| 멀티 사이트 / cross-site Infinispan | 로컬에서 "사이트"가 가짜라 배우는 것이 적다 |
|
|
||||||
|
|
||||||
**이 실험대가 검증하는 것은 계약(정합성)이지 성능이 아니다.**
|
|
||||||
|
|
||||||
### 구조적으로 줄 수 없는 경험
|
|
||||||
|
|
||||||
실험 설계로는 만들 수 없는 것들. **무엇을 겪지 않았는지 아는 것도 기록의
|
|
||||||
일부다.**
|
|
||||||
|
|
||||||
| 없는 것 | 왜 |
|
|
||||||
|---|---|
|
|
||||||
| **규모** | 수천 세션에서의 커넥션 풀 고갈, Infinispan 캐시 축출 |
|
|
||||||
| **시간** | 몇 달 돌면서 드러나는 디스크 참, 로그 누적, 메모리 누수 |
|
|
||||||
| **다른 사람** | 동시에 만지는 사람, 온콜, 인수인계, "내가 안 바꿨는데 바뀌어 있음" |
|
|
||||||
| **실제 사용자** | 봇, 오래된 클라이언트, 예측 못 한 사용 패턴 |
|
|
||||||
| **클라우드 관리형 컴포넌트** | ALB·RDS·ElastiCache의 고유 동작과 **그것들의 장애 모드** |
|
|
||||||
| **비용** | 운영 판단의 큰 축인데 실험대엔 없다 |
|
|
||||||
| **보안 사고 대응** | 실제 침해, 토큰 유출 후 회수, 감사 로그 추적 |
|
|
||||||
|
|
||||||
**"시간"은 부분적으로 살 수 있다.** 실험이 끝나도 클러스터를 지우지 않고
|
|
||||||
몇 주 켜둔 채로 두면 인증서가 갱신되고, 로그가 쌓이고, 예상 못 한 것이
|
|
||||||
죽는다. 실험 설계로는 만들 수 없는 종류의 관찰이다.
|
|
||||||
|
|
||||||
### 따라서 말할 수 있는 것과 없는 것
|
|
||||||
|
|
||||||
**말할 수 있다** — Keycloak 멀티노드에서 세션과 토큰이 어디에 저장되고 각
|
|
||||||
저장소가 죽으면 무엇이 어떻게 실패하는지 재현하고 복구했다. 프록시 체인의
|
|
||||||
헤더 계약을 측정으로 확정했고, 신뢰 경계의 구멍을 실증하고 막았다.
|
|
||||||
|
|
||||||
**말하면 안 된다** — "운영해봤다", "대규모 트래픽을 다뤄봤다"
|
|
||||||
|
|
||||||
**그 경계를 정확히 구분해 말하는 것 자체가 이 기록의 목적이다.**
|
|
||||||
|
|
||||||
## 관련 문서
|
|
||||||
|
|
||||||
| 문서 | 내용 |
|
|
||||||
|---|---|
|
|
||||||
| [`open-questions-coverage.md`](open-questions-coverage.md) | 공개 열린 질문 4개와의 대조, 순서 근거 |
|
|
||||||
| [`session-lab-concepts.md`](session-lab-concepts.md) | 등장 개념 전체 (가상화·네트워크·k3s·TLS·패키지) |
|
|
||||||
| [`session-lab-operations.md`](session-lab-operations.md) | 관측 도구 · 자주 쓰는 명령 · 훈련 · 자원 예산 |
|
|
||||||
| [`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) | 첫 실험의 측정·진단·수정 |
|
|
||||||
| [`deploy/lab/README.md`](../deploy/lab/README.md) | 실험대 구축·복구 절차 |
|
|
||||||
| [`four-pattern-tradeoff-matrix.md`](four-pattern-tradeoff-matrix.md) | AP1~AP4 비교. "server session" 행이 B층 대상 |
|
|
||||||
| [`refresh-token-rotation.md`](refresh-token-rotation.md) | 회전 계약 (1홉·단일 노드 가정) |
|
|
||||||
| [`reverse-proxy-headers.md`](reverse-proxy-headers.md) | 1홉 헤더 계약 원본 |
|
|
||||||
@@ -1,816 +0,0 @@
|
|||||||
# 2홉 프록시 헤더 계약 — 측정·진단·적용
|
|
||||||
|
|
||||||
`docs/reverse-proxy-headers.md`의 계약은 **nginx 한 홉**을 가정하고 쓰였다.
|
|
||||||
실험대와 운영은 모두 **`nginx → Traefik` 두 홉**이므로 그 계약이 그대로
|
|
||||||
성립하는지 측정했다. **성립하지 않는다.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 왜 이것부터 재는가
|
|
||||||
|
|
||||||
Keycloak과 그 앞의 애플리케이션이 만드는 값 대부분이 **"원래 요청이
|
|
||||||
무엇이었나"** 에 의존한다.
|
|
||||||
|
|
||||||
| 만들어지는 값 | 의존하는 정보 |
|
|
||||||
|---|---|
|
|
||||||
| 토큰의 `iss` 클레임 | 외부 스킴 + 호스트 |
|
|
||||||
| OAuth2 `redirect_uri` | 외부 스킴 + 호스트 + 포트 |
|
|
||||||
| 세션 쿠키의 `Secure` 속성 | 외부 스킴 |
|
|
||||||
| brute-force 탐지·감사 로그 | 클라이언트 IP |
|
|
||||||
|
|
||||||
그런데 **TLS는 맨 앞 nginx가 끊는다.** 그 뒤로는 평문 HTTP가 흐르므로,
|
|
||||||
뒤쪽 구성요소는 원래 요청이 HTTPS였다는 사실을 **오직 `X-Forwarded-*`
|
|
||||||
헤더로만** 알 수 있다. 이 헤더가 중간에서 사라지거나 바뀌면 위 값이 전부
|
|
||||||
틀어진다.
|
|
||||||
|
|
||||||
Keycloak을 올린 뒤에 로그인이 깨지면 **세션 문제인지 프록시 문제인지 구분할
|
|
||||||
수 없다.** 그래서 Keycloak 없이 이 계약만 먼저 떼어내 측정했다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 구조 — 누가 어디에 사는가
|
|
||||||
|
|
||||||
### 물리적 배치
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─ test-server (호스트 OS · Arch · 베어메탈) ──────────────────┐
|
|
||||||
│ │
|
|
||||||
│ [스위치 1] nginx ← 호스트 OS 의 프로세스 │
|
|
||||||
│ /etc/nginx/sites-available/keycloak-lab │
|
|
||||||
│ = deploy/lab/host/nginx-keycloak-lab.conf │
|
|
||||||
│ │
|
|
||||||
│ ┌─ kc-lab-1 (VM) ─────────────┐ ┌─ kc-lab-2 (VM) ────────┐ │
|
|
||||||
│ │ svclb 파드 :80 │ │ svclb 파드 :80 │ │
|
|
||||||
│ │ ↓ │ │ └────────────────┼──┼─┐
|
|
||||||
│ │ [스위치 2] Traefik 파드 ◀──┼─┼────────────────────────┼──┼─┘
|
|
||||||
│ │ 클러스터 전체에 하나뿐 │ │ │ │
|
|
||||||
│ │ ↓ │ │ │ │
|
|
||||||
│ │ [스위치 3] 앱 파드 │ │ [스위치 3] 앱 파드 │ │
|
|
||||||
│ └─────────────────────────────┘ └────────────────────────┘ │
|
|
||||||
└──────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
| # | 무엇 | 사는 곳 | 설정 파일 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | nginx | **호스트 OS의 프로세스** | `deploy/lab/host/nginx-keycloak-lab.conf` |
|
|
||||||
| 2 | Traefik | **클러스터 안 파드 1개** | `HelmChartConfig` (kube-system) |
|
|
||||||
| 3 | 앱 | **클러스터 안 파드 N개** | 각 앱의 매니페스트 `env` |
|
|
||||||
|
|
||||||
### Traefik은 노드마다 있지 않다
|
|
||||||
|
|
||||||
k3s 기본값이 **replica 1**이다. 대신 **svclb**(klipper-lb) DaemonSet이 각
|
|
||||||
노드의 80/443 hostPort를 열어두고, 받은 트래픽을 **그 하나의 Traefik 파드로**
|
|
||||||
전달한다.
|
|
||||||
|
|
||||||
측정에서 8회 요청의 `x-forwarded-server`가 모두 같은 파드 이름이었던 것이
|
|
||||||
그 증거다. 이 사실은 **노드 상실 실험에서 변수**가 된다 — Traefik이 있는
|
|
||||||
노드를 죽이면 다른 노드의 svclb도 보낼 곳을 잃는다.
|
|
||||||
|
|
||||||
### "홉"은 노드 수가 아니라 프록시 계층 수다
|
|
||||||
|
|
||||||
```
|
|
||||||
홉 1 홉 2 목적지
|
|
||||||
호스트 nginx ──▶ Traefik ──▶ 앱 파드
|
|
||||||
(HTTP 를 봄) (HTTP 를 봄) (HTTP 를 봄)
|
|
||||||
```
|
|
||||||
|
|
||||||
**svclb는 홉으로 세지 않는다.** iptables 수준의 전달이라 HTTP를 아예 보지
|
|
||||||
않기 때문이다. 다만 SNAT를 하므로 **IP는 바꾼다.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 측정 장치
|
|
||||||
|
|
||||||
`backend`의 `/api/echo`가 **자신에게 실제로 도달한 것**을 그대로 돌려준다.
|
|
||||||
|
|
||||||
```
|
|
||||||
GET https://app1.hyeonworks.com/api/echo
|
|
||||||
→ { headers, remoteAddr, localAddr, scheme, secure, serverName, serverPort, requestUrl }
|
|
||||||
```
|
|
||||||
|
|
||||||
`scheme` · `secure` · `requestUrl`은 Keycloak이 `iss`와 redirect URL을 만들 때
|
|
||||||
쓰는 것과 **같은 종류의 값**이다. `localAddr`은 파드 IP이므로 어느 노드가
|
|
||||||
응답했는지 알려준다.
|
|
||||||
|
|
||||||
배포는 `deploy/lab/k8s/echo.yaml`, 실행은
|
|
||||||
`deploy/lab/scripts/measure-proxy-headers.sh`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 요청 흐름 — 홉마다 헤더가 어떻게 변하는가
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─ 1. 브라우저 ────────────────────────────────────────────────┐
|
|
||||||
│ GET /api/echo │
|
|
||||||
│ Host: app1.hyeonworks.com │
|
|
||||||
│ 전 구간 TLS 로 암호화 │
|
|
||||||
└──────────────────────────┬───────────────────────────────────┘
|
|
||||||
│ tailnet → 100.83.212.4:443
|
|
||||||
┌─ 2. 호스트 nginx ────────▼───────────────────────────────────┐
|
|
||||||
│ ★ TLS 종료 — 이 지점부터 평문 HTTP │
|
|
||||||
│ │
|
|
||||||
│ 원래 요청 정보를 헤더로 바꿔 붙인다: │
|
|
||||||
│ Host app1.hyeonworks.com │
|
|
||||||
│ X-Forwarded-Host app1.hyeonworks.com │
|
|
||||||
│ X-Forwarded-Proto https ← 원래 스킴 │
|
|
||||||
│ X-Forwarded-Port 443 │
|
|
||||||
│ X-Forwarded-For <클라이언트 IP> ($remote_addr 로 덮어씀) │
|
|
||||||
│ X-Real-IP <클라이언트 IP> │
|
|
||||||
└──────────────────────────┬───────────────────────────────────┘
|
|
||||||
│ upstream 라운드로빈
|
|
||||||
│ 192.168.122.11:80 또는 .12:80
|
|
||||||
┌─ 3. svclb (klipper-lb) ──▼───────────────────────────────────┐
|
|
||||||
│ 노드의 hostPort 80 에서 받아 iptables 로 전달 │
|
|
||||||
│ externalTrafficPolicy: Cluster → SNAT 발생 │
|
|
||||||
│ │
|
|
||||||
│ ★ 출발지 IP 가 노드의 flannel 게이트웨이로 바뀐다 │
|
|
||||||
│ → 클라이언트 IP 1차 소실 │
|
|
||||||
└──────────────────────────┬───────────────────────────────────┘
|
|
||||||
│ 10.42.0.8 (Traefik 파드)
|
|
||||||
┌─ 4. Traefik ─────────────▼───────────────────────────────────┐
|
|
||||||
│ Ingress 규칙 매칭: host app1.hyeonworks.com, path /api │
|
|
||||||
│ │
|
|
||||||
│ ★ forwardedHeaders.trustedIPs 미설정 │
|
|
||||||
│ → 들어온 X-Forwarded-* 를 신뢰하지 않고 │
|
|
||||||
│ 자기가 받은 연결을 기준으로 다시 쓴다 │
|
|
||||||
│ │
|
|
||||||
│ X-Forwarded-Proto https → http 자기가 받은 게 평문이므로 │
|
|
||||||
│ X-Forwarded-Port 443 → 80 │
|
|
||||||
│ X-Forwarded-For 실IP → 10.42.1.0 ← 2차 소실 │
|
|
||||||
│ X-Real-IP 실IP → 10.42.1.0 │
|
|
||||||
│ X-Forwarded-Host 유지 │
|
|
||||||
│ X-Forwarded-Server traefik-... 자기 이름 추가 │
|
|
||||||
└──────────────────────────┬───────────────────────────────────┘
|
|
||||||
│ Service → 파드
|
|
||||||
┌─ 5. 애플리케이션 ────────▼───────────────────────────────────┐
|
|
||||||
│ Spring: forward-headers-strategy = none │
|
|
||||||
│ → forwarded 헤더를 해석하지 않고 TCP 연결 그대로 보고 │
|
|
||||||
│ │
|
|
||||||
│ scheme http │
|
|
||||||
│ secure false │
|
|
||||||
│ requestUrl http://app1.hyeonworks.com/api/echo │
|
|
||||||
└──────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 측정 결과
|
|
||||||
|
|
||||||
| 헤더 | nginx 가 보낸 값 | 앱에 도달한 값 | |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `X-Forwarded-Proto` | `https` | **`http`** | 덮어써짐 |
|
|
||||||
| `X-Forwarded-Port` | `443` | **`80`** | 덮어써짐 |
|
|
||||||
| `X-Forwarded-For` | 클라이언트 IP | **`10.42.1.0`** | 소실 |
|
|
||||||
| `X-Real-IP` | 클라이언트 IP | **`10.42.1.0`** | 소실 |
|
|
||||||
| `X-Forwarded-Host` | `app1.hyeonworks.com` | `app1.hyeonworks.com` | 유지 |
|
|
||||||
|
|
||||||
앱이 최종적으로 보는 값:
|
|
||||||
|
|
||||||
```
|
|
||||||
scheme http
|
|
||||||
secure false
|
|
||||||
requestUrl http://app1.hyeonworks.com/api/echo
|
|
||||||
```
|
|
||||||
|
|
||||||
**위조 테스트** — 클라이언트가 `X-Forwarded-For: 1.2.3.4`,
|
|
||||||
`X-Forwarded-Host: evil.example.com`을 직접 붙여 요청했으나 **앱에 흔적이
|
|
||||||
도달하지 않았다.** 다만 이는 nginx가 막아서가 아니라 **Traefik이 전부
|
|
||||||
덮어썼기 때문**이다. 신뢰 경계는 결과적으로 작동하지만, 그 대가로 정당한
|
|
||||||
값까지 함께 버려진다.
|
|
||||||
|
|
||||||
**파드 분배** — 8회 요청이 두 파드(`10.42.0.9`, `10.42.1.3`)에 정확히 번갈아
|
|
||||||
도달했다. nginx upstream 라운드로빈과 Service 분배가 모두 작동한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5-1. 대조 실험 — 원인이 둘임을 분리한다
|
|
||||||
|
|
||||||
측정값만으로는 "누가 값을 바꿨는지" 알 수 없다. nginx를 우회해 Traefik에
|
|
||||||
직접 요청하여 원인을 분리했다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# test-server 에서, nginx 를 거치지 않고 노드의 Traefik 에 직접
|
|
||||||
curl -s http://192.168.122.11/api/echo \
|
|
||||||
-H 'Host: app1.hyeonworks.com' \
|
|
||||||
-H 'X-Forwarded-Proto: https' \
|
|
||||||
-H 'X-Forwarded-Port: 443' \
|
|
||||||
-H 'X-Forwarded-For: 203.0.113.7'
|
|
||||||
```
|
|
||||||
|
|
||||||
| | 보낸 값 | 도달한 값 |
|
|
||||||
|---|---|---|
|
|
||||||
| `X-Forwarded-Proto` | `https` | **`http`** |
|
|
||||||
| `X-Forwarded-Port` | `443` | **`80`** |
|
|
||||||
| `X-Forwarded-For` | `203.0.113.7` | **`10.42.0.1`** |
|
|
||||||
|
|
||||||
**올바른 헤더를 명시했는데도 전부 재작성됐다.** Traefik의 덮어쓰기가
|
|
||||||
독립적으로 증명된다.
|
|
||||||
|
|
||||||
그리고 이 과정에서 **두 번째 원인**이 드러났다.
|
|
||||||
|
|
||||||
### 원인 A — nginx가 애초에 틀린 값을 보내고 있다
|
|
||||||
|
|
||||||
`/etc/nginx/sites-available/keycloak-lab`의 443 서버 블록:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
listen 443 ssl default_server;
|
|
||||||
...
|
|
||||||
proxy_set_header X-Forwarded-Proto http; # ← https 여야 한다
|
|
||||||
proxy_set_header X-Forwarded-Port 80; # ← 443 이어야 한다
|
|
||||||
```
|
|
||||||
|
|
||||||
**TLS를 종료하는 서버가 "원래 요청은 평문이었다"고 알리고 있다.**
|
|
||||||
HTTP 전용으로 먼저 세운 뒤 TLS를 얹는 과정에서 이 두 줄을 함께 바꾸지 않아
|
|
||||||
남은 값이다.
|
|
||||||
|
|
||||||
문법 오류가 아니므로 `nginx -t`도 통과하고 **아무 경고 없이 잘못된 값이
|
|
||||||
전파된다.** 이런 종류의 실수는 측정 없이는 드러나지 않는다.
|
|
||||||
|
|
||||||
### 원인 B — Traefik이 올바른 값이 와도 덮어쓴다
|
|
||||||
|
|
||||||
위 대조 실험이 보여준 것이다. `forwardedHeaders.trustedIPs` 미설정.
|
|
||||||
|
|
||||||
**두 원인은 직렬로 걸려 있다. A만 고쳐도 B 때문에 해결되지 않는다.**
|
|
||||||
|
|
||||||
> 증거 원자료: `docs/evidence/two-hop-proxy-headers/`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 원인 — 독립된 스위치 세 개
|
|
||||||
|
|
||||||
이 사슬에는 **각각 따로 켜야 하는 스위치가 세 개** 있다.
|
|
||||||
**하나만 꺼져 있어도 정보가 끊긴다.**
|
|
||||||
|
|
||||||
| # | 위치 | 스위치 | 현재 | 하는 일 | 꺼져 있으면 |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| 1 | nginx | `proxy_set_header X-Forwarded-*` | **켜짐** | 헤더를 **만든다** | 헤더가 존재하지 않음 |
|
|
||||||
| 2 | Traefik | `forwardedHeaders.trustedIPs` | **꺼짐** | 받은 헤더를 **전달할지 버릴지** | **버리고 자기 값으로 재작성** |
|
|
||||||
| 3 | 앱 | `forward-headers-strategy` 등 | **꺼짐** | 도착한 헤더를 **읽어서 반영할지** | 헤더가 와 있어도 무시 |
|
|
||||||
|
|
||||||
지금은 2번에서 끊긴다. 2번을 고쳐도 3번을 켜지 않으면 앱은 여전히 원래
|
|
||||||
스킴을 모른다.
|
|
||||||
|
|
||||||
### 3번을 구체적으로
|
|
||||||
|
|
||||||
헤더는 **이미 앱에 도착해 있다.** `/api/echo` 출력에 `x-forwarded-proto: http`가
|
|
||||||
찍혔다. 도착은 했다.
|
|
||||||
|
|
||||||
그런데 앱이 `request.getScheme()`을 부르면 `http`가 나온다.
|
|
||||||
**헤더를 읽지 않고 TCP 연결 자체를 보기 때문**이다.
|
|
||||||
`forward-headers-strategy=native`를 켜면 Tomcat이 헤더를 읽어서
|
|
||||||
**요청 객체의 scheme·host·port·remoteAddr를 갈아끼운다.**
|
|
||||||
|
|
||||||
즉 3번은 **"도착한 헤더를 진짜로 믿고 내 요청 정보를 바꿔칠까"** 의 스위치다.
|
|
||||||
|
|
||||||
### 3번은 앱마다 하나씩이다
|
|
||||||
|
|
||||||
1번과 2번은 한 번 켜면 끝이지만 **3번은 새 앱을 올릴 때마다 따로 켜야 한다.**
|
|
||||||
|
|
||||||
```
|
|
||||||
[1] nginx 1개 고정
|
|
||||||
[2] Traefik 1개 고정
|
|
||||||
[3] 앱 N개 Keycloak · BFF · oauth2-proxy · backend API …
|
|
||||||
```
|
|
||||||
|
|
||||||
그리고 **빠뜨려도 오류가 나지 않고 조용히 틀린 값으로 동작**한다.
|
|
||||||
이것이 이 계약을 문서로 고정해두어야 하는 이유다.
|
|
||||||
|
|
||||||
### 기본값이 "믿지 않음"인 것은 의도된 설계다
|
|
||||||
|
|
||||||
`X-Forwarded-*`는 **누구나 위조할 수 있는 평범한 HTTP 헤더**다.
|
|
||||||
"누구로부터 온 것을 믿을지"를 명시하지 않으면 **신뢰하지 않는 쪽이 안전**하다.
|
|
||||||
프레임워크들이 하나같이 기본값을 꺼두는 이유다.
|
|
||||||
|
|
||||||
### 네 번째 요인 — `externalTrafficPolicy: Cluster`
|
|
||||||
|
|
||||||
svclb가 트래픽을 SNAT하면서 클라이언트 IP가 Traefik에 도달하기 전에 이미
|
|
||||||
사라진다. 2번을 고치면 `X-Forwarded-For`에 담긴 nginx의 값은 살아나지만,
|
|
||||||
**TCP 출발지 주소 자체는 복원되지 않는다.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 앱 스위치를 켜는 방법
|
|
||||||
|
|
||||||
### Spring Boot
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
server:
|
|
||||||
forward-headers-strategy: native # none | native | framework
|
|
||||||
```
|
|
||||||
|
|
||||||
환경변수는 `SERVER_FORWARD_HEADERS_STRATEGY=native`.
|
|
||||||
|
|
||||||
| 값 | 구현 | 신뢰 IP 제한 |
|
|
||||||
|---|---|---|
|
|
||||||
| `none` (기본) | 무시 | — |
|
|
||||||
| **`native`** | 서블릿 컨테이너 기능 (Tomcat `RemoteIpValve`) | **있음** |
|
|
||||||
| `framework` | Spring `ForwardedHeaderFilter` | **없음 — 무조건 신뢰** |
|
|
||||||
|
|
||||||
**`native`를 권하는 이유가 마지막 열이다.** Tomcat의 `RemoteIpValve`는
|
|
||||||
`internalProxies` 기본 정규식(`10.x`, `192.168.x`, `172.16~31.x`, `127.x`)에
|
|
||||||
해당하는 **출발지에서 온 요청만** 헤더를 반영한다. 파드 IP가 `10.42.x`라
|
|
||||||
기본값에 들어간다.
|
|
||||||
|
|
||||||
`framework`는 그런 필터가 없어 **누가 보내든 믿는다.**
|
|
||||||
|
|
||||||
**켜면 실제로 무슨 일이 일어나나** — 밸브가 요청 객체를 갈아끼운다.
|
|
||||||
|
|
||||||
```
|
|
||||||
X-Forwarded-For → request.getRemoteAddr()
|
|
||||||
X-Forwarded-Proto → request.getScheme(), isSecure()
|
|
||||||
X-Forwarded-Port → request.getServerPort()
|
|
||||||
X-Forwarded-Host → request.getServerName()
|
|
||||||
→ 그 결과 getRequestURL() 이 외부 URL 로 재구성됨
|
|
||||||
```
|
|
||||||
|
|
||||||
**애플리케이션 코드는 한 줄도 고치지 않는다.** 프레임워크가 요청 정보를
|
|
||||||
바꿔서 넘겨준다.
|
|
||||||
|
|
||||||
### Keycloak
|
|
||||||
|
|
||||||
```
|
|
||||||
KC_PROXY_HEADERS=xforwarded # xforwarded | forwarded
|
|
||||||
```
|
|
||||||
|
|
||||||
| 값 | 읽는 헤더 |
|
|
||||||
|---|---|
|
|
||||||
| `xforwarded` | `X-Forwarded-For`, `-Proto`, `-Host`, `-Port` (관례) |
|
|
||||||
| `forwarded` | RFC 7239의 `Forwarded:` 단일 헤더 |
|
|
||||||
| 미설정 | 무시 |
|
|
||||||
|
|
||||||
**Keycloak은 방어가 두 겹이다.**
|
|
||||||
|
|
||||||
| 설정 | 담당 |
|
|
||||||
|---|---|
|
|
||||||
| `KC_HOSTNAME=https://auth...` | 스킴·호스트를 **고정** — 헤더와 무관 |
|
|
||||||
| `KC_PROXY_HEADERS=xforwarded` | **클라이언트 IP** 등 나머지를 헤더에서 |
|
|
||||||
|
|
||||||
그래서 `iss`는 `KC_HOSTNAME`만으로도 살아난다. 하지만 brute-force 탐지와
|
|
||||||
감사 로그의 IP는 `KC_PROXY_HEADERS`가 있어야 맞는다.
|
|
||||||
|
|
||||||
> 예전 `KC_PROXY=edge` 옵션은 Keycloak 24에서 deprecated 되고
|
|
||||||
> `KC_PROXY_HEADERS`로 대체됐다. 오래된 예제 참고 시 주의.
|
|
||||||
|
|
||||||
### oauth2-proxy
|
|
||||||
|
|
||||||
```
|
|
||||||
--reverse-proxy=true # 또는 OAUTH2_PROXY_REVERSE_PROXY=true
|
|
||||||
```
|
|
||||||
|
|
||||||
신뢰 IP 제한 기능이 없어 **무조건 신뢰**한다.
|
|
||||||
|
|
||||||
### 뒤쪽에 nginx가 있는 경우
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
set_real_ip_from 10.42.0.0/16;
|
|
||||||
real_ip_header X-Forwarded-For;
|
|
||||||
real_ip_recursive on;
|
|
||||||
```
|
|
||||||
|
|
||||||
신뢰 IP 지정이 **필수**다. `set_real_ip_from` 없이는 동작하지 않는다.
|
|
||||||
|
|
||||||
### 공통 원리
|
|
||||||
|
|
||||||
어느 프레임워크든 결국 **두 가지를 정하는 일**이다.
|
|
||||||
|
|
||||||
1. **어떤 헤더 형식을 읽을지** — `X-Forwarded-*` vs RFC 7239 `Forwarded`
|
|
||||||
2. **누구로부터 온 것을 믿을지** — 신뢰 프록시 IP 목록
|
|
||||||
|
|
||||||
두 번째가 있는 구현이 안전하다. Spring `native`와 nginx `real_ip`는 있고,
|
|
||||||
Spring `framework`와 oauth2-proxy는 없다.
|
|
||||||
|
|
||||||
### 켤 때 반드시 같이 봐야 하는 것
|
|
||||||
|
|
||||||
**앱에 프록시를 거치지 않고 직접 도달할 경로가 있으면 안 된다.**
|
|
||||||
|
|
||||||
헤더 신뢰를 켠 상태에서 공격자가 앱에 직접 요청하며
|
|
||||||
`X-Forwarded-Proto: https`를 붙이면, 앱은 그걸 믿고 **`Secure` 쿠키를
|
|
||||||
발급하거나 IP 기반 제한을 우회**당한다.
|
|
||||||
|
|
||||||
쿠버네티스에서는 Service ClusterIP로 파드에 직접 접근할 수 있으므로,
|
|
||||||
**NetworkPolicy로 Traefik에서 오는 트래픽만 허용**하는 것이 정석이다.
|
|
||||||
이 클러스터는 kube-router 내장 컨트롤러가 있어 적용 가능하다.
|
|
||||||
|
|
||||||
**"헤더를 믿는다"는 결정과 "그 앞에 반드시 프록시가 있다"는 보장은 한 쌍이다.**
|
|
||||||
한쪽만 하면 구멍이 된다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 영향 — 패턴별
|
|
||||||
|
|
||||||
| 패턴 | 의존도 | 무엇이 깨지나 |
|
|
||||||
|---|---|---|
|
|
||||||
| AP1 SPA direct | 낮음 | 브라우저가 Keycloak에 직접 감. Keycloak 자체만 필요 |
|
|
||||||
| AP2 token mediator | 중간 | mediator의 redirect URI |
|
|
||||||
| **AP3 BFF** | **높음** | **로그인 자체가 실패**, 세션 쿠키 `Secure` 유실 |
|
|
||||||
| **AP4 edge auth** | **결정적** | **인증 결과가 헤더로 전달됨** |
|
|
||||||
|
|
||||||
### AP3(BFF)에서 왜 중요한가
|
|
||||||
|
|
||||||
**흔한 오해 — "BFF가 넘기는 헤더는 세션 쿠키뿐인데?"**
|
|
||||||
|
|
||||||
쿠키를 **전달하는 것**은 문제가 아니다. 프록시는 `Cookie:` 헤더를 그대로
|
|
||||||
통과시킨다. 문제는 **쿠키와 URL을 만들 때**이고, 그 재료가 `X-Forwarded-*`다.
|
|
||||||
|
|
||||||
**(1) OAuth2 `redirect_uri` 생성 — 가장 먼저 터진다**
|
|
||||||
|
|
||||||
BFF는 Spring Security의 `oauth2Login`을 쓴다. Keycloak으로 사용자를 보낼 때
|
|
||||||
`redirect_uri` 파라미터를 **현재 요청 URL로부터** 만든다.
|
|
||||||
|
|
||||||
```
|
|
||||||
redirect_uri = {scheme}://{serverName}:{serverPort}/login/oauth2/code/keycloak
|
|
||||||
↑ request.getScheme() 에서 온다
|
|
||||||
```
|
|
||||||
|
|
||||||
`scheme=http`면 이렇게 나간다.
|
|
||||||
|
|
||||||
```
|
|
||||||
redirect_uri=http://app1.hyeonworks.com/login/oauth2/code/keycloak
|
|
||||||
```
|
|
||||||
|
|
||||||
그러면 두 가지 중 하나가 벌어진다.
|
|
||||||
|
|
||||||
- Keycloak 클라이언트에 `https://...`만 등록돼 있으면
|
|
||||||
→ **`invalid_redirect_uri` 오류로 로그인 거부**
|
|
||||||
- 실수로 `http://...`도 등록해뒀다면
|
|
||||||
→ 브라우저가 https 페이지에서 http로 리다이렉트 →
|
|
||||||
**혼합 콘텐츠 차단 또는 세션 쿠키 유실**
|
|
||||||
|
|
||||||
**세션 쿠키 문제보다 먼저, 로그인 자체가 안 된다.**
|
|
||||||
|
|
||||||
**(2) 세션 쿠키의 `Secure` 속성**
|
|
||||||
|
|
||||||
서블릿 컨테이너는 `request.isSecure()`를 보고 `Set-Cookie`에 `Secure`를
|
|
||||||
붙일지 정한다. `isSecure()`가 `false`면 **`Secure` 없는 세션 쿠키**가 나간다.
|
|
||||||
|
|
||||||
| 결과 | 내용 |
|
|
||||||
|---|---|
|
|
||||||
| 평문 전송 위험 | 그 쿠키는 http 요청에도 실려 나간다. 중간자가 세션을 탈취할 수 있다 |
|
|
||||||
| **`SameSite=None` 사용 불가** | 브라우저는 `Secure` 없는 `SameSite=None` 쿠키를 **거부**한다 |
|
|
||||||
|
|
||||||
두 번째가 AP3의 학습 주제와 정면으로 부딪힌다. AP3는 `oauth2Login` 세션과
|
|
||||||
**CSRF·SameSite 방어**가 핵심인데, `Secure`가 없으면 `SameSite` 설계
|
|
||||||
선택지가 통째로 사라진다.
|
|
||||||
|
|
||||||
**(3) 로그아웃 `post_logout_redirect_uri`**
|
|
||||||
|
|
||||||
같은 원리로 http가 박히고, Keycloak에 등록된 값과 불일치해 거부된다.
|
|
||||||
|
|
||||||
**정리하면** — BFF에서 헤더 계약이 중요한 이유는 쿠키를 *전달*하기 때문이
|
|
||||||
아니라, **쿠키와 OAuth2 URL을 *생성*하는 재료이기 때문**이다.
|
|
||||||
|
|
||||||
### AP4(edge auth)에서 왜 결정적인가
|
|
||||||
|
|
||||||
AP4는 **헤더 신뢰가 패턴의 존재 이유 자체**다.
|
|
||||||
|
|
||||||
oauth2-proxy는 인증을 끝내고 **결과를 헤더로 downstream에 넘긴다.**
|
|
||||||
|
|
||||||
```
|
|
||||||
X-Auth-Request-User
|
|
||||||
X-Auth-Request-Email
|
|
||||||
X-Auth-Request-Groups
|
|
||||||
X-Auth-Request-Access-Token
|
|
||||||
```
|
|
||||||
|
|
||||||
downstream 앱은 **이 헤더를 믿고 "누가 로그인했는지"를 판단**한다. 토큰을
|
|
||||||
직접 검증하지 않는다. 그것이 AP4가 "기존 upstream을 수정하지 않고 경계에서
|
|
||||||
일괄 인증"할 수 있는 이유다.
|
|
||||||
|
|
||||||
**그래서 여기서 헤더 신뢰가 무너지면 인증 우회가 된다.**
|
|
||||||
|
|
||||||
```
|
|
||||||
공격자가 직접: X-Auth-Request-User: admin
|
|
||||||
프록시가 안 덮어쓰면 → downstream 은 admin 으로 인식
|
|
||||||
```
|
|
||||||
|
|
||||||
지금 측정한 `X-Forwarded-*` 문제와 **구조가 완전히 같다.** 헤더 이름과
|
|
||||||
의미만 다르다.
|
|
||||||
|
|
||||||
| 헤더군 | 담는 정보 | 위조되면 |
|
|
||||||
|---|---|---|
|
|
||||||
| `X-Forwarded-*` | 원래 요청이 어땠나 | 쿠키 속성·URL이 틀어짐 |
|
|
||||||
| `X-Auth-Request-*` | **누가 인증됐나** | **신원 위조 = 인증 우회** |
|
|
||||||
|
|
||||||
저장소의 `feature/keycloak-header-spoofing-defense` 브랜치
|
|
||||||
(manifest: `ap4 / locally-verified`)가 이 문제를 다룬다.
|
|
||||||
**지금 확정하는 2홉 계약이 그 브랜치의 전제**다 — 1홉 가정으로 검증된
|
|
||||||
방어가 2홉에서도 유효한지 다시 확인해야 한다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 해결 — 어느 파일의 무엇을 어떻게 고치는가
|
|
||||||
|
|
||||||
세 곳을 순서대로 고쳤다. **각 단계마다 측정하여 어느 스위치가 무엇을
|
|
||||||
담당하는지 데이터로 확인했다.**
|
|
||||||
|
|
||||||
### A. nginx — 원래 스킴을 사실대로 알린다
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| 저장소 파일 | `deploy/lab/host/nginx-keycloak-lab.conf` |
|
|
||||||
| 서버 배포 위치 | `/etc/nginx/sites-available/keycloak-lab` |
|
|
||||||
| 활성화 | `/etc/nginx/sites-enabled/keycloak-lab` 심볼릭 링크 |
|
|
||||||
|
|
||||||
`server { listen 443 ssl ... }` 블록의 `location /` 안에서 두 줄을 고친다.
|
|
||||||
|
|
||||||
```diff
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
|
||||||
- proxy_set_header X-Forwarded-Proto http;
|
|
||||||
- proxy_set_header X-Forwarded-Port 80;
|
|
||||||
+ proxy_set_header X-Forwarded-Proto https;
|
|
||||||
+ proxy_set_header X-Forwarded-Port 443;
|
|
||||||
proxy_set_header X-Forwarded-For $remote_addr;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
```
|
|
||||||
|
|
||||||
**`listen 443 ssl`과 `proxy_pass http://`는 건드리지 않는다.**
|
|
||||||
앞에서 HTTPS로 받고 뒤로 평문으로 보내는 것은 의도된 설계다.
|
|
||||||
고치는 것은 **뒤로 보낼 때 붙이는 라벨**뿐이다.
|
|
||||||
|
|
||||||
배포:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/workspace/keycloak-pattern && git pull
|
|
||||||
sudo cp deploy/lab/host/nginx-keycloak-lab.conf /etc/nginx/sites-available/keycloak-lab
|
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
|
||||||
grep -n 'X-Forwarded-Proto\|X-Forwarded-Port' /etc/nginx/sites-available/keycloak-lab
|
|
||||||
```
|
|
||||||
|
|
||||||
**손으로 서버 파일을 고치지 않는다.** 저장소에서 단방향으로 복사한다.
|
|
||||||
이 실수가 발생한 원인 자체가 저장소와 서버의 드리프트였다.
|
|
||||||
|
|
||||||
### B. Traefik — nginx의 헤더를 신뢰한다
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| 저장소 파일 | `deploy/lab/k8s/traefik-forwarded-headers.yaml` (신규) |
|
|
||||||
| 적용 대상 | `kube-system` 네임스페이스의 Traefik HelmChart |
|
|
||||||
|
|
||||||
k3s의 Traefik은 번들 HelmChart로 설치되므로 **Deployment를 직접 고치면
|
|
||||||
안 된다.** helm-controller가 되돌린다. `HelmChartConfig`로 차트 값을
|
|
||||||
덮어써야 한다.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: helm.cattle.io/v1
|
|
||||||
kind: HelmChartConfig
|
|
||||||
metadata:
|
|
||||||
name: traefik
|
|
||||||
namespace: kube-system
|
|
||||||
spec:
|
|
||||||
valuesContent: |-
|
|
||||||
ports:
|
|
||||||
web:
|
|
||||||
forwardedHeaders:
|
|
||||||
trustedIPs:
|
|
||||||
- 10.42.0.0/16 # 파드 대역 (svclb SNAT 출발지)
|
|
||||||
- 192.168.122.0/24 # 노드·호스트 대역
|
|
||||||
websecure:
|
|
||||||
forwardedHeaders:
|
|
||||||
trustedIPs:
|
|
||||||
- 10.42.0.0/16
|
|
||||||
- 192.168.122.0/24
|
|
||||||
```
|
|
||||||
|
|
||||||
**`10.42.0.0/16`이 필요한 이유** — traefik Service가
|
|
||||||
`externalTrafficPolicy: Cluster`이므로 svclb가 SNAT한다. Traefik이 보는
|
|
||||||
출발지는 호스트 nginx의 주소가 아니라 **파드 네트워크 주소**다.
|
|
||||||
|
|
||||||
적용:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl apply -f deploy/lab/k8s/traefik-forwarded-headers.yaml
|
|
||||||
kubectl -n kube-system rollout status deploy/traefik --timeout=180s
|
|
||||||
```
|
|
||||||
|
|
||||||
**함정 — `rollout status` 완료가 곧 반영은 아니다.** helm-controller가
|
|
||||||
`helm-install-traefik` **Job을 새로 돌려** 차트를 업그레이드하므로, 그 사이
|
|
||||||
**구 파드가 잠시 함께 살아 있다.** 이 시점에 측정하면 옛 파드가 응답해
|
|
||||||
"고쳤는데 안 바뀌었다"고 오해하게 된다. 실제로 이 함정에 한 번 걸렸다.
|
|
||||||
|
|
||||||
파드 이름과 인자로 확인한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl -n kube-system get pods -l app.kubernetes.io/name=traefik
|
|
||||||
kubectl -n kube-system get pod -l app.kubernetes.io/name=traefik \
|
|
||||||
-o jsonpath='{.items[0].spec.containers[0].args}' | tr ',' '\n' | grep -i forwarded
|
|
||||||
# --entryPoints.web.forwardedHeaders.trustedIPs=10.42.0.0/16,192.168.122.0/24
|
|
||||||
```
|
|
||||||
|
|
||||||
**트레이드오프** — 파드 대역 전체를 신뢰하면 **클러스터 안의 어떤 파드든
|
|
||||||
헤더를 위조할 수 있다.** 실험대에서는 받아들일 만하지만 운영에서는 좁혀야
|
|
||||||
한다. 좁히려면 `externalTrafficPolicy: Local`로 SNAT를 없애고 실제
|
|
||||||
출발지(`192.168.122.1`)만 신뢰하는 방법이 있으나, 그러면 해당 노드에 Traefik
|
|
||||||
파드가 없을 때 트래픽이 버려진다.
|
|
||||||
|
|
||||||
### C. 앱 — 도착한 헤더를 해석한다
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| 저장소 파일 | `deploy/lab/k8s/echo.yaml` |
|
|
||||||
| 위치 | `spec.template.spec.containers[0].env` |
|
|
||||||
|
|
||||||
```diff
|
|
||||||
- name: SERVER_FORWARD_HEADERS_STRATEGY
|
|
||||||
- value: "none"
|
|
||||||
+ value: "native"
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl apply -f deploy/lab/k8s/echo.yaml
|
|
||||||
kubectl -n header-lab rollout status deployment/echo --timeout=180s
|
|
||||||
```
|
|
||||||
|
|
||||||
앱마다 스위치 이름이 다르다.
|
|
||||||
|
|
||||||
| 앱 | 설정 | 넣는 곳 |
|
|
||||||
|---|---|---|
|
|
||||||
| Spring Boot | `SERVER_FORWARD_HEADERS_STRATEGY=native` | 컨테이너 `env` |
|
|
||||||
| **Keycloak** | **`KC_PROXY_HEADERS=xforwarded`** | 컨테이너 `env` |
|
|
||||||
| oauth2-proxy | `--reverse-proxy=true` | 컨테이너 `args` |
|
|
||||||
|
|
||||||
**새 앱을 올릴 때마다 반복해야 한다.** 빠뜨려도 오류가 나지 않는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 단계별 측정 결과
|
|
||||||
|
|
||||||
각 스위치를 하나씩 켜면서 측정한 값이다.
|
|
||||||
**어느 스위치가 무엇을 담당하는지가 그대로 드러난다.**
|
|
||||||
|
|
||||||
| 측정 항목 | 최초 | A 이후 | B 이후 | **C 이후** |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| nginx 가 보내는 값 | `http`/`80` | `https`/`443` | `https`/`443` | `https`/`443` |
|
|
||||||
| `x-forwarded-proto` | `http` | **`http`** | `https` | `https` |
|
|
||||||
| `x-forwarded-port` | `80` | **`80`** | `443` | `443` |
|
|
||||||
| `x-real-ip` | `10.42.1.0` | `10.42.1.0` | `100.123.124.30` | `100.123.124.30` |
|
|
||||||
| `scheme` (앱 해석) | `http` | `http` | **`http`** | **`https`** |
|
|
||||||
| `secure` | `false` | `false` | **`false`** | **`true`** |
|
|
||||||
| `requestUrl` | `http://…` | `http://…` | `http://…` | **`https://…`** |
|
|
||||||
|
|
||||||
**A 이후에 아무것도 바뀌지 않은 것**이 Traefik의 덮어쓰기를 증명한다.
|
|
||||||
nginx가 올바른 값을 보내는데도 앱에는 `http`가 도달했다.
|
|
||||||
|
|
||||||
**B 이후에 헤더는 살아났지만 앱 해석은 그대로**인 것이 2번과 3번 스위치가
|
|
||||||
서로 다른 일을 한다는 증거다. 헤더는 도착해 있었지만 앱이 읽지 않았다.
|
|
||||||
|
|
||||||
**C 이후에야 앱이 원래 요청을 인식한다.**
|
|
||||||
|
|
||||||
최종 상태:
|
|
||||||
|
|
||||||
```
|
|
||||||
x-forwarded-proto https
|
|
||||||
x-forwarded-port 443
|
|
||||||
x-real-ip 100.123.124.30 ← 실제 클라이언트(워크스테이션 tailnet IP)
|
|
||||||
scheme https
|
|
||||||
secure True
|
|
||||||
serverPort 443
|
|
||||||
remoteAddr 100.123.124.30
|
|
||||||
requestUrl https://app1.hyeonworks.com/api/echo
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 검증
|
|
||||||
|
|
||||||
### 정상 동작
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool
|
|
||||||
```
|
|
||||||
|
|
||||||
| 항목 | 기대값 |
|
|
||||||
|---|---|
|
|
||||||
| `x-forwarded-proto` | `https` |
|
|
||||||
| `x-forwarded-port` | `443` |
|
|
||||||
| `x-real-ip` | 실제 클라이언트 IP |
|
|
||||||
| `scheme` | `https` |
|
|
||||||
| `secure` | `true` |
|
|
||||||
| `requestUrl` | `https://app1.hyeonworks.com/api/echo` |
|
|
||||||
|
|
||||||
### 위조 차단 — 이쪽이 더 중요하다
|
|
||||||
|
|
||||||
헤더 신뢰를 켠 뒤에는 **위조가 여전히 막히는지 반드시 확인해야 한다.**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s https://app1.hyeonworks.com/api/echo \
|
|
||||||
-H 'X-Forwarded-Proto: http' \
|
|
||||||
-H 'X-Forwarded-Host: evil.example.com' \
|
|
||||||
-H 'X-Forwarded-For: 1.2.3.4' \
|
|
||||||
-H 'X-Real-IP: 1.2.3.4' | python3 -m json.tool
|
|
||||||
```
|
|
||||||
|
|
||||||
**주입한 값이 하나도 반영되지 않아야 한다.** 실측 결과 `scheme=https`,
|
|
||||||
`serverName=app1.hyeonworks.com`, `remoteAddr=100.123.124.30`이 유지됐다.
|
|
||||||
|
|
||||||
방어의 주체가 바뀌었다는 점에 유의한다.
|
|
||||||
|
|
||||||
| | 수정 전 | 수정 후 |
|
|
||||||
|---|---|---|
|
|
||||||
| 위조를 막는 주체 | **Traefik** (전부 덮어씀) | **nginx** (`$remote_addr`로 덮어씀) |
|
|
||||||
| 대가 | 정당한 값도 함께 버려짐 | 없음 |
|
|
||||||
|
|
||||||
**따라서 nginx의 `$remote_addr` 사용은 선택이 아니라 필수다.**
|
|
||||||
`$proxy_add_x_forwarded_for`(덧붙이기)로 바꾸면 클라이언트가 넣은 값이
|
|
||||||
사슬 앞부분에 남아 신뢰 경계가 무너진다.
|
|
||||||
|
|
||||||
### 프록시 우회 경로 차단
|
|
||||||
|
|
||||||
앱이 헤더를 신뢰하게 되면 **Traefik을 거치지 않고 파드에 직접 도달할 수
|
|
||||||
있는 경로가 곧 구멍**이 된다. 클러스터 안에서는 Service ClusterIP로 접근할
|
|
||||||
수 있으므로 실제로 위조가 성립했다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl -n header-lab run t --rm -i --restart=Never --image=curlimages/curl -- \
|
|
||||||
curl -s http://echo:8081/api/echo \
|
|
||||||
-H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: evil.example.com' -H 'X-Forwarded-For: 1.2.3.4'
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
serverName evil.example.com ← 위조 성공
|
|
||||||
remoteAddr 1.2.3.4 ← 위조 성공
|
|
||||||
requestUrl https://evil.example.com/api/echo
|
|
||||||
```
|
|
||||||
|
|
||||||
**두 신뢰 설정이 모두 "대역"을 믿기 때문**이다.
|
|
||||||
|
|
||||||
| 계층 | 신뢰 범위 | 지정 방식 |
|
|
||||||
|---|---|---|
|
|
||||||
| Traefik `trustedIPs` | 파드 대역 전체 | IP 대역 |
|
|
||||||
| 앱 Tomcat `internalProxies` | 사설 대역 전체 (기본 정규식) | IP 정규식 |
|
|
||||||
|
|
||||||
IP로는 Traefik을 특정할 수 없다. **파드 IP가 재시작마다 바뀌기 때문**이다
|
|
||||||
(측정 중 실제로 `10.42.0.8` → `10.42.1.12`로, 노드까지 옮겨갔다).
|
|
||||||
|
|
||||||
**해결 — NetworkPolicy는 IP가 아니라 라벨로 지정한다.**
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| 저장소 파일 | `deploy/lab/k8s/echo-network-policy.yaml` |
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
ingress:
|
|
||||||
- from:
|
|
||||||
- namespaceSelector:
|
|
||||||
matchLabels:
|
|
||||||
kubernetes.io/metadata.name: kube-system
|
|
||||||
podSelector:
|
|
||||||
matchLabels:
|
|
||||||
app.kubernetes.io/name: traefik # ← IP 가 아니라 라벨
|
|
||||||
ports:
|
|
||||||
- protocol: TCP
|
|
||||||
port: 8081
|
|
||||||
```
|
|
||||||
|
|
||||||
`namespaceSelector`와 `podSelector`를 **같은 리스트 항목**에 두면 AND로
|
|
||||||
결합된다. 별개 항목으로 나누면 OR이 되어 kube-system 전체가 허용되므로
|
|
||||||
주의한다.
|
|
||||||
|
|
||||||
**kubelet probe를 위한 규칙이 별도로 필요하다.** readiness/liveness는 파드가
|
|
||||||
아니라 노드에서 오므로 위 규칙에 걸리지 않는다. 빠뜨리면 probe가 실패하고
|
|
||||||
**파드가 재시작 루프에 빠진다.**
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- from:
|
|
||||||
- ipBlock: { cidr: 10.42.0.1/32 } # kc-lab-1 의 cni0
|
|
||||||
- ipBlock: { cidr: 10.42.1.1/32 } # kc-lab-2 의 cni0
|
|
||||||
```
|
|
||||||
|
|
||||||
probe의 출발지는 **노드의 flannel 브리지(cni0)** 이고, 각 노드 `/24`의 첫
|
|
||||||
주소다. `/32`로 정확히 지정해야 한다 — `10.42.0.0/16`으로 넓히면 임의의
|
|
||||||
파드가 다시 들어와 정책이 무의미해진다.
|
|
||||||
|
|
||||||
**적용 후 확인**
|
|
||||||
|
|
||||||
```
|
|
||||||
정상 경로 scheme=https, remoteAddr=100.123.124.30 계속 동작
|
|
||||||
우회 시도 HTTP 000 / curl exit 7 연결 자체가 거부됨
|
|
||||||
파드 상태 1/1 Running, restarts=0 probe 정상
|
|
||||||
```
|
|
||||||
|
|
||||||
**"헤더를 믿는다"와 "앞에 반드시 프록시가 있다"는 한 쌍이다.**
|
|
||||||
앞의 것만 하면 이 구멍이 남는다.
|
|
||||||
|
|
||||||
## 12. 증거
|
|
||||||
|
|
||||||
`docs/evidence/two-hop-proxy-headers/`
|
|
||||||
|
|
||||||
| 파일 | 내용 |
|
|
||||||
|---|---|
|
|
||||||
| `01-environment.txt` | 수정 전 세 계층 설정 |
|
|
||||||
| `02-measurements.txt` | 수정 전 측정 + 대조 실험 |
|
|
||||||
| `03-browser-https-vs-app-http.png` | 브라우저와 앱의 인식 차이 |
|
|
||||||
| `stage-a-nginx-fixed.png` | A 이후 — 여전히 `http` |
|
|
||||||
| `stage-b-traefik-trusts.png` | B 이후 — 헤더는 살아났으나 앱 해석은 `http` |
|
|
||||||
| `stage-c-resolved.png` | C 이후 — 전 구간 `https` |
|
|
||||||
| `04-after-fix.txt` | 최종 측정 + 위조 테스트 + 분배 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 참고
|
|
||||||
|
|
||||||
- 1홉 계약 원본: `docs/reverse-proxy-headers.md`
|
|
||||||
- 개념 상세: `docs/session-lab-concepts.md`
|
|
||||||
- 패턴 비교: `docs/four-pattern-tradeoff-matrix.md`
|
|
||||||
- 측정 배포: `deploy/lab/k8s/echo.yaml`
|
|
||||||
- 측정 실행: `deploy/lab/scripts/measure-proxy-headers.sh`
|
|
||||||
+9
-8
@@ -1,26 +1,27 @@
|
|||||||
{
|
{
|
||||||
"name": "keycloak-google-broker-e2e",
|
"name": "keycloak-pattern-e2e",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "keycloak-google-broker-e2e",
|
"name": "keycloak-pattern-e2e",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"devDependencies": {
|
||||||
"playwright-core": "1.55.1"
|
"playwright-core": "1.62.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.55.1",
|
"version": "1.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
|
||||||
"integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==",
|
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "keycloak-pattern-e2e",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test:pattern2": "node pattern2.mjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"playwright-core": "1.62.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { chromium } from "playwright-core";
|
|
||||||
|
|
||||||
const keycloakUrl = process.env.KEYCLOAK_URL ?? "http://localhost:8080";
|
|
||||||
const adminUsername = process.env.KC_BOOTSTRAP_ADMIN_USERNAME;
|
|
||||||
const adminPassword = process.env.KC_BOOTSTRAP_ADMIN_PASSWORD;
|
|
||||||
const mockPassword = process.env.MOCK_GOOGLE_USER_PASSWORD;
|
|
||||||
assert.ok(adminUsername && adminPassword && mockPassword);
|
|
||||||
|
|
||||||
async function adminToken() {
|
|
||||||
const response = await fetch(
|
|
||||||
`${keycloakUrl}/realms/master/protocol/openid-connect/token`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: new URLSearchParams({
|
|
||||||
client_id: "admin-cli",
|
|
||||||
grant_type: "password",
|
|
||||||
username: adminUsername,
|
|
||||||
password: adminPassword,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert.equal(response.status, 200);
|
|
||||||
return (await response.json()).access_token;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function usersByEmail(token) {
|
|
||||||
const response = await fetch(
|
|
||||||
`${keycloakUrl}/admin/realms/keycloak-patterns/users?email=${encodeURIComponent(
|
|
||||||
"broker-new-user@example.test",
|
|
||||||
)}&exact=true`,
|
|
||||||
{ headers: { Authorization: `Bearer ${token}` } },
|
|
||||||
);
|
|
||||||
assert.equal(response.status, 200);
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function userById(token, userId) {
|
|
||||||
const response = await fetch(
|
|
||||||
`${keycloakUrl}/admin/realms/keycloak-patterns/users/${userId}`,
|
|
||||||
{ headers: { Authorization: `Bearer ${token}` } },
|
|
||||||
);
|
|
||||||
assert.equal(response.status, 200);
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removePreviousUser(token) {
|
|
||||||
for (const user of await usersByEmail(token)) {
|
|
||||||
const response = await fetch(
|
|
||||||
`${keycloakUrl}/admin/realms/keycloak-patterns/users/${user.id}`,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert.equal(response.status, 204);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function brokerLogin(page) {
|
|
||||||
const url = new URL(
|
|
||||||
`${keycloakUrl}/realms/keycloak-patterns/protocol/openid-connect/auth`,
|
|
||||||
);
|
|
||||||
url.search = new URLSearchParams({
|
|
||||||
client_id: "spa-public",
|
|
||||||
redirect_uri: "http://localhost:8088/",
|
|
||||||
response_type: "code",
|
|
||||||
scope: "openid profile email",
|
|
||||||
state: crypto.randomUUID(),
|
|
||||||
nonce: crypto.randomUUID(),
|
|
||||||
code_challenge: "K2qUEfBl-nQvF2gB4dNxC2zYVwZc1CVnZb5CsX2L7fI",
|
|
||||||
code_challenge_method: "S256",
|
|
||||||
kc_idp_hint: "mock-google",
|
|
||||||
prompt: "login",
|
|
||||||
});
|
|
||||||
await page.goto(url.toString());
|
|
||||||
await page.waitForURL(/\/realms\/mock-google\//u);
|
|
||||||
await page.locator("#username").fill("mock-new-user");
|
|
||||||
await page.locator("#password").fill(mockPassword);
|
|
||||||
await page.locator("#kc-login").click();
|
|
||||||
await page.waitForURL(/localhost:8088\/\?.*code=/u);
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = await adminToken();
|
|
||||||
await removePreviousUser(token);
|
|
||||||
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
|
|
||||||
headless: true,
|
|
||||||
args: ["--no-sandbox"],
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const page = await browser.newPage();
|
|
||||||
await brokerLogin(page);
|
|
||||||
|
|
||||||
const users = await usersByEmail(token);
|
|
||||||
assert.equal(users.length, 1);
|
|
||||||
const user = await userById(token, users[0].id);
|
|
||||||
assert.match(user.username, /^mock-google\.[0-9a-f-]+$/u);
|
|
||||||
assert.equal(user.firstName, "Broker");
|
|
||||||
assert.equal(user.lastName, "New");
|
|
||||||
assert.deepEqual(user.attributes.picture, [
|
|
||||||
"https://images.example.test/mock-user.png",
|
|
||||||
]);
|
|
||||||
assert.deepEqual(user.attributes.hd, ["example.test"]);
|
|
||||||
|
|
||||||
const identitiesResponse = await fetch(
|
|
||||||
`${keycloakUrl}/admin/realms/keycloak-patterns/users/${user.id}/federated-identity`,
|
|
||||||
{ headers: { Authorization: `Bearer ${token}` } },
|
|
||||||
);
|
|
||||||
assert.equal(identitiesResponse.status, 200);
|
|
||||||
const identities = await identitiesResponse.json();
|
|
||||||
assert.equal(identities.length, 1);
|
|
||||||
assert.equal(identities[0].identityProvider, "mock-google");
|
|
||||||
assert.ok(identities[0].userId);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"Google claim mapping verified: stable sub username, profile attributes, federated identity",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { createHash, randomBytes } from "node:crypto";
|
|
||||||
import { chromium } from "playwright-core";
|
|
||||||
|
|
||||||
const keycloakUrl = process.env.KEYCLOAK_URL ?? "http://localhost:8080";
|
|
||||||
const password = process.env.MOCK_GOOGLE_USER_PASSWORD;
|
|
||||||
assert.ok(password);
|
|
||||||
|
|
||||||
const verifier = randomBytes(48).toString("base64url");
|
|
||||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
||||||
const redirectUri = "http://localhost:8088/";
|
|
||||||
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
|
|
||||||
headless: true,
|
|
||||||
args: ["--no-sandbox"],
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const page = await browser.newPage();
|
|
||||||
const authorizationUrl = new URL(
|
|
||||||
`${keycloakUrl}/realms/keycloak-patterns/protocol/openid-connect/auth`,
|
|
||||||
);
|
|
||||||
authorizationUrl.search = new URLSearchParams({
|
|
||||||
client_id: "spa-public",
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
response_type: "code",
|
|
||||||
scope: "openid profile email",
|
|
||||||
state: crypto.randomUUID(),
|
|
||||||
nonce: crypto.randomUUID(),
|
|
||||||
code_challenge: challenge,
|
|
||||||
code_challenge_method: "S256",
|
|
||||||
kc_idp_hint: "mock-google",
|
|
||||||
prompt: "login",
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto(authorizationUrl.toString());
|
|
||||||
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(/localhost:8088\/\?.*code=/u);
|
|
||||||
|
|
||||||
const code = new URL(page.url()).searchParams.get("code");
|
|
||||||
assert.ok(code);
|
|
||||||
const response = await fetch(
|
|
||||||
`${keycloakUrl}/realms/keycloak-patterns/protocol/openid-connect/token`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: "authorization_code",
|
|
||||||
client_id: "spa-public",
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
code,
|
|
||||||
code_verifier: verifier,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert.equal(response.status, 200);
|
|
||||||
const tokens = await response.json();
|
|
||||||
const payload = JSON.parse(
|
|
||||||
Buffer.from(tokens.access_token.split(".")[1], "base64url").toString(),
|
|
||||||
);
|
|
||||||
assert.ok(payload.realm_access.roles.includes("employee-role"));
|
|
||||||
console.log("Claim-to-role verified: hd=example.test -> employee-role");
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { chromium } from "playwright-core";
|
|
||||||
|
|
||||||
const expectation = process.env.FIRST_BROKER_EXPECTATION;
|
|
||||||
assert.ok(
|
|
||||||
expectation === "vulnerable" || expectation === "secure",
|
|
||||||
"FIRST_BROKER_EXPECTATION must be vulnerable or secure",
|
|
||||||
);
|
|
||||||
|
|
||||||
const keycloakUrl = process.env.KEYCLOAK_URL ?? "http://localhost:8080";
|
|
||||||
const adminUsername = process.env.KC_BOOTSTRAP_ADMIN_USERNAME;
|
|
||||||
const adminPassword = process.env.KC_BOOTSTRAP_ADMIN_PASSWORD;
|
|
||||||
const mockPassword = process.env.MOCK_GOOGLE_USER_PASSWORD;
|
|
||||||
assert.ok(adminUsername && adminPassword && mockPassword);
|
|
||||||
|
|
||||||
async function adminToken() {
|
|
||||||
const body = new URLSearchParams({
|
|
||||||
client_id: "admin-cli",
|
|
||||||
grant_type: "password",
|
|
||||||
username: adminUsername,
|
|
||||||
password: adminPassword,
|
|
||||||
});
|
|
||||||
const response = await fetch(
|
|
||||||
`${keycloakUrl}/realms/master/protocol/openid-connect/token`,
|
|
||||||
{ method: "POST", body },
|
|
||||||
);
|
|
||||||
assert.equal(response.status, 200);
|
|
||||||
return (await response.json()).access_token;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function regularUser(token) {
|
|
||||||
const response = await fetch(
|
|
||||||
`${keycloakUrl}/admin/realms/keycloak-patterns/users?username=regular-user&exact=true`,
|
|
||||||
{ headers: { Authorization: `Bearer ${token}` } },
|
|
||||||
);
|
|
||||||
assert.equal(response.status, 200);
|
|
||||||
const users = await response.json();
|
|
||||||
assert.equal(users.length, 1);
|
|
||||||
return users[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function federatedIdentities(token, userId) {
|
|
||||||
const response = await fetch(
|
|
||||||
`${keycloakUrl}/admin/realms/keycloak-patterns/users/${userId}/federated-identity`,
|
|
||||||
{ headers: { Authorization: `Bearer ${token}` } },
|
|
||||||
);
|
|
||||||
assert.equal(response.status, 200);
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeMockLink(token, userId) {
|
|
||||||
const identities = await federatedIdentities(token, userId);
|
|
||||||
if (identities.some(({ identityProvider }) => identityProvider === "mock-google")) {
|
|
||||||
const response = await fetch(
|
|
||||||
`${keycloakUrl}/admin/realms/keycloak-patterns/users/${userId}/federated-identity/mock-google`,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert.equal(response.status, 204);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = await adminToken();
|
|
||||||
const user = await regularUser(token);
|
|
||||||
await removeMockLink(token, user.id);
|
|
||||||
|
|
||||||
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 authorizationUrl = new URL(
|
|
||||||
`${keycloakUrl}/realms/keycloak-patterns/protocol/openid-connect/auth`,
|
|
||||||
);
|
|
||||||
authorizationUrl.search = new URLSearchParams({
|
|
||||||
client_id: "spa-public",
|
|
||||||
redirect_uri: "http://localhost:8088/",
|
|
||||||
response_type: "code",
|
|
||||||
scope: "openid profile email",
|
|
||||||
state: `first-broker-${expectation}`,
|
|
||||||
nonce: `nonce-${expectation}`,
|
|
||||||
code_challenge: "K2qUEfBl-nQvF2gB4dNxC2zYVwZc1CVnZb5CsX2L7fI",
|
|
||||||
code_challenge_method: "S256",
|
|
||||||
kc_idp_hint: "mock-google",
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto(authorizationUrl.toString());
|
|
||||||
await page.waitForURL(/\/realms\/mock-google\//u);
|
|
||||||
await page.locator("#username").fill("mock-collision-user");
|
|
||||||
await page.locator("#password").fill(mockPassword);
|
|
||||||
await page.locator("#kc-login").click();
|
|
||||||
await page.waitForLoadState("domcontentloaded");
|
|
||||||
|
|
||||||
if (expectation === "vulnerable") {
|
|
||||||
await page.waitForURL(/localhost:8088\/\?.*code=/u);
|
|
||||||
const identities = await federatedIdentities(token, user.id);
|
|
||||||
assert.equal(
|
|
||||||
identities.some(({ identityProvider }) => identityProvider === "mock-google"),
|
|
||||||
true,
|
|
||||||
"unsafe AutoLink should attach the attacker-controlled identity",
|
|
||||||
);
|
|
||||||
await removeMockLink(token, user.id);
|
|
||||||
} else {
|
|
||||||
assert.match(page.url(), /\/realms\/keycloak-patterns\//u);
|
|
||||||
const body = (await page.locator("body").innerText()).toLowerCase();
|
|
||||||
assert.match(body, /account already exists|link existing account|existing account/u);
|
|
||||||
const identities = await federatedIdentities(token, user.id);
|
|
||||||
assert.equal(
|
|
||||||
identities.some(({ identityProvider }) => identityProvider === "mock-google"),
|
|
||||||
false,
|
|
||||||
"Confirm Link must not attach the identity without ownership proof",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`first broker login ${expectation} case verified`);
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "keycloak-google-broker-e2e",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"test:first-broker": "node first-broker-login.mjs",
|
|
||||||
"test:claim-mapping": "node claim-mapping.mjs",
|
|
||||||
"test:claim-to-role": "node claim-to-role.mjs",
|
|
||||||
"test:sub-vs-email": "node sub-vs-email.mjs"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"playwright-core": "1.55.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { chromium } from "playwright-core";
|
|
||||||
|
|
||||||
const baseUrl = process.env.KEYCLOAK_URL ?? "http://localhost:8080";
|
|
||||||
const adminUsername = process.env.KC_BOOTSTRAP_ADMIN_USERNAME;
|
|
||||||
const adminPassword = process.env.KC_BOOTSTRAP_ADMIN_PASSWORD;
|
|
||||||
const mockPassword = process.env.MOCK_GOOGLE_USER_PASSWORD;
|
|
||||||
assert.ok(adminUsername && adminPassword && mockPassword);
|
|
||||||
|
|
||||||
async function adminToken() {
|
|
||||||
const response = await fetch(
|
|
||||||
`${baseUrl}/realms/master/protocol/openid-connect/token`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: new URLSearchParams({
|
|
||||||
client_id: "admin-cli",
|
|
||||||
grant_type: "password",
|
|
||||||
username: adminUsername,
|
|
||||||
password: adminPassword,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert.equal(response.status, 200);
|
|
||||||
return (await response.json()).access_token;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function adminJson(token, path, init = {}) {
|
|
||||||
const response = await fetch(`${baseUrl}/admin/realms/${path}`, {
|
|
||||||
...init,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
...(init.body ? { "Content-Type": "application/json" } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
assert.ok(response.ok, `${init.method ?? "GET"} ${path}: ${response.status}`);
|
|
||||||
return response.status === 204 ? undefined : response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function users(token, realm, query) {
|
|
||||||
return adminJson(token, `${realm}/users?${new URLSearchParams(query)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function brokerLogin(browser) {
|
|
||||||
const page = await browser.newPage();
|
|
||||||
const url = new URL(
|
|
||||||
`${baseUrl}/realms/keycloak-patterns/protocol/openid-connect/auth`,
|
|
||||||
);
|
|
||||||
url.search = new URLSearchParams({
|
|
||||||
client_id: "spa-public",
|
|
||||||
redirect_uri: "http://localhost:8088/",
|
|
||||||
response_type: "code",
|
|
||||||
scope: "openid profile email",
|
|
||||||
state: crypto.randomUUID(),
|
|
||||||
nonce: crypto.randomUUID(),
|
|
||||||
code_challenge: "K2qUEfBl-nQvF2gB4dNxC2zYVwZc1CVnZb5CsX2L7fI",
|
|
||||||
code_challenge_method: "S256",
|
|
||||||
kc_idp_hint: "mock-google",
|
|
||||||
prompt: "login",
|
|
||||||
});
|
|
||||||
await page.goto(url.toString());
|
|
||||||
await page.waitForURL(/\/realms\/mock-google\//u);
|
|
||||||
await page.locator("#username").fill("mock-new-user");
|
|
||||||
await page.locator("#password").fill(mockPassword);
|
|
||||||
await page.locator("#kc-login").click();
|
|
||||||
await page.waitForURL(/localhost:8088\/\?.*code=/u);
|
|
||||||
await page.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = await adminToken();
|
|
||||||
const mockUsers = await users(token, "mock-google", {
|
|
||||||
username: "mock-new-user",
|
|
||||||
exact: "true",
|
|
||||||
});
|
|
||||||
assert.equal(mockUsers.length, 1);
|
|
||||||
const mockUser = await adminJson(
|
|
||||||
token,
|
|
||||||
`mock-google/users/${mockUsers[0].id}`,
|
|
||||||
);
|
|
||||||
const originalEmail = mockUser.email;
|
|
||||||
const changedEmail = "broker-renamed-user@example.test";
|
|
||||||
|
|
||||||
for (const existing of await users(token, "keycloak-patterns", {
|
|
||||||
username: `mock-google.${mockUser.id}`,
|
|
||||||
exact: "true",
|
|
||||||
})) {
|
|
||||||
await adminJson(token, `keycloak-patterns/users/${existing.id}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
|
|
||||||
headless: true,
|
|
||||||
args: ["--no-sandbox"],
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await brokerLogin(browser);
|
|
||||||
const before = await users(token, "keycloak-patterns", {
|
|
||||||
username: `mock-google.${mockUser.id}`,
|
|
||||||
exact: "true",
|
|
||||||
});
|
|
||||||
assert.equal(before.length, 1);
|
|
||||||
const localUserId = before[0].id;
|
|
||||||
const identities = await adminJson(
|
|
||||||
token,
|
|
||||||
`keycloak-patterns/users/${localUserId}/federated-identity`,
|
|
||||||
);
|
|
||||||
assert.equal(identities[0].userId, mockUser.id);
|
|
||||||
|
|
||||||
await adminJson(token, `mock-google/users/${mockUser.id}`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({ ...mockUser, email: changedEmail }),
|
|
||||||
});
|
|
||||||
await brokerLogin(browser);
|
|
||||||
|
|
||||||
const after = await users(token, "keycloak-patterns", {
|
|
||||||
username: `mock-google.${mockUser.id}`,
|
|
||||||
exact: "true",
|
|
||||||
});
|
|
||||||
assert.equal(after.length, 1);
|
|
||||||
assert.equal(after[0].id, localUserId);
|
|
||||||
console.log(
|
|
||||||
"Federated identity verified: provider sub stayed linked while upstream email changed",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await adminJson(token, `mock-google/users/${mockUser.id}`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({ ...mockUser, email: originalEmail }),
|
|
||||||
});
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
@@ -24,10 +24,6 @@
|
|||||||
{
|
{
|
||||||
"name": "user-role",
|
"name": "user-role",
|
||||||
"description": "Regular authenticated user role"
|
"description": "Regular authenticated user role"
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "employee-role",
|
|
||||||
"description": "Assigned to brokered identities whose hosted-domain claim is example.test"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -128,108 +124,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"identityProviders": [
|
|
||||||
{
|
|
||||||
"alias": "mock-google",
|
|
||||||
"displayName": "Mock Google (local verification)",
|
|
||||||
"providerId": "oidc",
|
|
||||||
"enabled": true,
|
|
||||||
"updateProfileFirstLoginMode": "off",
|
|
||||||
"trustEmail": false,
|
|
||||||
"storeToken": false,
|
|
||||||
"addReadTokenRoleOnCreate": false,
|
|
||||||
"authenticateByDefault": false,
|
|
||||||
"linkOnly": false,
|
|
||||||
"firstBrokerLoginFlowAlias": "first broker login",
|
|
||||||
"config": {
|
|
||||||
"clientId": "mock-google-broker",
|
|
||||||
"clientSecret": "${MOCK_GOOGLE_BROKER_CLIENT_SECRET}",
|
|
||||||
"authorizationUrl": "http://localhost:8080/realms/mock-google/protocol/openid-connect/auth",
|
|
||||||
"tokenUrl": "http://keycloak:8080/realms/mock-google/protocol/openid-connect/token",
|
|
||||||
"userInfoUrl": "http://keycloak:8080/realms/mock-google/protocol/openid-connect/userinfo",
|
|
||||||
"issuer": "http://localhost:8080/realms/mock-google",
|
|
||||||
"jwksUrl": "http://keycloak:8080/realms/mock-google/protocol/openid-connect/certs",
|
|
||||||
"useJwksUrl": "true",
|
|
||||||
"validateSignature": "true",
|
|
||||||
"defaultScope": "openid profile email",
|
|
||||||
"syncMode": "IMPORT",
|
|
||||||
"pkceEnabled": "true",
|
|
||||||
"pkceMethod": "S256"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"identityProviderMappers": [
|
|
||||||
{
|
|
||||||
"name": "mock-google-stable-username",
|
|
||||||
"identityProviderAlias": "mock-google",
|
|
||||||
"identityProviderMapper": "oidc-username-idp-mapper",
|
|
||||||
"config": {
|
|
||||||
"template": "${ALIAS}.${CLAIM.sub}",
|
|
||||||
"target": "LOCAL"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mock-google-email",
|
|
||||||
"identityProviderAlias": "mock-google",
|
|
||||||
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
|
|
||||||
"config": {
|
|
||||||
"syncMode": "INHERIT",
|
|
||||||
"claim": "email",
|
|
||||||
"user.attribute": "email"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mock-google-given-name",
|
|
||||||
"identityProviderAlias": "mock-google",
|
|
||||||
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
|
|
||||||
"config": {
|
|
||||||
"syncMode": "INHERIT",
|
|
||||||
"claim": "given_name",
|
|
||||||
"user.attribute": "firstName"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mock-google-family-name",
|
|
||||||
"identityProviderAlias": "mock-google",
|
|
||||||
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
|
|
||||||
"config": {
|
|
||||||
"syncMode": "INHERIT",
|
|
||||||
"claim": "family_name",
|
|
||||||
"user.attribute": "lastName"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mock-google-picture",
|
|
||||||
"identityProviderAlias": "mock-google",
|
|
||||||
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
|
|
||||||
"config": {
|
|
||||||
"syncMode": "INHERIT",
|
|
||||||
"claim": "picture",
|
|
||||||
"user.attribute": "picture"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mock-google-hosted-domain",
|
|
||||||
"identityProviderAlias": "mock-google",
|
|
||||||
"identityProviderMapper": "oidc-user-attribute-idp-mapper",
|
|
||||||
"config": {
|
|
||||||
"syncMode": "INHERIT",
|
|
||||||
"claim": "hd",
|
|
||||||
"user.attribute": "hd"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mock-google-example-domain-role",
|
|
||||||
"identityProviderAlias": "mock-google",
|
|
||||||
"identityProviderMapper": "oidc-role-idp-mapper",
|
|
||||||
"config": {
|
|
||||||
"syncMode": "INHERIT",
|
|
||||||
"claim": "hd",
|
|
||||||
"claim.value": "example.test",
|
|
||||||
"role": "employee-role"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"users": [
|
"users": [
|
||||||
{
|
{
|
||||||
"username": "admin-user",
|
"username": "admin-user",
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
{
|
|
||||||
"realm": "mock-google",
|
|
||||||
"displayName": "Controllable Google OIDC Test Provider",
|
|
||||||
"enabled": true,
|
|
||||||
"sslRequired": "external",
|
|
||||||
"registrationAllowed": false,
|
|
||||||
"resetPasswordAllowed": false,
|
|
||||||
"editUsernameAllowed": false,
|
|
||||||
"loginWithEmailAllowed": true,
|
|
||||||
"duplicateEmailsAllowed": false,
|
|
||||||
"bruteForceProtected": true,
|
|
||||||
"clients": [
|
|
||||||
{
|
|
||||||
"clientId": "mock-google-broker",
|
|
||||||
"name": "Main Realm Identity Broker",
|
|
||||||
"enabled": true,
|
|
||||||
"protocol": "openid-connect",
|
|
||||||
"publicClient": false,
|
|
||||||
"clientAuthenticatorType": "client-secret",
|
|
||||||
"secret": "${MOCK_GOOGLE_BROKER_CLIENT_SECRET}",
|
|
||||||
"standardFlowEnabled": true,
|
|
||||||
"implicitFlowEnabled": false,
|
|
||||||
"directAccessGrantsEnabled": false,
|
|
||||||
"serviceAccountsEnabled": false,
|
|
||||||
"redirectUris": [
|
|
||||||
"http://localhost:8080/realms/keycloak-patterns/broker/mock-google/endpoint"
|
|
||||||
],
|
|
||||||
"webOrigins": [],
|
|
||||||
"protocolMappers": [
|
|
||||||
{
|
|
||||||
"name": "hosted-domain",
|
|
||||||
"protocol": "openid-connect",
|
|
||||||
"protocolMapper": "oidc-hardcoded-claim-mapper",
|
|
||||||
"consentRequired": false,
|
|
||||||
"config": {
|
|
||||||
"claim.name": "hd",
|
|
||||||
"claim.value": "example.test",
|
|
||||||
"jsonType.label": "String",
|
|
||||||
"id.token.claim": "true",
|
|
||||||
"access.token.claim": "true",
|
|
||||||
"userinfo.token.claim": "true"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "picture",
|
|
||||||
"protocol": "openid-connect",
|
|
||||||
"protocolMapper": "oidc-hardcoded-claim-mapper",
|
|
||||||
"consentRequired": false,
|
|
||||||
"config": {
|
|
||||||
"claim.name": "picture",
|
|
||||||
"claim.value": "https://images.example.test/mock-user.png",
|
|
||||||
"jsonType.label": "String",
|
|
||||||
"id.token.claim": "true",
|
|
||||||
"access.token.claim": "true",
|
|
||||||
"userinfo.token.claim": "true"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"users": [
|
|
||||||
{
|
|
||||||
"username": "mock-new-user",
|
|
||||||
"enabled": true,
|
|
||||||
"email": "broker-new-user@example.test",
|
|
||||||
"emailVerified": true,
|
|
||||||
"firstName": "Broker",
|
|
||||||
"lastName": "New",
|
|
||||||
"credentials": [
|
|
||||||
{
|
|
||||||
"type": "password",
|
|
||||||
"value": "${MOCK_GOOGLE_USER_PASSWORD}",
|
|
||||||
"temporary": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"username": "mock-collision-user",
|
|
||||||
"enabled": true,
|
|
||||||
"email": "regular-user@example.test",
|
|
||||||
"emailVerified": false,
|
|
||||||
"firstName": "Broker",
|
|
||||||
"lastName": "Collision",
|
|
||||||
"credentials": [
|
|
||||||
{
|
|
||||||
"type": "password",
|
|
||||||
"value": "${MOCK_GOOGLE_USER_PASSWORD}",
|
|
||||||
"temporary": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
manifest="${1:-docs/keycloak-branch-manifest.tsv}"
|
|
||||||
notes_dir="${KEYCLOAK_BRANCH_NOTES_DIR:-/home/donghyeon/workspace/ai-tools/llm-wiki/raw/branch-notes}"
|
|
||||||
|
|
||||||
expected_count="$(awk 'NR > 1 { count += 1 } END { print count + 0 }' "$manifest")"
|
|
||||||
if [ "$expected_count" -ne 39 ]; then
|
|
||||||
echo "manifest must contain exactly 39 Keycloak branches; found $expected_count" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
note_count="$(find "$notes_dir" -maxdepth 1 -type f -name 'feature-keycloak-*.md' | wc -l)"
|
|
||||||
if [ "$note_count" -ne 39 ]; then
|
|
||||||
echo "branch-note inventory must contain exactly 39 files; found $note_count" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
missing=0
|
|
||||||
unmerged=0
|
|
||||||
tab="$(printf '\t')"
|
|
||||||
|
|
||||||
while IFS="$tab" read -r branch target delivery; do
|
|
||||||
[ "$branch" = "branch" ] && continue
|
|
||||||
|
|
||||||
note_name="$(printf '%s\n' "$branch" |
|
|
||||||
sed 's#^feature/keycloak-#feature-keycloak-#').md"
|
|
||||||
if [ ! -f "$notes_dir/$note_name" ]; then
|
|
||||||
echo "missing branch note: $note_name" >&2
|
|
||||||
missing=$((missing + 1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! git show-ref --verify --quiet "refs/heads/$branch"; then
|
|
||||||
echo "missing local branch: $branch" >&2
|
|
||||||
missing=$((missing + 1))
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$target" in
|
|
||||||
common) target_branch="develop" ;;
|
|
||||||
ap1) target_branch="develop-keycloak-pattern1" ;;
|
|
||||||
ap2) target_branch="develop-keycloak-pattern2" ;;
|
|
||||||
ap3) target_branch="develop-keycloak-pattern3" ;;
|
|
||||||
ap4) target_branch="develop-keycloak-pattern4" ;;
|
|
||||||
*)
|
|
||||||
echo "unknown target '$target' for $branch ($delivery)" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if ! git merge-base --is-ancestor "$branch" "$target_branch"; then
|
|
||||||
echo "feature tip is not merged: $branch -> $target_branch" >&2
|
|
||||||
unmerged=$((unmerged + 1))
|
|
||||||
fi
|
|
||||||
done < "$manifest"
|
|
||||||
|
|
||||||
if [ "$missing" -ne 0 ] || [ "$unmerged" -ne 0 ]; then
|
|
||||||
echo "Keycloak branch audit failed: missing=$missing unmerged=$unmerged" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Keycloak branch audit passed: 39/39 branches exist and are merged"
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
if [ ! -f .env ]; then
|
|
||||||
echo "missing .env" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
set -a
|
|
||||||
. ./.env
|
|
||||||
set +a
|
|
||||||
|
|
||||||
keycloak_url="${KEYCLOAK_URL:-http://localhost:8080}"
|
|
||||||
realm="${KEYCLOAK_REALM:-keycloak-patterns}"
|
|
||||||
profile_url="$keycloak_url/admin/realms/$realm/users/profile"
|
|
||||||
|
|
||||||
admin_token="$(
|
|
||||||
curl -fsS \
|
|
||||||
-d client_id=admin-cli \
|
|
||||||
-d grant_type=password \
|
|
||||||
-d "username=$KC_BOOTSTRAP_ADMIN_USERNAME" \
|
|
||||||
-d "password=$KC_BOOTSTRAP_ADMIN_PASSWORD" \
|
|
||||||
"$keycloak_url/realms/master/protocol/openid-connect/token" |
|
|
||||||
jq -er .access_token
|
|
||||||
)"
|
|
||||||
|
|
||||||
profile="$(curl -fsS -H "Authorization: Bearer $admin_token" "$profile_url")"
|
|
||||||
updated_profile="$(
|
|
||||||
printf '%s' "$profile" |
|
|
||||||
jq '
|
|
||||||
def broker_attribute($name; $label): {
|
|
||||||
name: $name,
|
|
||||||
displayName: $label,
|
|
||||||
validations: {length: {max: 2048}},
|
|
||||||
permissions: {
|
|
||||||
view: ["admin", "user"],
|
|
||||||
edit: ["admin"]
|
|
||||||
},
|
|
||||||
multivalued: false,
|
|
||||||
group: "user-metadata"
|
|
||||||
};
|
|
||||||
if any(.attributes[]; .name == "picture") then .
|
|
||||||
else .attributes += [broker_attribute("picture"; "Profile picture URL")]
|
|
||||||
end |
|
|
||||||
if any(.attributes[]; .name == "hd") then .
|
|
||||||
else .attributes += [broker_attribute("hd"; "Hosted domain")]
|
|
||||||
end
|
|
||||||
'
|
|
||||||
)"
|
|
||||||
|
|
||||||
printf '%s' "$updated_profile" |
|
|
||||||
curl -fsS -X PUT \
|
|
||||||
-H "Authorization: Bearer $admin_token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
--data @- \
|
|
||||||
"$profile_url"
|
|
||||||
|
|
||||||
echo "Broker user-profile attributes configured for realm '$realm'"
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
if [ ! -f .env ]; then
|
|
||||||
echo "missing .env" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
set -a
|
|
||||||
. ./.env
|
|
||||||
set +a
|
|
||||||
|
|
||||||
: "${KC_BOOTSTRAP_ADMIN_USERNAME:?set KC_BOOTSTRAP_ADMIN_USERNAME in .env}"
|
|
||||||
: "${KC_BOOTSTRAP_ADMIN_PASSWORD:?set KC_BOOTSTRAP_ADMIN_PASSWORD in .env}"
|
|
||||||
: "${GOOGLE_CLIENT_ID:?set GOOGLE_CLIENT_ID in .env}"
|
|
||||||
: "${GOOGLE_CLIENT_SECRET:?set GOOGLE_CLIENT_SECRET in .env}"
|
|
||||||
|
|
||||||
./scripts/configure-broker-user-profile.sh
|
|
||||||
|
|
||||||
keycloak_url="${KEYCLOAK_URL:-http://localhost:8080}"
|
|
||||||
realm="${KEYCLOAK_REALM:-keycloak-patterns}"
|
|
||||||
|
|
||||||
admin_token="$(
|
|
||||||
curl -fsS \
|
|
||||||
-d client_id=admin-cli \
|
|
||||||
-d grant_type=password \
|
|
||||||
-d "username=$KC_BOOTSTRAP_ADMIN_USERNAME" \
|
|
||||||
-d "password=$KC_BOOTSTRAP_ADMIN_PASSWORD" \
|
|
||||||
"$keycloak_url/realms/master/protocol/openid-connect/token" |
|
|
||||||
jq -er .access_token
|
|
||||||
)"
|
|
||||||
|
|
||||||
payload="$(
|
|
||||||
jq -n \
|
|
||||||
--arg client_id "$GOOGLE_CLIENT_ID" \
|
|
||||||
--arg client_secret "$GOOGLE_CLIENT_SECRET" \
|
|
||||||
'{
|
|
||||||
alias: "google",
|
|
||||||
displayName: "Sign in with Google",
|
|
||||||
providerId: "google",
|
|
||||||
enabled: true,
|
|
||||||
updateProfileFirstLoginMode: "off",
|
|
||||||
trustEmail: false,
|
|
||||||
storeToken: false,
|
|
||||||
addReadTokenRoleOnCreate: false,
|
|
||||||
authenticateByDefault: false,
|
|
||||||
linkOnly: false,
|
|
||||||
firstBrokerLoginFlowAlias: "first broker login",
|
|
||||||
config: {
|
|
||||||
clientId: $client_id,
|
|
||||||
clientSecret: $client_secret,
|
|
||||||
defaultScope: "openid profile email",
|
|
||||||
syncMode: "IMPORT"
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
)"
|
|
||||||
|
|
||||||
endpoint="$keycloak_url/admin/realms/$realm/identity-provider/instances"
|
|
||||||
status="$(
|
|
||||||
curl -sS -o /dev/null -w '%{http_code}' \
|
|
||||||
-H "Authorization: Bearer $admin_token" \
|
|
||||||
"$endpoint/google"
|
|
||||||
)"
|
|
||||||
|
|
||||||
if [ "$status" = "200" ]; then
|
|
||||||
curl -fsS -X PUT \
|
|
||||||
-H "Authorization: Bearer $admin_token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
--data "$payload" \
|
|
||||||
"$endpoint/google"
|
|
||||||
action="updated"
|
|
||||||
else
|
|
||||||
curl -fsS -X POST \
|
|
||||||
-H "Authorization: Bearer $admin_token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
--data "$payload" \
|
|
||||||
"$endpoint"
|
|
||||||
action="created"
|
|
||||||
fi
|
|
||||||
|
|
||||||
mapper_endpoint="$endpoint/google/mappers"
|
|
||||||
upsert_mapper() {
|
|
||||||
mapper_name="$1"
|
|
||||||
mapper_type="$2"
|
|
||||||
mapper_config="$3"
|
|
||||||
mapper_id="$(
|
|
||||||
curl -fsS \
|
|
||||||
-H "Authorization: Bearer $admin_token" \
|
|
||||||
"$mapper_endpoint" |
|
|
||||||
jq -r --arg name "$mapper_name" '
|
|
||||||
.[] | select(.name == $name) | .id
|
|
||||||
' |
|
|
||||||
head -1
|
|
||||||
)"
|
|
||||||
mapper_payload="$(
|
|
||||||
jq -n \
|
|
||||||
--arg name "$mapper_name" \
|
|
||||||
--arg alias "google" \
|
|
||||||
--arg mapper "$mapper_type" \
|
|
||||||
--argjson config "$mapper_config" \
|
|
||||||
'{
|
|
||||||
name: $name,
|
|
||||||
identityProviderAlias: $alias,
|
|
||||||
identityProviderMapper: $mapper,
|
|
||||||
config: $config
|
|
||||||
}'
|
|
||||||
)"
|
|
||||||
if [ -n "$mapper_id" ]; then
|
|
||||||
curl -fsS -X PUT \
|
|
||||||
-H "Authorization: Bearer $admin_token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
--data "$mapper_payload" \
|
|
||||||
"$mapper_endpoint/$mapper_id"
|
|
||||||
else
|
|
||||||
curl -fsS -X POST \
|
|
||||||
-H "Authorization: Bearer $admin_token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
--data "$mapper_payload" \
|
|
||||||
"$mapper_endpoint"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
upsert_mapper \
|
|
||||||
"google-stable-username" \
|
|
||||||
"oidc-username-idp-mapper" \
|
|
||||||
'{"template":"${ALIAS}.${CLAIM.sub}","target":"LOCAL"}'
|
|
||||||
upsert_mapper \
|
|
||||||
"google-email" \
|
|
||||||
"oidc-user-attribute-idp-mapper" \
|
|
||||||
'{"syncMode":"INHERIT","claim":"email","user.attribute":"email"}'
|
|
||||||
upsert_mapper \
|
|
||||||
"google-given-name" \
|
|
||||||
"oidc-user-attribute-idp-mapper" \
|
|
||||||
'{"syncMode":"INHERIT","claim":"given_name","user.attribute":"firstName"}'
|
|
||||||
upsert_mapper \
|
|
||||||
"google-family-name" \
|
|
||||||
"oidc-user-attribute-idp-mapper" \
|
|
||||||
'{"syncMode":"INHERIT","claim":"family_name","user.attribute":"lastName"}'
|
|
||||||
upsert_mapper \
|
|
||||||
"google-picture" \
|
|
||||||
"oidc-user-attribute-idp-mapper" \
|
|
||||||
'{"syncMode":"INHERIT","claim":"picture","user.attribute":"picture"}'
|
|
||||||
upsert_mapper \
|
|
||||||
"google-hosted-domain" \
|
|
||||||
"oidc-user-attribute-idp-mapper" \
|
|
||||||
'{"syncMode":"INHERIT","claim":"hd","user.attribute":"hd"}'
|
|
||||||
upsert_mapper \
|
|
||||||
"google-example-domain-role" \
|
|
||||||
"oidc-role-idp-mapper" \
|
|
||||||
'{"syncMode":"INHERIT","claim":"hd","claim.value":"example.test","role":"employee-role"}'
|
|
||||||
|
|
||||||
echo "Google Identity Provider $action for realm '$realm'"
|
|
||||||
echo "Register this exact Google redirect URI:"
|
|
||||||
echo "$keycloak_url/realms/$realm/broker/google/endpoint"
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
mode="${1:-}"
|
|
||||||
case "$mode" in
|
|
||||||
vulnerable|secure) ;;
|
|
||||||
*)
|
|
||||||
echo "usage: $0 vulnerable|secure" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ ! -f .env ]; then
|
|
||||||
echo "missing .env" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
set -a
|
|
||||||
. ./.env
|
|
||||||
set +a
|
|
||||||
|
|
||||||
keycloak_url="${KEYCLOAK_URL:-http://localhost:8080}"
|
|
||||||
realm="${KEYCLOAK_REALM:-keycloak-patterns}"
|
|
||||||
admin_base="$keycloak_url/admin/realms/$realm"
|
|
||||||
vulnerable_flow="vulnerable first broker login"
|
|
||||||
idp_url="$admin_base/identity-provider/instances/mock-google"
|
|
||||||
|
|
||||||
admin_token="$(
|
|
||||||
curl -fsS \
|
|
||||||
-d client_id=admin-cli \
|
|
||||||
-d grant_type=password \
|
|
||||||
-d "username=$KC_BOOTSTRAP_ADMIN_USERNAME" \
|
|
||||||
-d "password=$KC_BOOTSTRAP_ADMIN_PASSWORD" \
|
|
||||||
"$keycloak_url/realms/master/protocol/openid-connect/token" |
|
|
||||||
jq -er .access_token
|
|
||||||
)"
|
|
||||||
|
|
||||||
auth_header="Authorization: Bearer $admin_token"
|
|
||||||
encode() {
|
|
||||||
jq -rn --arg value "$1" '$value | @uri'
|
|
||||||
}
|
|
||||||
|
|
||||||
flows="$(curl -fsS -H "$auth_header" "$admin_base/authentication/flows")"
|
|
||||||
flow_id="$(
|
|
||||||
printf '%s' "$flows" |
|
|
||||||
jq -r --arg alias "$vulnerable_flow" '
|
|
||||||
.[] | select(.alias == $alias) | .id
|
|
||||||
' |
|
|
||||||
head -1
|
|
||||||
)"
|
|
||||||
|
|
||||||
if [ -n "$flow_id" ]; then
|
|
||||||
existing_executions="$(
|
|
||||||
curl -fsS -H "$auth_header" \
|
|
||||||
"$admin_base/authentication/flows/$(encode "$vulnerable_flow")/executions"
|
|
||||||
)"
|
|
||||||
if printf '%s' "$existing_executions" | jq -e '
|
|
||||||
any(.[]; .authenticationFlow == true)
|
|
||||||
' >/dev/null; then
|
|
||||||
idp_before_delete="$(curl -fsS -H "$auth_header" "$idp_url")"
|
|
||||||
printf '%s' "$idp_before_delete" |
|
|
||||||
jq '.firstBrokerLoginFlowAlias = "first broker login"' |
|
|
||||||
curl -fsS -X PUT \
|
|
||||||
-H "$auth_header" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
--data @- \
|
|
||||||
"$idp_url"
|
|
||||||
curl -fsS -X DELETE \
|
|
||||||
-H "$auth_header" \
|
|
||||||
"$admin_base/authentication/flows/$flow_id"
|
|
||||||
flow_id=""
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$flow_id" ]; then
|
|
||||||
curl -fsS -X POST \
|
|
||||||
-H "$auth_header" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
--data "$(
|
|
||||||
jq -n --arg alias "$vulnerable_flow" '{
|
|
||||||
alias: $alias,
|
|
||||||
description: "INSECURE LEARNING FLOW - automatic email linking",
|
|
||||||
providerId: "basic-flow",
|
|
||||||
topLevel: true,
|
|
||||||
builtIn: false
|
|
||||||
}'
|
|
||||||
)" \
|
|
||||||
"$admin_base/authentication/flows"
|
|
||||||
fi
|
|
||||||
|
|
||||||
executions_url="$admin_base/authentication/flows/$(encode "$vulnerable_flow")/executions"
|
|
||||||
executions="$(curl -fsS -H "$auth_header" "$executions_url")"
|
|
||||||
create_user_id="$(
|
|
||||||
printf '%s' "$executions" |
|
|
||||||
jq -r '.[] | select(.providerId == "idp-create-user-if-unique") | .id' |
|
|
||||||
head -1
|
|
||||||
)"
|
|
||||||
if [ -z "$create_user_id" ]; then
|
|
||||||
curl -fsS -X POST \
|
|
||||||
-H "$auth_header" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
--data '{"provider":"idp-create-user-if-unique"}' \
|
|
||||||
"$admin_base/authentication/flows/$(encode "$vulnerable_flow")/executions/execution"
|
|
||||||
executions="$(curl -fsS -H "$auth_header" "$executions_url")"
|
|
||||||
create_user_id="$(
|
|
||||||
printf '%s' "$executions" |
|
|
||||||
jq -r '.[] | select(.providerId == "idp-create-user-if-unique") | .id' |
|
|
||||||
head -1
|
|
||||||
)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
auto_link_id="$(
|
|
||||||
printf '%s' "$executions" |
|
|
||||||
jq -r '.[] | select(.providerId == "idp-auto-link") | .id' |
|
|
||||||
head -1
|
|
||||||
)"
|
|
||||||
|
|
||||||
if [ -z "$auto_link_id" ]; then
|
|
||||||
curl -fsS -X POST \
|
|
||||||
-H "$auth_header" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
--data '{"provider":"idp-auto-link"}' \
|
|
||||||
"$admin_base/authentication/flows/$(encode "$vulnerable_flow")/executions/execution"
|
|
||||||
executions="$(curl -fsS -H "$auth_header" "$executions_url")"
|
|
||||||
auto_link_id="$(
|
|
||||||
printf '%s' "$executions" |
|
|
||||||
jq -r '.[] | select(.providerId == "idp-auto-link") | .id' |
|
|
||||||
head -1
|
|
||||||
)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$mode" = "vulnerable" ]; then
|
|
||||||
curl -fsS -X PUT \
|
|
||||||
-H "$auth_header" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
--data "$(jq -n --arg id "$create_user_id" '{id: $id, requirement: "ALTERNATIVE"}')" \
|
|
||||||
"$executions_url"
|
|
||||||
curl -fsS -X PUT \
|
|
||||||
-H "$auth_header" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
--data "$(jq -n --arg id "$auto_link_id" '{id: $id, requirement: "ALTERNATIVE"}')" \
|
|
||||||
"$executions_url"
|
|
||||||
selected_flow="$vulnerable_flow"
|
|
||||||
else
|
|
||||||
selected_flow="first broker login"
|
|
||||||
fi
|
|
||||||
|
|
||||||
idp="$(curl -fsS -H "$auth_header" "$idp_url")"
|
|
||||||
printf '%s' "$idp" |
|
|
||||||
jq --arg flow "$selected_flow" '.firstBrokerLoginFlowAlias = $flow' |
|
|
||||||
curl -fsS -X PUT \
|
|
||||||
-H "$auth_header" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
--data @- \
|
|
||||||
"$idp_url"
|
|
||||||
|
|
||||||
assigned="$(
|
|
||||||
curl -fsS -H "$auth_header" "$idp_url" |
|
|
||||||
jq -r .firstBrokerLoginFlowAlias
|
|
||||||
)"
|
|
||||||
test "$assigned" = "$selected_flow"
|
|
||||||
|
|
||||||
if [ "$mode" = "secure" ]; then
|
|
||||||
secure_executions="$(
|
|
||||||
curl -fsS -H "$auth_header" \
|
|
||||||
"$admin_base/authentication/flows/$(encode "first broker login")/executions"
|
|
||||||
)"
|
|
||||||
printf '%s' "$secure_executions" | jq -e '
|
|
||||||
any(.[];
|
|
||||||
.providerId == "idp-confirm-link" and .requirement == "REQUIRED"
|
|
||||||
) and
|
|
||||||
(any(.[];
|
|
||||||
.providerId == "idp-auto-link" and .requirement != "DISABLED"
|
|
||||||
) | not)
|
|
||||||
' >/dev/null
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "mock-google First Broker Login mode: $mode ($selected_flow)"
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
set -a
|
|
||||||
. ./.env
|
|
||||||
set +a
|
|
||||||
|
|
||||||
./scripts/set-first-broker-login-mode.sh secure
|
|
||||||
cd google-e2e
|
|
||||||
npm install --ignore-scripts
|
|
||||||
npm run test:sub-vs-email
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
if [ ! -f .env ]; then
|
|
||||||
echo "missing .env" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
set -a
|
|
||||||
. ./.env
|
|
||||||
set +a
|
|
||||||
|
|
||||||
restore_secure_flow() {
|
|
||||||
./scripts/set-first-broker-login-mode.sh secure >/dev/null 2>&1 || true
|
|
||||||
}
|
|
||||||
trap restore_secure_flow 0 1 2 15
|
|
||||||
|
|
||||||
npm --prefix google-e2e ci
|
|
||||||
|
|
||||||
./scripts/set-first-broker-login-mode.sh vulnerable
|
|
||||||
FIRST_BROKER_EXPECTATION=vulnerable \
|
|
||||||
npm --prefix google-e2e run test:first-broker
|
|
||||||
|
|
||||||
./scripts/set-first-broker-login-mode.sh secure
|
|
||||||
FIRST_BROKER_EXPECTATION=secure \
|
|
||||||
npm --prefix google-e2e run test:first-broker
|
|
||||||
|
|
||||||
trap - 0 1 2 15
|
|
||||||
echo "First Broker Login verified: unsafe AutoLink reproduced, Confirm Link restored"
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
if [ ! -f .env ]; then
|
|
||||||
echo "missing .env" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
set -a
|
|
||||||
. ./.env
|
|
||||||
set +a
|
|
||||||
|
|
||||||
keycloak_url="${KEYCLOAK_URL:-http://localhost:8080}"
|
|
||||||
|
|
||||||
admin_token="$(
|
|
||||||
curl -fsS \
|
|
||||||
-d client_id=admin-cli \
|
|
||||||
-d grant_type=password \
|
|
||||||
-d "username=$KC_BOOTSTRAP_ADMIN_USERNAME" \
|
|
||||||
-d "password=$KC_BOOTSTRAP_ADMIN_PASSWORD" \
|
|
||||||
"$keycloak_url/realms/master/protocol/openid-connect/token" |
|
|
||||||
jq -er .access_token
|
|
||||||
)"
|
|
||||||
|
|
||||||
idp="$(
|
|
||||||
curl -fsS \
|
|
||||||
-H "Authorization: Bearer $admin_token" \
|
|
||||||
"$keycloak_url/admin/realms/keycloak-patterns/identity-provider/instances/mock-google"
|
|
||||||
)"
|
|
||||||
|
|
||||||
printf '%s' "$idp" | jq -e '
|
|
||||||
.providerId == "oidc" and
|
|
||||||
.enabled == true and
|
|
||||||
.trustEmail == false and
|
|
||||||
.config.clientId == "mock-google-broker" and
|
|
||||||
.config.defaultScope == "openid profile email" and
|
|
||||||
.config.syncMode == "IMPORT" and
|
|
||||||
.config.validateSignature == "true"
|
|
||||||
' >/dev/null
|
|
||||||
|
|
||||||
client="$(
|
|
||||||
curl -fsS \
|
|
||||||
-H "Authorization: Bearer $admin_token" \
|
|
||||||
"$keycloak_url/admin/realms/mock-google/clients?clientId=mock-google-broker"
|
|
||||||
)"
|
|
||||||
|
|
||||||
printf '%s' "$client" | jq -e '
|
|
||||||
length == 1 and
|
|
||||||
.[0].publicClient == false and
|
|
||||||
(.[0].redirectUris | index(
|
|
||||||
"http://localhost:8080/realms/keycloak-patterns/broker/mock-google/endpoint"
|
|
||||||
)) != null
|
|
||||||
' >/dev/null
|
|
||||||
|
|
||||||
location="$(
|
|
||||||
curl -sS -D - -o /dev/null \
|
|
||||||
"$keycloak_url/realms/keycloak-patterns/protocol/openid-connect/auth?client_id=spa-public&redirect_uri=http%3A%2F%2Flocalhost%3A8088%2F&response_type=code&scope=openid&code_challenge=K2qUEfBl-nQvF2gB4dNxC2zYVwZc1CVnZb5CsX2L7fI&code_challenge_method=S256&kc_idp_hint=mock-google" |
|
|
||||||
awk 'BEGIN { IGNORECASE=1 } /^Location:/ { print $2 }' |
|
|
||||||
tr -d '\r'
|
|
||||||
)"
|
|
||||||
|
|
||||||
case "$location" in
|
|
||||||
"$keycloak_url/realms/keycloak-patterns/broker/mock-google/login"*) ;;
|
|
||||||
*)
|
|
||||||
echo "broker did not redirect to the controllable OIDC provider: $location" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
echo "Google broker contract verified with the local mock OIDC realm"
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
if [ ! -f .env ]; then
|
|
||||||
echo "missing .env" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
set -a
|
|
||||||
. ./.env
|
|
||||||
set +a
|
|
||||||
|
|
||||||
./scripts/configure-broker-user-profile.sh
|
|
||||||
./scripts/set-first-broker-login-mode.sh secure
|
|
||||||
npm --prefix google-e2e ci
|
|
||||||
npm --prefix google-e2e run test:claim-mapping
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
set -a
|
|
||||||
. ./.env
|
|
||||||
set +a
|
|
||||||
|
|
||||||
./scripts/configure-broker-user-profile.sh >/dev/null
|
|
||||||
./scripts/set-first-broker-login-mode.sh secure
|
|
||||||
|
|
||||||
docker compose exec -T keycloak \
|
|
||||||
/opt/keycloak/bin/kcadm.sh create roles -r keycloak-patterns \
|
|
||||||
-s name=employee-role \
|
|
||||||
-s 'description=Assigned from the upstream hd claim' >/dev/null 2>&1 || true
|
|
||||||
|
|
||||||
cd google-e2e
|
|
||||||
npm install --ignore-scripts
|
|
||||||
npm run test:claim-to-role
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
realm="${KEYCLOAK_REALM:-keycloak-patterns}"
|
|
||||||
public_keycloak_url="${PUBLIC_KEYCLOAK_URL:-https://auth.example.test}"
|
|
||||||
expected="$public_keycloak_url/realms/$realm/broker/google/endpoint"
|
|
||||||
|
|
||||||
case "$public_keycloak_url" in
|
|
||||||
https://*) ;;
|
|
||||||
*)
|
|
||||||
echo "PUBLIC_KEYCLOAK_URL must use https outside the local mock environment" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
case "$public_keycloak_url" in
|
|
||||||
*\** | */)
|
|
||||||
echo "PUBLIC_KEYCLOAK_URL must be an exact origin without wildcard/trailing slash" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
test "$expected" = "https://auth.example.test/realms/keycloak-patterns/broker/google/endpoint"
|
|
||||||
echo "Google redirect URI policy verified: $expected"
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
test_dir="$(mktemp -d)"
|
|
||||||
cleanup() {
|
|
||||||
rm -rf "$test_dir"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
|
|
||||||
-subj "/CN=auth.example.test" \
|
|
||||||
-keyout "$test_dir/tls.key" \
|
|
||||||
-out "$test_dir/tls.crt" >/dev/null 2>&1
|
|
||||||
|
|
||||||
docker run --rm \
|
|
||||||
--add-host keycloak:127.0.0.1 \
|
|
||||||
-v "$PWD/deploy/tls/nginx.conf:/etc/nginx/nginx.conf:ro" \
|
|
||||||
-v "$test_dir:/etc/tls:ro" \
|
|
||||||
nginx:1.29-alpine nginx -t
|
|
||||||
|
|
||||||
docker run --rm \
|
|
||||||
--add-host keycloak:127.0.0.1 \
|
|
||||||
-v "$PWD/deploy/tls/Caddyfile:/etc/caddy/Caddyfile:ro" \
|
|
||||||
-v "$test_dir:/etc/tls:ro" \
|
|
||||||
caddy:2.10.2-alpine caddy validate --config /etc/caddy/Caddyfile
|
|
||||||
|
|
||||||
echo "nginx and Caddy HTTPS termination configurations verified"
|
|
||||||
Executable
+21
@@ -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:pattern2
|
||||||
|
|
||||||
|
echo "AP2 confidential client boundary verified"
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
config=deploy/tunnel/cloudflared-config.yml
|
|
||||||
grep -q '^tunnel: [0-9a-f-]*$' "$config"
|
|
||||||
grep -q '^ - hostname: auth.example.test$' "$config"
|
|
||||||
grep -q '^ service: http://reverse-proxy:8080$' "$config"
|
|
||||||
grep -q '^ - service: http_status:404$' "$config"
|
|
||||||
|
|
||||||
docker run --rm \
|
|
||||||
-v "$PWD/$config:/etc/cloudflared/config.yml:ro" \
|
|
||||||
cloudflare/cloudflared:2025.6.1 \
|
|
||||||
tunnel --config /etc/cloudflared/config.yml ingress validate
|
|
||||||
|
|
||||||
echo "Cloudflare named-tunnel ingress configuration verified"
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
config=deploy/reverse-proxy/nginx-keycloak.conf
|
|
||||||
env_file=deploy/reverse-proxy/keycloak.env.example
|
|
||||||
|
|
||||||
grep -q 'proxy_set_header X-Forwarded-Host' "$config"
|
|
||||||
grep -q 'proxy_set_header X-Forwarded-Port 443' "$config"
|
|
||||||
grep -q 'proxy_set_header X-Forwarded-Proto https' "$config"
|
|
||||||
grep -q '^KC_PROXY_HEADERS=xforwarded$' "$env_file"
|
|
||||||
grep -q '^KC_HOSTNAME=https://' "$env_file"
|
|
||||||
|
|
||||||
docker run --rm \
|
|
||||||
--add-host keycloak:127.0.0.1 \
|
|
||||||
-v "$PWD/$config:/etc/nginx/conf.d/default.conf:ro" \
|
|
||||||
nginx:1.29-alpine nginx -t
|
|
||||||
|
|
||||||
echo "Reverse-proxy header and Keycloak hostname contracts verified"
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
target/
|
||||||
@@ -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"]
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -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>
|
||||||
+50
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user