Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e49f270a0e | ||
|
|
34bcd89bd0 | ||
|
|
49863532e1 | ||
|
|
c15aeabb87 | ||
|
|
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,16 @@
|
|||||||
|
# Google federation without an AP1 application fork
|
||||||
|
|
||||||
|
SPA는 Google SDK나 Google token endpoint를 알지 않는다. 기존 `spa-public`
|
||||||
|
client로 Keycloak authorization endpoint를 호출하고, Keycloak 로그인
|
||||||
|
화면에서 mock Google을 선택해도 callback, PKCE 교환, access token audience,
|
||||||
|
Spring API 호출은 로컬 사용자 로그인과 동일하다.
|
||||||
|
|
||||||
|
달라지는 곳은 Keycloak 앞단뿐이다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
SPA -> Keycloak -> Google/mock OIDC
|
||||||
|
SPA <- Keycloak access token <- Keycloak
|
||||||
|
```
|
||||||
|
|
||||||
|
`verify-federation-spa-zero-change.sh`는 기존 SPA 로그인 버튼에서 broker를
|
||||||
|
선택하고, 변경 없는 callback과 `/api/me`가 200인지 실제 브라우저로 검증한다.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# AP1 internal SPA direct: local identity profile
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser SPA --Authorization Code + PKCE--> Keycloak
|
||||||
|
Browser SPA --Bearer access token-------> Spring Resource Server
|
||||||
|
```
|
||||||
|
|
||||||
|
이 profile은 Keycloak 로컬 사용자만으로 동작한다. Google client ID/secret,
|
||||||
|
public domain, broker callback이 없어도 AP1의 login, refresh, logout,
|
||||||
|
audience/issuer 검증과 RBAC를 모두 학습할 수 있다.
|
||||||
|
|
||||||
|
`mock-google` provider가 realm에 함께 존재해도 로컬 로그인은 provider
|
||||||
|
availability에 의존하지 않는다. 실제로 federation 없는 배포를 만들 때는
|
||||||
|
해당 IdP를 disabled로 두거나 realm overlay에서 제거한다.
|
||||||
|
|
||||||
|
빠른 계약 검증은 `verify-internal-spa-no-google-contract.sh`, 실제 브라우저
|
||||||
|
흐름은 `verify-pattern1.sh`가 담당한다.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
|
||||||
|
const password = process.env.MOCK_GOOGLE_USER_PASSWORD;
|
||||||
|
assert.ok(password);
|
||||||
|
|
||||||
|
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 tokenResponse = page.waitForResponse((response) =>
|
||||||
|
response.url().includes("/protocol/openid-connect/token")
|
||||||
|
&& response.request().postData()?.includes("grant_type=authorization_code"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("http://localhost:8088/");
|
||||||
|
await page.locator("#login").click();
|
||||||
|
await page.locator('a[href*="/broker/mock-google/login"]').click();
|
||||||
|
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();
|
||||||
|
|
||||||
|
const response = await tokenResponse;
|
||||||
|
assert.equal(response.status(), 200);
|
||||||
|
const token = (await response.json()).access_token;
|
||||||
|
const payload = JSON.parse(
|
||||||
|
Buffer.from(token.split(".")[1], "base64url").toString(),
|
||||||
|
);
|
||||||
|
assert.match(payload.preferred_username, /^mock-google\./u);
|
||||||
|
assert.ok(payload.aud.includes("keycloak-pattern-api"));
|
||||||
|
|
||||||
|
await page.waitForURL("http://localhost:8088/");
|
||||||
|
await page.locator('[data-authenticated="true"]').waitFor();
|
||||||
|
await page.locator("#call-api").click();
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
document.querySelector("#result")?.textContent.includes('"httpStatus": 200'),
|
||||||
|
);
|
||||||
|
console.log("Google federation verified with the unchanged AP1 SPA/API contract");
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
+2
-1
@@ -5,7 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test:pattern1": "node pattern1.mjs",
|
"test:pattern1": "node pattern1.mjs",
|
||||||
"test:role-mapping": "node role-mapping.mjs"
|
"test:role-mapping": "node role-mapping.mjs",
|
||||||
|
"test:federation-zero-change": "node federation-zero-change.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"playwright-core": "1.62.0"
|
"playwright-core": "1.62.0"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
set -a
|
||||||
|
. ./.env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
./scripts/set-first-broker-login-mode.sh secure
|
||||||
|
npm --prefix e2e ci
|
||||||
|
MOCK_GOOGLE_USER_PASSWORD="$MOCK_GOOGLE_USER_PASSWORD" \
|
||||||
|
npm --prefix e2e run test:federation-zero-change
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
jq -e '
|
||||||
|
(.clients[] | select(.clientId == "spa-public")
|
||||||
|
| .publicClient == true
|
||||||
|
and .attributes["pkce.code.challenge.method"] == "S256")
|
||||||
|
and
|
||||||
|
([.users[].username] | index("regular-user") != null)
|
||||||
|
' keycloak/import/keycloak-patterns-realm.json >/dev/null
|
||||||
|
|
||||||
|
npm --prefix frontend test
|
||||||
|
echo "AP1 local-identity profile verified without a Google dependency"
|
||||||
Reference in New Issue
Block a user