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