Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eec9feae5c | ||
|
|
32450c35ab | ||
|
|
b1c2d05ae9 | ||
|
|
7916455ce5 | ||
|
|
55a99b8147 | ||
|
|
2765f5d109 |
@@ -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,15 @@
|
|||||||
|
# Authorization Code + PKCE stages
|
||||||
|
|
||||||
|
1. SPA가 매 로그인마다 고엔트로피 `code_verifier`를 생성한다.
|
||||||
|
2. SHA-256과 Base64URL로 `code_challenge`를 만든다.
|
||||||
|
3. authorization request에는 challenge와 `S256`만 전송한다.
|
||||||
|
4. redirect의 code와 저장해 둔 state를 대조한다.
|
||||||
|
5. token request에 원래 verifier를 보내 code를 교환한다.
|
||||||
|
6. verifier/state/code는 한 번 사용한 뒤 메모리에서 제거한다.
|
||||||
|
|
||||||
|
Keycloak client는 public client이며 implicit와 password grant를 끄고 S256을
|
||||||
|
강제한다. PKCE는 악성 redirect endpoint가 code만 가로챘을 때의 교환을 막지만,
|
||||||
|
SPA 실행 컨텍스트를 장악한 XSS 자체를 막지는 않는다.
|
||||||
|
|
||||||
|
`verify-pkce-flow-stages.sh`는 Web Crypto 단위 테스트, realm client 계약,
|
||||||
|
authorization/token 요청의 필드를 함께 검사한다.
|
||||||
@@ -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,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
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
npm --prefix frontend ci
|
||||||
|
npm --prefix frontend test
|
||||||
|
|
||||||
|
jq -e '
|
||||||
|
.clients[]
|
||||||
|
| select(.clientId == "spa-public")
|
||||||
|
| .publicClient == true
|
||||||
|
and .standardFlowEnabled == true
|
||||||
|
and .implicitFlowEnabled == false
|
||||||
|
and .directAccessGrantsEnabled == false
|
||||||
|
and .attributes["pkce.code.challenge.method"] == "S256"
|
||||||
|
' keycloak/import/keycloak-patterns-realm.json >/dev/null
|
||||||
|
|
||||||
|
rg -q 'response_type: "code"' frontend/src/app.js
|
||||||
|
rg -q 'new InMemoryWebStorage' frontend/src/app.js
|
||||||
|
rg -Fq 'cryptoApi.subtle.digest(' frontend/src/pkce.js
|
||||||
|
rg -Fq '"SHA-256"' frontend/src/pkce.js
|
||||||
|
echo "PKCE stages verified: verifier -> S256 challenge -> code -> verifier exchange"
|
||||||
+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"
|
||||||
Reference in New Issue
Block a user