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"