diff --git a/docs/google-claim-mapping.md b/docs/google-claim-mapping.md new file mode 100644 index 0000000..4998447 --- /dev/null +++ b/docs/google-claim-mapping.md @@ -0,0 +1,23 @@ +# 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. diff --git a/google-e2e/claim-mapping.mjs b/google-e2e/claim-mapping.mjs new file mode 100644 index 0000000..9be4c2a --- /dev/null +++ b/google-e2e/claim-mapping.mjs @@ -0,0 +1,123 @@ +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(); +} diff --git a/google-e2e/package.json b/google-e2e/package.json index 5727e22..9be0821 100644 --- a/google-e2e/package.json +++ b/google-e2e/package.json @@ -4,7 +4,8 @@ "private": true, "type": "module", "scripts": { - "test:first-broker": "node first-broker-login.mjs" + "test:first-broker": "node first-broker-login.mjs", + "test:claim-mapping": "node claim-mapping.mjs" }, "dependencies": { "playwright-core": "1.55.1" diff --git a/keycloak/import/keycloak-patterns-realm.json b/keycloak/import/keycloak-patterns-realm.json index c6acf67..e45a188 100644 --- a/keycloak/import/keycloak-patterns-realm.json +++ b/keycloak/import/keycloak-patterns-realm.json @@ -170,6 +170,67 @@ } } ], + "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" + } + } + ], "users": [ { "username": "admin-user", diff --git a/keycloak/import/mock-google-realm.json b/keycloak/import/mock-google-realm.json index 8ad4f8f..0b49b07 100644 --- a/keycloak/import/mock-google-realm.json +++ b/keycloak/import/mock-google-realm.json @@ -25,7 +25,37 @@ "redirectUris": [ "http://localhost:8080/realms/keycloak-patterns/broker/mock-google/endpoint" ], - "webOrigins": [] + "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": [ @@ -51,14 +81,6 @@ "emailVerified": false, "firstName": "Broker", "lastName": "Collision", - "attributes": { - "hd": [ - "example.test" - ], - "picture": [ - "https://images.example.test/mock-collision-user.png" - ] - }, "credentials": [ { "type": "password", diff --git a/scripts/configure-broker-user-profile.sh b/scripts/configure-broker-user-profile.sh new file mode 100755 index 0000000..e07764f --- /dev/null +++ b/scripts/configure-broker-user-profile.sh @@ -0,0 +1,58 @@ +#!/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'" diff --git a/scripts/configure-google-idp.sh b/scripts/configure-google-idp.sh index dcbddd9..b39ffb8 100755 --- a/scripts/configure-google-idp.sh +++ b/scripts/configure-google-idp.sh @@ -15,6 +15,8 @@ set +a : "${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}" @@ -76,6 +78,73 @@ else 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"}' + 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-claim-mapping.sh b/scripts/verify-google-claim-mapping.sh new file mode 100755 index 0000000..ad90ea1 --- /dev/null +++ b/scripts/verify-google-claim-mapping.sh @@ -0,0 +1,16 @@ +#!/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