diff --git a/docs/account-linking-spa-ux.md b/docs/account-linking-spa-ux.md new file mode 100644 index 0000000..1ed3dc5 --- /dev/null +++ b/docs/account-linking-spa-ux.md @@ -0,0 +1,18 @@ +# Account linking UX for the SPA + +두 흐름을 구분한다. + +- 로그인 도중 email collision: Keycloak의 안전한 First Broker Login flow가 + 기존 계정 인증을 요구한다. +- 로그인한 사용자가 설정 화면에서 “Google 연결”: Client-Initiated Account + Linking URL을 만들어 Keycloak로 redirect한다. + +`createAccountLinkUrl`은 현재 token의 `session_state`, `azp`(issued-for), +provider와 nonce를 SHA-256 서명 재료로 사용한다. SPA는 연결 성공 후 +Account Console 또는 별도 backend read model을 통해 연결 상태를 새로 +조회해야 하며 email만 보고 “연결됨”을 표시하면 안 된다. + +Unlink는 사용자가 다른 로그인 수단을 갖고 있는지 먼저 안내하고, Keycloak이 +마지막 federated identity 제거를 거부하면 해당 오류를 그대로 성공처럼 +처리하지 않는다. production First Broker Login에는 자동 기존-user linking을 +넣지 않는다. diff --git a/frontend/src/account-linking.js b/frontend/src/account-linking.js new file mode 100644 index 0000000..b0772a1 --- /dev/null +++ b/frontend/src/account-linking.js @@ -0,0 +1,38 @@ +function base64Url(bytes) { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replaceAll("=", ""); +} + +export async function createAccountLinkUrl({ + keycloakBaseUrl, + realm, + provider, + clientId, + redirectUri, + sessionState, + issuedFor, + nonce = crypto.randomUUID(), + cryptoApi = crypto, +}) { + const material = `${nonce}${sessionState}${issuedFor}${provider}`; + const digest = await cryptoApi.subtle.digest( + "SHA-256", + new TextEncoder().encode(material), + ); + const url = new URL( + `${keycloakBaseUrl}/realms/${realm}/broker/${provider}/link`, + ); + url.search = new URLSearchParams({ + nonce, + hash: base64Url(new Uint8Array(digest)), + client_id: clientId, + redirect_uri: redirectUri, + }); + return url; +} diff --git a/frontend/test/account-linking.test.mjs b/frontend/test/account-linking.test.mjs new file mode 100644 index 0000000..b4630cb --- /dev/null +++ b/frontend/test/account-linking.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const { createAccountLinkUrl } = await import("../src/account-linking.js"); + +test("creates a signed client-initiated account-link URL", async () => { + const url = await createAccountLinkUrl({ + keycloakBaseUrl: "https://auth.example.test", + realm: "keycloak-patterns", + provider: "google", + clientId: "spa-public", + redirectUri: "https://app.example.test/settings/identity", + sessionState: "session-state", + issuedFor: "spa-public", + nonce: "fixed-nonce", + }); + + assert.equal( + url.pathname, + "/realms/keycloak-patterns/broker/google/link", + ); + assert.equal(url.searchParams.get("client_id"), "spa-public"); + assert.equal(url.searchParams.get("nonce"), "fixed-nonce"); + assert.match(url.searchParams.get("hash"), /^[A-Za-z0-9_-]{43}$/u); +});