From bdde0feb862a459d6ff50ae5c6676e373a610446 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 16:28:00 +0900 Subject: [PATCH] test: verify broker identity uses subject not email --- docs/account-linking-sub-vs-email.md | 18 +++ google-e2e/package.json | 3 +- google-e2e/sub-vs-email.mjs | 132 ++++++++++++++++++ .../verify-account-linking-sub-vs-email.sh | 11 ++ 4 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 docs/account-linking-sub-vs-email.md create mode 100644 google-e2e/sub-vs-email.mjs create mode 100755 scripts/verify-account-linking-sub-vs-email.sh diff --git a/docs/account-linking-sub-vs-email.md b/docs/account-linking-sub-vs-email.md new file mode 100644 index 0000000..80fb09c --- /dev/null +++ b/docs/account-linking-sub-vs-email.md @@ -0,0 +1,18 @@ +# Federated account key: `sub`, not email + +외부 IdP의 email은 표시·연락 속성이지 계정 식별자나 자동 연결 증명이 아니다. +Keycloak의 federated identity는 provider alias와 provider user ID(`sub`)를 +로컬 사용자에 연결한다. + +정책: + +- 신규 identity의 email이 기존 로컬 계정과 충돌하면 기존 계정의 인증을 다시 + 요구하는 기본 First Broker Login flow를 사용한다. +- `Automatically Set Existing User`를 production flow에 넣지 않는다. +- upstream email 변경은 같은 `sub`의 계정 귀속을 바꾸지 않는다. +- 마지막 로그인 수단을 unlink하는 UI에서는 먼저 다른 인증 수단을 등록하도록 + 안내한다. + +`verify-account-linking-sub-vs-email.sh`는 mock IdP 사용자의 email을 실제로 +변경하고 다시 로그인한다. 로컬 사용자 ID가 유지되고 federated `userId`가 +upstream `sub`와 같은지 확인한 후 원래 email을 복구한다. diff --git a/google-e2e/package.json b/google-e2e/package.json index f61a7de..71ca2a3 100644 --- a/google-e2e/package.json +++ b/google-e2e/package.json @@ -6,7 +6,8 @@ "scripts": { "test:first-broker": "node first-broker-login.mjs", "test:claim-mapping": "node claim-mapping.mjs", - "test:claim-to-role": "node claim-to-role.mjs" + "test:claim-to-role": "node claim-to-role.mjs", + "test:sub-vs-email": "node sub-vs-email.mjs" }, "dependencies": { "playwright-core": "1.55.1" diff --git a/google-e2e/sub-vs-email.mjs b/google-e2e/sub-vs-email.mjs new file mode 100644 index 0000000..b10995c --- /dev/null +++ b/google-e2e/sub-vs-email.mjs @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import { chromium } from "playwright-core"; + +const baseUrl = 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( + `${baseUrl}/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 adminJson(token, path, init = {}) { + const response = await fetch(`${baseUrl}/admin/realms/${path}`, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + ...(init.body ? { "Content-Type": "application/json" } : {}), + }, + }); + assert.ok(response.ok, `${init.method ?? "GET"} ${path}: ${response.status}`); + return response.status === 204 ? undefined : response.json(); +} + +async function users(token, realm, query) { + return adminJson(token, `${realm}/users?${new URLSearchParams(query)}`); +} + +async function brokerLogin(browser) { + const page = await browser.newPage(); + const url = new URL( + `${baseUrl}/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); + await page.close(); +} + +const token = await adminToken(); +const mockUsers = await users(token, "mock-google", { + username: "mock-new-user", + exact: "true", +}); +assert.equal(mockUsers.length, 1); +const mockUser = await adminJson( + token, + `mock-google/users/${mockUsers[0].id}`, +); +const originalEmail = mockUser.email; +const changedEmail = "broker-renamed-user@example.test"; + +for (const existing of await users(token, "keycloak-patterns", { + username: `mock-google.${mockUser.id}`, + exact: "true", +})) { + await adminJson(token, `keycloak-patterns/users/${existing.id}`, { + method: "DELETE", + }); +} + +const browser = await chromium.launch({ + executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome", + headless: true, + args: ["--no-sandbox"], +}); + +try { + await brokerLogin(browser); + const before = await users(token, "keycloak-patterns", { + username: `mock-google.${mockUser.id}`, + exact: "true", + }); + assert.equal(before.length, 1); + const localUserId = before[0].id; + const identities = await adminJson( + token, + `keycloak-patterns/users/${localUserId}/federated-identity`, + ); + assert.equal(identities[0].userId, mockUser.id); + + await adminJson(token, `mock-google/users/${mockUser.id}`, { + method: "PUT", + body: JSON.stringify({ ...mockUser, email: changedEmail }), + }); + await brokerLogin(browser); + + const after = await users(token, "keycloak-patterns", { + username: `mock-google.${mockUser.id}`, + exact: "true", + }); + assert.equal(after.length, 1); + assert.equal(after[0].id, localUserId); + console.log( + "Federated identity verified: provider sub stayed linked while upstream email changed", + ); +} finally { + await adminJson(token, `mock-google/users/${mockUser.id}`, { + method: "PUT", + body: JSON.stringify({ ...mockUser, email: originalEmail }), + }); + await browser.close(); +} diff --git a/scripts/verify-account-linking-sub-vs-email.sh b/scripts/verify-account-linking-sub-vs-email.sh new file mode 100755 index 0000000..efc7142 --- /dev/null +++ b/scripts/verify-account-linking-sub-vs-email.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env sh +set -eu + +set -a +. ./.env +set +a + +./scripts/set-first-broker-login-mode.sh secure +cd google-e2e +npm install --ignore-scripts +npm run test:sub-vs-email