From 728e737dc8b17d108b37d157a9c305b8d761536b Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 15:26:26 +0900 Subject: [PATCH 1/3] docs: add 39-branch Keycloak governance index --- .gitignore | 3 ++ README.md | 3 ++ docs/keycloak-branch-index.md | 29 ++++++++++++++ docs/keycloak-branch-manifest.tsv | 40 +++++++++++++++++++ scripts/audit-keycloak-branches.sh | 62 ++++++++++++++++++++++++++++++ 5 files changed, 137 insertions(+) create mode 100644 docs/keycloak-branch-index.md create mode 100644 docs/keycloak-branch-manifest.tsv create mode 100755 scripts/audit-keycloak-branches.sh diff --git a/.gitignore b/.gitignore index 8f0f1cc..c021301 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ backend/target/ build/ +e2e/node_modules/ +frontend/node_modules/ +frontend/dist/ diff --git a/README.md b/README.md index ac1a49e..b502271 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Keycloak Authentication Patterns +The 39-branch implementation registry is documented in +[`docs/keycloak-branch-index.md`](docs/keycloak-branch-index.md). + Keycloak을 중심으로 네 가지 브라우저 인증 통합 패턴을 같은 로컬 인프라에서 비교하는 학습 프로젝트입니다. diff --git a/docs/keycloak-branch-index.md b/docs/keycloak-branch-index.md new file mode 100644 index 0000000..b35cba6 --- /dev/null +++ b/docs/keycloak-branch-index.md @@ -0,0 +1,29 @@ +# 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. diff --git a/docs/keycloak-branch-manifest.tsv b/docs/keycloak-branch-manifest.tsv new file mode 100644 index 0000000..bcbe1f7 --- /dev/null +++ b/docs/keycloak-branch-manifest.tsv @@ -0,0 +1,40 @@ +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 diff --git a/scripts/audit-keycloak-branches.sh b/scripts/audit-keycloak-branches.sh new file mode 100755 index 0000000..b6279ae --- /dev/null +++ b/scripts/audit-keycloak-branches.sh @@ -0,0 +1,62 @@ +#!/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" From 2ee4b2af1c6747697ff95a5eb42f67d5b07f6703 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 15:30:47 +0900 Subject: [PATCH 2/3] feat: add Google broker configuration profiles --- .env.example | 7 ++ README.md | 4 + docker-compose.yml | 2 + docs/google-idp-brokering.md | 28 +++++++ keycloak/import/keycloak-patterns-realm.json | 30 ++++++++ keycloak/import/mock-google-realm.json | 71 +++++++++++++++++ scripts/configure-google-idp.sh | 81 ++++++++++++++++++++ scripts/verify-google-broker-config.sh | 70 +++++++++++++++++ 8 files changed, 293 insertions(+) create mode 100644 docs/google-idp-brokering.md create mode 100644 keycloak/import/mock-google-realm.json create mode 100755 scripts/configure-google-idp.sh create mode 100755 scripts/verify-google-broker-config.sh diff --git a/.env.example b/.env.example index 1bc37f1..37c5628 100644 --- a/.env.example +++ b/.env.example @@ -10,8 +10,15 @@ POSTGRES_PASSWORD=change-me-postgres-password TOKEN_MEDIATING_CLIENT_SECRET=change-me-token-mediating-client-secret BFF_CLIENT_SECRET=change-me-bff-client-secret EDGE_PROXY_CLIENT_SECRET=change-me-edge-proxy-client-secret +MOCK_GOOGLE_BROKER_CLIENT_SECRET=change-me-mock-google-broker-client-secret ADMIN_USER_PASSWORD=change-me-admin-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. NGINX_PORT=8088 diff --git a/README.md b/README.md index b502271..29e6033 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,10 @@ 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을 중심으로 네 가지 브라우저 인증 통합 패턴을 같은 로컬 인프라에서 비교하는 학습 프로젝트입니다. diff --git a/docker-compose.yml b/docker-compose.yml index 585bebf..73f52fc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,8 +38,10 @@ services: 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} + 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} 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: - "127.0.0.1:8080:8080" volumes: diff --git a/docs/google-idp-brokering.md b/docs/google-idp-brokering.md new file mode 100644 index 0000000..089c7f1 --- /dev/null +++ b/docs/google-idp-brokering.md @@ -0,0 +1,28 @@ +# 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. diff --git a/keycloak/import/keycloak-patterns-realm.json b/keycloak/import/keycloak-patterns-realm.json index 9a4fae9..5ec8bd8 100644 --- a/keycloak/import/keycloak-patterns-realm.json +++ b/keycloak/import/keycloak-patterns-realm.json @@ -124,6 +124,36 @@ } } ], + "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" + } + } + ], "users": [ { "username": "admin-user", diff --git a/keycloak/import/mock-google-realm.json b/keycloak/import/mock-google-realm.json new file mode 100644 index 0000000..8ad4f8f --- /dev/null +++ b/keycloak/import/mock-google-realm.json @@ -0,0 +1,71 @@ +{ + "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": [] + } + ], + "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", + "attributes": { + "hd": [ + "example.test" + ], + "picture": [ + "https://images.example.test/mock-collision-user.png" + ] + }, + "credentials": [ + { + "type": "password", + "value": "${MOCK_GOOGLE_USER_PASSWORD}", + "temporary": false + } + ] + } + ] +} diff --git a/scripts/configure-google-idp.sh b/scripts/configure-google-idp.sh new file mode 100755 index 0000000..dcbddd9 --- /dev/null +++ b/scripts/configure-google-idp.sh @@ -0,0 +1,81 @@ +#!/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}" + +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 + +echo "Google Identity Provider $action for realm '$realm'" +echo "Register this exact Google redirect URI:" +echo "$keycloak_url/realms/$realm/broker/google/endpoint" diff --git a/scripts/verify-google-broker-config.sh b/scripts/verify-google-broker-config.sh new file mode 100755 index 0000000..ed4dff4 --- /dev/null +++ b/scripts/verify-google-broker-config.sh @@ -0,0 +1,70 @@ +#!/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" From aeb783e5927b6919919d791d931f761ce4c83356 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 16:17:54 +0900 Subject: [PATCH 3/3] test: reproduce and block unsafe broker auto-link --- .gitignore | 1 + docs/first-broker-login-security.md | 27 ++++ google-e2e/first-broker-login.mjs | 124 +++++++++++++++++ google-e2e/package-lock.json | 27 ++++ google-e2e/package.json | 12 ++ scripts/set-first-broker-login-mode.sh | 178 +++++++++++++++++++++++++ scripts/verify-first-broker-login.sh | 29 ++++ 7 files changed, 398 insertions(+) create mode 100644 docs/first-broker-login-security.md create mode 100644 google-e2e/first-broker-login.mjs create mode 100644 google-e2e/package-lock.json create mode 100644 google-e2e/package.json create mode 100755 scripts/set-first-broker-login-mode.sh create mode 100755 scripts/verify-first-broker-login.sh diff --git a/.gitignore b/.gitignore index c021301..90ea2f6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,6 @@ backend/target/ build/ e2e/node_modules/ +google-e2e/node_modules/ frontend/node_modules/ frontend/dist/ diff --git a/docs/first-broker-login-security.md b/docs/first-broker-login-security.md new file mode 100644 index 0000000..429d677 --- /dev/null +++ b/docs/first-broker-login-security.md @@ -0,0 +1,27 @@ +# 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. diff --git a/google-e2e/first-broker-login.mjs b/google-e2e/first-broker-login.mjs new file mode 100644 index 0000000..75dd7f5 --- /dev/null +++ b/google-e2e/first-broker-login.mjs @@ -0,0 +1,124 @@ +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(); +} diff --git a/google-e2e/package-lock.json b/google-e2e/package-lock.json new file mode 100644 index 0000000..6407ce5 --- /dev/null +++ b/google-e2e/package-lock.json @@ -0,0 +1,27 @@ +{ + "name": "keycloak-google-broker-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "keycloak-google-broker-e2e", + "version": "1.0.0", + "dependencies": { + "playwright-core": "1.55.1" + } + }, + "node_modules/playwright-core": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz", + "integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/google-e2e/package.json b/google-e2e/package.json new file mode 100644 index 0000000..5727e22 --- /dev/null +++ b/google-e2e/package.json @@ -0,0 +1,12 @@ +{ + "name": "keycloak-google-broker-e2e", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "test:first-broker": "node first-broker-login.mjs" + }, + "dependencies": { + "playwright-core": "1.55.1" + } +} diff --git a/scripts/set-first-broker-login-mode.sh b/scripts/set-first-broker-login-mode.sh new file mode 100755 index 0000000..fd92950 --- /dev/null +++ b/scripts/set-first-broker-login-mode.sh @@ -0,0 +1,178 @@ +#!/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)" diff --git a/scripts/verify-first-broker-login.sh b/scripts/verify-first-broker-login.sh new file mode 100755 index 0000000..28f5ce3 --- /dev/null +++ b/scripts/verify-first-broker-login.sh @@ -0,0 +1,29 @@ +#!/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"