From 998eada6f50784ea9207f8c8075f3fbe001efd82 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 13:21:36 +0900 Subject: [PATCH] feat: add shared realm and pattern clients --- .env.example | 7 + README.md | 33 ++++ docker-compose.yml | 5 + keycloak/import/keycloak-patterns-realm.json | 165 +++++++++++++++++++ scripts/export-realm.sh | 30 ++++ scripts/validate-realm.py | 118 +++++++++++++ scripts/verify-realm.sh | 39 +++++ 7 files changed, 397 insertions(+) create mode 100644 keycloak/import/keycloak-patterns-realm.json create mode 100755 scripts/export-realm.sh create mode 100755 scripts/validate-realm.py create mode 100755 scripts/verify-realm.sh diff --git a/.env.example b/.env.example index a5752c7..1bc37f1 100644 --- a/.env.example +++ b/.env.example @@ -6,5 +6,12 @@ POSTGRES_DB=keycloak POSTGRES_USER=keycloak POSTGRES_PASSWORD=change-me-postgres-password +# Keycloak resolves these placeholders while importing the realm. +TOKEN_MEDIATING_CLIENT_SECRET=change-me-token-mediating-client-secret +BFF_CLIENT_SECRET=change-me-bff-client-secret +EDGE_PROXY_CLIENT_SECRET=change-me-edge-proxy-client-secret +ADMIN_USER_PASSWORD=change-me-admin-user-password +REGULAR_USER_PASSWORD=change-me-regular-user-password + # Port 80 is the single-EC2 target. 8088 avoids common local port conflicts. NGINX_PORT=8088 diff --git a/README.md b/README.md index 334caa5..ac1a49e 100644 --- a/README.md +++ b/README.md @@ -63,3 +63,36 @@ docker compose down -v && docker compose up --build -d `start-dev`와 로컬 HTTP 설정은 학습 전용입니다. 운영 환경에서는 optimized Keycloak image, HTTPS, 엄격한 hostname 및 외부 secret store를 사용해야 합니다. + +## Realm baseline + +`keycloak/import/keycloak-patterns-realm.json`은 시작 시 자동 import됩니다. +하나의 `keycloak-patterns` realm 안에서 패턴마다 client를 분리합니다. + +| Client | 유형 | 패턴 | +|---|---|---| +| `spa-public` | public + PKCE S256 | AP1 | +| `token-mediating-confidential` | confidential | AP2 | +| `bff-confidential` | confidential | AP3 | +| `edge-proxy` | confidential | AP4 | + +confidential client secret과 테스트 사용자 password는 JSON에 평문으로 +저장하지 않습니다. JSON에는 `${ENVIRONMENT_VARIABLE}` placeholder만 +커밋하고, Keycloak 26.7.0이 import 시 `.env` 값을 주입합니다. + +```bash +python3 scripts/validate-realm.py +docker compose down -v +docker compose up -d --wait +./scripts/verify-realm.sh +``` + +실행 중인 DB에서 CLI realm export를 재현하려면 다음 명령을 사용합니다. +Keycloak을 잠시 중지하고 export한 뒤 자동으로 다시 올립니다. + +```bash +./scripts/export-realm.sh +``` + +runtime export에는 실제 client secret과 credential hash가 포함될 수 있어 +gitignored `build/keycloak-export/`에 권한 `0600`으로만 저장됩니다. diff --git a/docker-compose.yml b/docker-compose.yml index f9ff2a4..585bebf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,11 @@ services: KC_HEALTH_ENABLED: "true" KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME:?set KC_BOOTSTRAP_ADMIN_USERNAME in .env} KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD:?set KC_BOOTSTRAP_ADMIN_PASSWORD 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} + EDGE_PROXY_CLIENT_SECRET: ${EDGE_PROXY_CLIENT_SECRET:?set EDGE_PROXY_CLIENT_SECRET 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} ports: - "127.0.0.1:8080:8080" volumes: diff --git a/keycloak/import/keycloak-patterns-realm.json b/keycloak/import/keycloak-patterns-realm.json new file mode 100644 index 0000000..9a4fae9 --- /dev/null +++ b/keycloak/import/keycloak-patterns-realm.json @@ -0,0 +1,165 @@ +{ + "realm": "keycloak-patterns", + "displayName": "Keycloak Authentication Patterns", + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "bruteForceProtected": true, + "accessTokenLifespan": 300, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "offlineSessionIdleTimeout": 2592000, + "revokeRefreshToken": true, + "refreshTokenMaxReuse": 0, + "roles": { + "realm": [ + { + "name": "admin-role", + "description": "Administrative role used by authorization examples" + }, + { + "name": "user-role", + "description": "Regular authenticated user role" + } + ] + }, + "clients": [ + { + "clientId": "spa-public", + "name": "AP1 SPA Public Client", + "description": "Browser-based OAuth client using Authorization Code and PKCE", + "enabled": true, + "protocol": "openid-connect", + "publicClient": true, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "frontchannelLogout": true, + "redirectUris": [ + "http://localhost:8088/*", + "http://127.0.0.1:8088/*" + ], + "webOrigins": [ + "http://localhost:8088", + "http://127.0.0.1:8088" + ], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "http://localhost:8088/*##http://127.0.0.1:8088/*" + } + }, + { + "clientId": "token-mediating-confidential", + "name": "AP2 Token-Mediating Backend", + "description": "Confidential backend that keeps refresh tokens server-side", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "clientAuthenticatorType": "client-secret", + "secret": "${TOKEN_MEDIATING_CLIENT_SECRET}", + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "frontchannelLogout": true, + "redirectUris": [ + "http://localhost:8082/login/oauth2/code/keycloak" + ], + "webOrigins": [ + "http://localhost:8082" + ], + "attributes": { + "post.logout.redirect.uris": "http://localhost:8082/*" + } + }, + { + "clientId": "bff-confidential", + "name": "AP3 Backend-for-Frontend", + "description": "Confidential BFF that keeps all OAuth tokens server-side", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "clientAuthenticatorType": "client-secret", + "secret": "${BFF_CLIENT_SECRET}", + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "frontchannelLogout": true, + "redirectUris": [ + "http://localhost:8083/login/oauth2/code/keycloak" + ], + "webOrigins": [ + "http://localhost:8083" + ], + "attributes": { + "post.logout.redirect.uris": "http://localhost:8083/*" + } + }, + { + "clientId": "edge-proxy", + "name": "AP4 Edge Forward Auth", + "description": "Confidential oauth2-proxy OIDC client", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "clientAuthenticatorType": "client-secret", + "secret": "${EDGE_PROXY_CLIENT_SECRET}", + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "frontchannelLogout": true, + "redirectUris": [ + "http://localhost:4180/oauth2/callback" + ], + "webOrigins": [], + "attributes": { + "post.logout.redirect.uris": "http://localhost:8088/*" + } + } + ], + "users": [ + { + "username": "admin-user", + "enabled": true, + "email": "admin-user@example.test", + "emailVerified": true, + "firstName": "Admin", + "lastName": "User", + "realmRoles": [ + "admin-role" + ], + "credentials": [ + { + "type": "password", + "value": "${ADMIN_USER_PASSWORD}", + "temporary": false + } + ] + }, + { + "username": "regular-user", + "enabled": true, + "email": "regular-user@example.test", + "emailVerified": true, + "firstName": "Regular", + "lastName": "User", + "realmRoles": [ + "user-role" + ], + "credentials": [ + { + "type": "password", + "value": "${REGULAR_USER_PASSWORD}", + "temporary": false + } + ] + } + ] +} diff --git a/scripts/export-realm.sh b/scripts/export-realm.sh new file mode 100755 index 0000000..a295465 --- /dev/null +++ b/scripts/export-realm.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env sh +set -eu + +compose_file=${COMPOSE_FILE:-docker-compose.yml} +output_dir=${REALM_EXPORT_DIR:-"$(pwd)/build/keycloak-export"} +output_file="$output_dir/keycloak-patterns-realm.json" + +mkdir -p "$output_dir" + +restart_keycloak() { + trap - EXIT INT TERM + docker compose -f "$compose_file" up -d --wait >/dev/null +} +trap restart_keycloak EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +docker compose -f "$compose_file" stop keycloak >/dev/null +docker compose -f "$compose_file" run --rm --no-deps \ + --volume "$output_dir:/opt/keycloak/data/export" \ + keycloak \ + export \ + --realm keycloak-patterns \ + --file /opt/keycloak/data/export/keycloak-patterns-realm.json \ + --users same_file + +chmod 600 "$output_file" +python3 scripts/validate-realm.py --runtime "$output_file" + +echo "runtime realm export written to $output_file" diff --git a/scripts/validate-realm.py b/scripts/validate-realm.py new file mode 100755 index 0000000..397886c --- /dev/null +++ b/scripts/validate-realm.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Validate the committed realm template or a runtime Keycloak CLI export.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +REALM_NAME = "keycloak-patterns" +PUBLIC_CLIENT = "spa-public" +CONFIDENTIAL_CLIENTS = { + "token-mediating-confidential": "${TOKEN_MEDIATING_CLIENT_SECRET}", + "bff-confidential": "${BFF_CLIENT_SECRET}", + "edge-proxy": "${EDGE_PROXY_CLIENT_SECRET}", +} +EXPECTED_ROLES = {"admin-role", "user-role"} +EXPECTED_USERS = {"admin-user", "regular-user"} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument( + "realm_file", + nargs="?", + type=Path, + default=Path("keycloak/import/keycloak-patterns-realm.json"), + ) + parser.add_argument( + "--runtime", + action="store_true", + help="validate an expanded CLI export instead of the committed template", + ) + return parser.parse_args() + + +def require(condition: bool, message: str) -> None: + if not condition: + raise SystemExit(f"realm validation failed: {message}") + + +def indexed(items: list[dict[str, Any]], key: str) -> dict[str, dict[str, Any]]: + return {str(item[key]): item for item in items} + + +def validate(path: Path, runtime: bool) -> None: + document = json.loads(path.read_text(encoding="utf-8")) + require(document.get("realm") == REALM_NAME, f"realm must be {REALM_NAME}") + require(document.get("enabled") is True, "realm must be enabled") + require(document.get("accessTokenLifespan") == 300, "access token TTL must be 300s") + require(document.get("revokeRefreshToken") is True, "refresh token rotation must be enabled") + require(document.get("refreshTokenMaxReuse") == 0, "refresh token max reuse must be 0") + + clients = indexed(document.get("clients", []), "clientId") + expected_client_ids = {PUBLIC_CLIENT, *CONFIDENTIAL_CLIENTS} + require(expected_client_ids <= clients.keys(), "all four pattern clients must exist") + if not runtime: + require(clients.keys() == expected_client_ids, "template must declare exactly four clients") + + spa = clients[PUBLIC_CLIENT] + require(spa.get("publicClient") is True, "spa-public must be a public client") + require("secret" not in spa, "spa-public must not have a client secret") + require(spa.get("standardFlowEnabled") is True, "spa-public standard flow must be enabled") + require( + spa.get("directAccessGrantsEnabled") is False, + "spa-public direct access grants must be disabled", + ) + require( + spa.get("attributes", {}).get("pkce.code.challenge.method") == "S256", + "spa-public must enforce PKCE S256", + ) + + for client_id, placeholder in CONFIDENTIAL_CLIENTS.items(): + client = clients[client_id] + require(client.get("publicClient") is False, f"{client_id} must be confidential") + require( + client.get("clientAuthenticatorType") == "client-secret", + f"{client_id} must use client-secret authentication", + ) + secret = client.get("secret") + if runtime: + require(isinstance(secret, str) and len(secret) >= 16, f"{client_id} secret missing") + require(not secret.startswith("${"), f"{client_id} placeholder was not resolved") + else: + require(secret == placeholder, f"{client_id} must use an env placeholder") + + roles = { + role["name"] + for role in document.get("roles", {}).get("realm", []) + if "name" in role + } + require(EXPECTED_ROLES <= roles, "admin-role and user-role must exist") + + users = indexed(document.get("users", []), "username") + require(EXPECTED_USERS <= users.keys(), "both baseline users must exist") + if not runtime: + expected_passwords = { + "admin-user": "${ADMIN_USER_PASSWORD}", + "regular-user": "${REGULAR_USER_PASSWORD}", + } + for username, placeholder in expected_passwords.items(): + credentials = users[username].get("credentials", []) + require(len(credentials) == 1, f"{username} must have one initial credential") + require( + credentials[0].get("value") == placeholder, + f"{username} password must use an env placeholder", + ) + + print( + f"realm validated: {REALM_NAME}, four pattern clients, " + f"{len(EXPECTED_ROLES)} roles, {len(EXPECTED_USERS)} users" + ) + + +if __name__ == "__main__": + arguments = parse_args() + validate(arguments.realm_file, arguments.runtime) diff --git a/scripts/verify-realm.sh b/scripts/verify-realm.sh new file mode 100755 index 0000000..06cf357 --- /dev/null +++ b/scripts/verify-realm.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env sh +set -eu + +compose_file=${COMPOSE_FILE:-docker-compose.yml} + +python3 scripts/validate-realm.py +./scripts/verify-stack.sh + +curl --fail --silent --show-error --output /dev/null \ + "http://localhost:8080/realms/keycloak-patterns/.well-known/openid-configuration" + +docker compose -f "$compose_file" exec -T keycloak sh -ec ' + kcadm=/opt/keycloak/bin/kcadm.sh + "$kcadm" config credentials \ + --server http://localhost:8080 \ + --realm master \ + --user "$KC_BOOTSTRAP_ADMIN_USERNAME" \ + --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" >/dev/null + + clients=$("$kcadm" get clients -r keycloak-patterns --fields clientId) + for client in \ + spa-public \ + token-mediating-confidential \ + bff-confidential \ + edge-proxy + do + printf "%s" "$clients" | grep -q "\"$client\"" + done + + users=$("$kcadm" get users -r keycloak-patterns --fields username) + printf "%s" "$users" | grep -q "\"admin-user\"" + printf "%s" "$users" | grep -q "\"regular-user\"" + + roles=$("$kcadm" get roles -r keycloak-patterns --fields name) + printf "%s" "$roles" | grep -q "\"admin-role\"" + printf "%s" "$roles" | grep -q "\"user-role\"" +' + +echo "realm verified: import, discovery, four clients, two roles, two users"