Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eec9feae5c | ||
|
|
32450c35ab |
@@ -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을
|
||||
넣지 않는다.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user