From ed064473dccd0d49599a5801518dbeaca9242686 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 16:26:59 +0900 Subject: [PATCH 1/7] feat: map broker claims to realm roles --- docs/google-claim-to-role.md | 18 ++++++ google-e2e/claim-to-role.mjs | 68 ++++++++++++++++++++ google-e2e/package.json | 3 +- keycloak/import/keycloak-patterns-realm.json | 15 +++++ scripts/configure-google-idp.sh | 4 ++ scripts/verify-google-claim-to-role.sh | 18 ++++++ 6 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 docs/google-claim-to-role.md create mode 100644 google-e2e/claim-to-role.mjs create mode 100755 scripts/verify-google-claim-to-role.sh diff --git a/docs/google-claim-to-role.md b/docs/google-claim-to-role.md new file mode 100644 index 0000000..18238a1 --- /dev/null +++ b/docs/google-claim-to-role.md @@ -0,0 +1,18 @@ +# 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`를 차례로 확인한다. diff --git a/google-e2e/claim-to-role.mjs b/google-e2e/claim-to-role.mjs new file mode 100644 index 0000000..611bd56 --- /dev/null +++ b/google-e2e/claim-to-role.mjs @@ -0,0 +1,68 @@ +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(); +} diff --git a/google-e2e/package.json b/google-e2e/package.json index 9be0821..f61a7de 100644 --- a/google-e2e/package.json +++ b/google-e2e/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "test:first-broker": "node first-broker-login.mjs", - "test:claim-mapping": "node claim-mapping.mjs" + "test:claim-mapping": "node claim-mapping.mjs", + "test:claim-to-role": "node claim-to-role.mjs" }, "dependencies": { "playwright-core": "1.55.1" diff --git a/keycloak/import/keycloak-patterns-realm.json b/keycloak/import/keycloak-patterns-realm.json index 3f31e84..fad42e2 100644 --- a/keycloak/import/keycloak-patterns-realm.json +++ b/keycloak/import/keycloak-patterns-realm.json @@ -24,6 +24,10 @@ { "name": "user-role", "description": "Regular authenticated user role" + }, + { + "name": "employee-role", + "description": "Assigned to brokered identities whose hosted-domain claim is example.test" } ] }, @@ -213,6 +217,17 @@ "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": [ diff --git a/scripts/configure-google-idp.sh b/scripts/configure-google-idp.sh index b39ffb8..36bb28e 100755 --- a/scripts/configure-google-idp.sh +++ b/scripts/configure-google-idp.sh @@ -144,6 +144,10 @@ 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:" diff --git a/scripts/verify-google-claim-to-role.sh b/scripts/verify-google-claim-to-role.sh new file mode 100755 index 0000000..ec56589 --- /dev/null +++ b/scripts/verify-google-claim-to-role.sh @@ -0,0 +1,18 @@ +#!/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 From bdde0feb862a459d6ff50ae5c6676e373a610446 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 16:28:00 +0900 Subject: [PATCH 2/7] test: verify broker identity uses subject not email --- docs/account-linking-sub-vs-email.md | 18 +++ google-e2e/package.json | 3 +- google-e2e/sub-vs-email.mjs | 132 ++++++++++++++++++ .../verify-account-linking-sub-vs-email.sh | 11 ++ 4 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 docs/account-linking-sub-vs-email.md create mode 100644 google-e2e/sub-vs-email.mjs create mode 100755 scripts/verify-account-linking-sub-vs-email.sh diff --git a/docs/account-linking-sub-vs-email.md b/docs/account-linking-sub-vs-email.md new file mode 100644 index 0000000..80fb09c --- /dev/null +++ b/docs/account-linking-sub-vs-email.md @@ -0,0 +1,18 @@ +# 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을 복구한다. diff --git a/google-e2e/package.json b/google-e2e/package.json index f61a7de..71ca2a3 100644 --- a/google-e2e/package.json +++ b/google-e2e/package.json @@ -6,7 +6,8 @@ "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:claim-to-role": "node claim-to-role.mjs", + "test:sub-vs-email": "node sub-vs-email.mjs" }, "dependencies": { "playwright-core": "1.55.1" diff --git a/google-e2e/sub-vs-email.mjs b/google-e2e/sub-vs-email.mjs new file mode 100644 index 0000000..b10995c --- /dev/null +++ b/google-e2e/sub-vs-email.mjs @@ -0,0 +1,132 @@ +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(); +} diff --git a/scripts/verify-account-linking-sub-vs-email.sh b/scripts/verify-account-linking-sub-vs-email.sh new file mode 100755 index 0000000..efc7142 --- /dev/null +++ b/scripts/verify-account-linking-sub-vs-email.sh @@ -0,0 +1,11 @@ +#!/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 From f077e5038e1df5d71b31530bc2bf14d227e66906 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 16:28:53 +0900 Subject: [PATCH 3/7] docs: define exact Google redirect URI policy --- docs/google-redirect-uri-policy.md | 24 ++++++++++++++++++++ scripts/verify-google-redirect-uri-policy.sh | 24 ++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 docs/google-redirect-uri-policy.md create mode 100755 scripts/verify-google-redirect-uri-policy.sh diff --git a/docs/google-redirect-uri-policy.md b/docs/google-redirect-uri-policy.md new file mode 100644 index 0000000..6ca8c89 --- /dev/null +++ b/docs/google-redirect-uri-policy.md @@ -0,0 +1,24 @@ +# 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 +``` diff --git a/scripts/verify-google-redirect-uri-policy.sh b/scripts/verify-google-redirect-uri-policy.sh new file mode 100755 index 0000000..02bb841 --- /dev/null +++ b/scripts/verify-google-redirect-uri-policy.sh @@ -0,0 +1,24 @@ +#!/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" From 8539d1bf5bd5d2ebaffaa1a071e3212a642f12b0 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 16:29:32 +0900 Subject: [PATCH 4/7] feat: define trusted reverse proxy header contract --- deploy/reverse-proxy/keycloak.env.example | 6 ++++++ deploy/reverse-proxy/nginx-keycloak.conf | 14 ++++++++++++++ docs/reverse-proxy-headers.md | 15 +++++++++++++++ scripts/verify-reverse-proxy-headers.sh | 18 ++++++++++++++++++ 4 files changed, 53 insertions(+) create mode 100644 deploy/reverse-proxy/keycloak.env.example create mode 100644 deploy/reverse-proxy/nginx-keycloak.conf create mode 100644 docs/reverse-proxy-headers.md create mode 100755 scripts/verify-reverse-proxy-headers.sh diff --git a/deploy/reverse-proxy/keycloak.env.example b/deploy/reverse-proxy/keycloak.env.example new file mode 100644 index 0000000..8056c8c --- /dev/null +++ b/deploy/reverse-proxy/keycloak.env.example @@ -0,0 +1,6 @@ +# 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 + diff --git a/deploy/reverse-proxy/nginx-keycloak.conf b/deploy/reverse-proxy/nginx-keycloak.conf new file mode 100644 index 0000000..9f68882 --- /dev/null +++ b/deploy/reverse-proxy/nginx-keycloak.conf @@ -0,0 +1,14 @@ +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; + } +} diff --git a/docs/reverse-proxy-headers.md b/docs/reverse-proxy-headers.md new file mode 100644 index 0000000..902b816 --- /dev/null +++ b/docs/reverse-proxy-headers.md @@ -0,0 +1,15 @@ +# 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 구문을 +검증한다. diff --git a/scripts/verify-reverse-proxy-headers.sh b/scripts/verify-reverse-proxy-headers.sh new file mode 100755 index 0000000..3a1e526 --- /dev/null +++ b/scripts/verify-reverse-proxy-headers.sh @@ -0,0 +1,18 @@ +#!/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" From e4cee2a06df6b7df41f917153e2d5a86636fc5a5 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 16:30:25 +0900 Subject: [PATCH 5/7] feat: add validated HTTPS termination profiles --- deploy/tls/Caddyfile | 10 ++++++++ deploy/tls/nginx.conf | 21 +++++++++++++++++ docs/https-termination.md | 20 ++++++++++++++++ scripts/verify-https-termination-config.sh | 27 ++++++++++++++++++++++ 4 files changed, 78 insertions(+) create mode 100644 deploy/tls/Caddyfile create mode 100644 deploy/tls/nginx.conf create mode 100644 docs/https-termination.md create mode 100755 scripts/verify-https-termination-config.sh diff --git a/deploy/tls/Caddyfile b/deploy/tls/Caddyfile new file mode 100644 index 0000000..c89d0f2 --- /dev/null +++ b/deploy/tls/Caddyfile @@ -0,0 +1,10 @@ +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 + } +} diff --git a/deploy/tls/nginx.conf b/deploy/tls/nginx.conf new file mode 100644 index 0000000..586f8d4 --- /dev/null +++ b/deploy/tls/nginx.conf @@ -0,0 +1,21 @@ +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; + } + } +} diff --git a/docs/https-termination.md b/docs/https-termination.md new file mode 100644 index 0000000..794acc1 --- /dev/null +++ b/docs/https-termination.md @@ -0,0 +1,20 @@ +# 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 +``` diff --git a/scripts/verify-https-termination-config.sh b/scripts/verify-https-termination-config.sh new file mode 100755 index 0000000..3870506 --- /dev/null +++ b/scripts/verify-https-termination-config.sh @@ -0,0 +1,27 @@ +#!/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" From eacc0e86c975e59315393bbd5e7b72b9ba6b4ed0 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 16:31:13 +0900 Subject: [PATCH 6/7] feat: add validated named tunnel profile --- deploy/tunnel/cloudflared-config.yml | 7 +++++++ docs/public-domain-tunneling.md | 21 +++++++++++++++++++++ scripts/verify-public-tunnel-config.sh | 15 +++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 deploy/tunnel/cloudflared-config.yml create mode 100644 docs/public-domain-tunneling.md create mode 100755 scripts/verify-public-tunnel-config.sh diff --git a/deploy/tunnel/cloudflared-config.yml b/deploy/tunnel/cloudflared-config.yml new file mode 100644 index 0000000..028ceff --- /dev/null +++ b/deploy/tunnel/cloudflared-config.yml @@ -0,0 +1,7 @@ +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 diff --git a/docs/public-domain-tunneling.md b/docs/public-domain-tunneling.md new file mode 100644 index 0000000..676232d --- /dev/null +++ b/docs/public-domain-tunneling.md @@ -0,0 +1,21 @@ +# 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 파일의 구조까지만 수행한다. diff --git a/scripts/verify-public-tunnel-config.sh b/scripts/verify-public-tunnel-config.sh new file mode 100755 index 0000000..dec857b --- /dev/null +++ b/scripts/verify-public-tunnel-config.sh @@ -0,0 +1,15 @@ +#!/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" From d01a60964ac044b95e4cb19dfd26d12f9b03c597 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 16:31:44 +0900 Subject: [PATCH 7/7] docs: compare four authentication patterns --- docs/four-pattern-tradeoff-matrix.md | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/four-pattern-tradeoff-matrix.md diff --git a/docs/four-pattern-tradeoff-matrix.md b/docs/four-pattern-tradeoff-matrix.md new file mode 100644 index 0000000..51f117c --- /dev/null +++ b/docs/four-pattern-tradeoff-matrix.md @@ -0,0 +1,37 @@ +# 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`로 추적한다.