Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64175266df | ||
|
|
bb8fd9333d | ||
|
|
42957abfed | ||
|
|
5026b79fc0 | ||
|
|
b42db5e93b | ||
|
|
aee01c9e90 | ||
|
|
4a55ee71a9 | ||
|
|
1341645cf5 | ||
|
|
a289aa2799 | ||
|
|
a63c901407 | ||
|
|
e49f270a0e | ||
|
|
34bcd89bd0 | ||
|
|
49863532e1 | ||
|
|
c15aeabb87 | ||
|
|
eec9feae5c | ||
|
|
32450c35ab | ||
|
|
b1c2d05ae9 | ||
|
|
7916455ce5 |
@@ -11,3 +11,6 @@ e2e/node_modules/
|
||||
google-e2e/node_modules/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
token-mediator/target
|
||||
bff/target
|
||||
|
||||
@@ -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,18 @@
|
||||
# AP1 internal SPA direct: Google-federated profile
|
||||
|
||||
```text
|
||||
SPA -> Keycloak -> Google
|
||||
SPA <- Keycloak code/token <- Keycloak
|
||||
SPA -> Spring API with Keycloak access token
|
||||
```
|
||||
|
||||
Google은 upstream authentication만 담당한다. SPA와 Resource Server의 trust
|
||||
anchor는 계속 Keycloak issuer/JWKS/audience다. 따라서 Spring이 Google
|
||||
ID token을 직접 받거나 Google JWKS를 검증하지 않는다.
|
||||
|
||||
추가 운영 항목은 Google client secret, exact broker redirect URI, safe First
|
||||
Broker Login, `sub` account key, claim mapper다. 로컬에서는 두 번째 Keycloak
|
||||
realm이 Google 역할을 하므로 외부 credential 없이 같은 hop을 재현한다.
|
||||
|
||||
`verify-internal-spa-google-contract.sh`는 broker 설정과 기존 AP1 SPA의
|
||||
zero-change federation E2E를 함께 실행한다.
|
||||
@@ -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,16 @@
|
||||
# Refresh token rotation contract
|
||||
|
||||
Realm 설정은 refresh token revoke를 활성화하고 재사용 허용 횟수를 0으로 둔다.
|
||||
SPA는 새 token set을 받은 즉시 이전 refresh token을 폐기한다.
|
||||
|
||||
실제 AP1 E2E는 다음을 구분한다.
|
||||
|
||||
- RT1로 refresh하면 RT2가 새로 발급된다.
|
||||
- 이미 소비한 RT1 재사용은 성공하면 안 된다.
|
||||
- 재사용 탐지 후 RT2까지 무효화할지는 Keycloak realm 정책과 동시 요청
|
||||
상황에 따라 관찰한다.
|
||||
- refresh token revoke 이후 refresh는 실패한다.
|
||||
- 이미 발급된 stateless access JWT는 `exp`까지 유효할 수 있다.
|
||||
|
||||
`verify-refresh-token-rotation-contract.sh`는 realm과 E2E assertions의 계약을
|
||||
빠르게 확인한다. 실제 token 수명주기 재현은 `verify-pattern1.sh`가 수행한다.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Single EC2 with Google federation
|
||||
|
||||
local-identity single-EC2 topology에 다음 네 요소가 추가된다.
|
||||
|
||||
1. 고정 public HTTPS hostname
|
||||
2. trusted TLS/reverse proxy와 Keycloak public hostname
|
||||
3. Google OAuth client의 exact broker callback
|
||||
4. Keycloak Google IdP secret과 safe First Broker Login policy
|
||||
|
||||
학습 profile은 Cloudflare named tunnel을 사용할 수 있어 EC2 inbound에
|
||||
Keycloak 8080을 열지 않는다. containerized cloudflared의 origin은
|
||||
`127.0.0.1`이 아니라 Compose service DNS의 reverse proxy다.
|
||||
|
||||
실제 Google secret은 `.env`/secret store에만 두며 realm export나 Git에
|
||||
포함하지 않는다. credential이 없는 CI는 mock OIDC realm으로 broker 계약을
|
||||
검증하고, 실제 Google callback은 사용자 소유 credential/domain 환경에서
|
||||
별도 실행한다.
|
||||
|
||||
`verify-single-ec2-google-profile.sh`는 tunnel, proxy headers, redirect URI,
|
||||
broker realm 계약을 묶어 검증한다.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Single EC2, AP1, no Google
|
||||
|
||||
한 VM의 Docker Compose에 PostgreSQL, Keycloak, Spring API, nginx SPA를 둔다.
|
||||
외부 진입점만 nginx/TLS proxy로 제한하고 Keycloak 8080, API 8081, PostgreSQL은
|
||||
loopback 또는 Docker private network에 둔다.
|
||||
|
||||
```text
|
||||
Internet :443 -> TLS proxy/SPA
|
||||
|-> Spring API:8081
|
||||
Browser ----------->|-> Keycloak:8080 (public auth path만 proxy)
|
||||
Keycloak ----------> PostgreSQL:5432
|
||||
```
|
||||
|
||||
이 profile은 Google credential과 tunnel이 필요 없다. EC2 보안 그룹은 22
|
||||
(관리 정책에 따른 제한), 80/443만 열고 container 내부 포트를 공개하지 않는다.
|
||||
backup 대상은 PostgreSQL 데이터와 realm/config/secret이며 Keycloak 개발
|
||||
모드 `start-dev`는 production에서 사용하지 않는다.
|
||||
|
||||
`verify-single-ec2-no-google.sh`는 Compose rendering, loopback binding,
|
||||
AP1 local-identity 계약을 확인한다.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Three-leg trust chain
|
||||
|
||||
| Hop | 발급자/행위자 | 검증자 | 핵심 검증 |
|
||||
|---|---|---|---|
|
||||
| 1 | Google/mock IdP | Keycloak broker | signature, upstream issuer, client audience, expiry, nonce |
|
||||
| 2 | Keycloak | SPA | state, PKCE code binding, Keycloak issuer, token response |
|
||||
| 3 | Keycloak access token | Spring Resource Server | Keycloak signature/JWKS, issuer, expiry, API audience, roles |
|
||||
|
||||
Keycloak은 upstream ID token을 그대로 SPA에 전달하지 않는다. upstream
|
||||
identity를 검증·연결한 뒤 자신의 realm key로 새 token을 발급한다. 그 결과
|
||||
backend의 trust store와 issuer 설정은 Google 로그인 여부와 무관하게 하나다.
|
||||
|
||||
`verify-three-leg-trust-chain.sh`는 두 realm의 discovery issuer가 서로 다르고,
|
||||
Spring 코드가 main Keycloak issuer만 신뢰하는지 확인한다. 실제 hop 전체는
|
||||
`verify-federation-spa-zero-change.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",
|
||||
"scripts": {
|
||||
"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": {
|
||||
"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
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
./scripts/verify-google-broker-config.sh
|
||||
./scripts/verify-federation-spa-zero-change.sh
|
||||
echo "AP1 Google-federated profile verified"
|
||||
+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"
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
jq -e '
|
||||
.revokeRefreshToken == true
|
||||
and .refreshTokenMaxReuse == 0
|
||||
and .accessTokenLifespan <= 300
|
||||
' keycloak/import/keycloak-patterns-realm.json >/dev/null
|
||||
|
||||
rg -Fq 'assert.notEqual(rotated.refresh_token, secondTokenSet.refresh_token)' \
|
||||
e2e/pattern1.mjs
|
||||
rg -Fq 'a consumed refresh token must not be accepted again' e2e/pattern1.mjs
|
||||
rg -Fq 'refresh revoke is not an immediate deny-list' e2e/pattern1.mjs
|
||||
|
||||
echo "Refresh rotation/reuse/revocation contract verified"
|
||||
echo "Run ./scripts/verify-pattern1.sh for the destructive live-token E2E"
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
./scripts/verify-public-tunnel-config.sh
|
||||
./scripts/verify-reverse-proxy-headers.sh
|
||||
./scripts/verify-google-redirect-uri-policy.sh
|
||||
./scripts/verify-google-broker-config.sh
|
||||
echo "Single-EC2 Google federation deployment profile verified"
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
rendered="$(docker compose config --format json)"
|
||||
printf '%s\n' "$rendered" | jq -e '
|
||||
(.services.keycloak.ports[] | .host_ip == "127.0.0.1" and .target == 8080)
|
||||
and
|
||||
(.services.app.ports[] | .host_ip == "127.0.0.1" and .target == 8081)
|
||||
and
|
||||
(.networks["keycloak-net"].driver == "bridge")
|
||||
' >/dev/null
|
||||
|
||||
./scripts/verify-internal-spa-no-google-contract.sh
|
||||
echo "Single-EC2 local-identity topology verified"
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
base="${KEYCLOAK_URL:-http://localhost:8080}"
|
||||
main_issuer="$base/realms/keycloak-patterns"
|
||||
mock_issuer="$base/realms/mock-google"
|
||||
|
||||
test "$(curl -fsS "$main_issuer/.well-known/openid-configuration" | jq -r .issuer)" \
|
||||
= "$main_issuer"
|
||||
test "$(curl -fsS "$mock_issuer/.well-known/openid-configuration" | jq -r .issuer)" \
|
||||
= "$mock_issuer"
|
||||
|
||||
rg -Fq 'SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI:' docker-compose.yml
|
||||
if rg -qi 'accounts\\.google\\.com|realms/mock-google' backend/src/main; then
|
||||
echo "backend must trust only the Keycloak issuer, not the upstream IdP" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Three-leg trust anchors verified: upstream -> Keycloak -> Resource Server"
|
||||
Reference in New Issue
Block a user