Compare commits

...
Author SHA1 Message Date
donghyeon-ka a289aa2799 docs: define AP1 Google federation profile 2026-07-25 16:39:53 +09:00
donghyeon-ka a63c901407 merge: AP1 local identity profile 2026-07-25 16:39:31 +09:00
donghyeon-ka e49f270a0e docs: define AP1 local identity profile 2026-07-25 16:39:31 +09:00
donghyeon-ka 34bcd89bd0 merge: zero-change SPA federation contract 2026-07-25 16:39:01 +09:00
donghyeon-ka 49863532e1 test: verify federation keeps the AP1 contract 2026-07-25 16:39:01 +09:00
donghyeon-ka c15aeabb87 merge: SPA account-linking UX contract 2026-07-25 16:38:01 +09:00
donghyeon-ka eec9feae5c feat: add signed SPA account-linking helper 2026-07-25 16:38:01 +09:00
donghyeon-ka 32450c35ab merge: refresh token rotation contract 2026-07-25 16:37:07 +09:00
donghyeon-ka b1c2d05ae9 test: isolate refresh token rotation contract 2026-07-25 16:37:07 +09:00
donghyeon-ka 7916455ce5 merge: PKCE flow stage contract 2026-07-25 16:36:43 +09:00
donghyeon-ka 55a99b8147 test: codify SPA PKCE flow stages 2026-07-25 16:36:43 +09:00
donghyeon-ka 2765f5d109 merge: Spring resource server role mapping 2026-07-25 16:35:28 +09:00
donghyeon-ka 3e3de6c4c3 feat: map Keycloak realm roles to Spring RBAC 2026-07-25 16:35:28 +09:00
donghyeon-ka f566ed192c merge: shared broker and deployment contracts 2026-07-25 16:31:53 +09:00
donghyeon-ka 549f5dcf3e merge: shared Google claim mapping 2026-07-25 16:24:30 +09:00
donghyeon-ka 6fc2e77b7d merge: shared Google brokering baseline 2026-07-25 16:18:00 +09:00
donghyeon-ka b601908ab9 merge(ap1): refresh rotation and logout 2026-07-25 14:16:17 +09:00
donghyeon-ka 2ff6d2bfda test(ap1): verify refresh rotation and logout 2026-07-25 14:16:17 +09:00
donghyeon-ka 7f47478fb8 merge(ap1): token storage tradeoff 2026-07-25 14:14:02 +09:00
donghyeon-ka 6f1ccdd978 test(ap1): demonstrate token storage tradeoff 2026-07-25 14:14:02 +09:00
donghyeon-ka 8961671a1c merge(ap1): issuer mismatch diagnostic 2026-07-25 14:12:23 +09:00
donghyeon-ka 5a8bc9b145 test(ap1): reproduce issuer mismatch 2026-07-25 14:12:23 +09:00
donghyeon-ka 994bef0edd merge(ap1): resource audience validation 2026-07-25 14:11:31 +09:00
donghyeon-ka 84a9ca3e8f feat(ap1): enforce resource audience 2026-07-25 14:11:28 +09:00
donghyeon-ka c048d00994 merge(ap1): vanilla SPA PKCE login 2026-07-25 14:09:09 +09:00
donghyeon-ka c1fae6137c feat(ap1): add vanilla SPA PKCE login 2026-07-25 14:09:03 +09:00
45 changed files with 1839 additions and 15 deletions
+2
View File
@@ -4,6 +4,8 @@
*.iml
backend/target/
**/node_modules/
frontend/dist/
build/
e2e/node_modules/
google-e2e/node_modules/
+36
View File
@@ -103,3 +103,39 @@ Keycloak을 잠시 중지하고 export한 뒤 자동으로 다시 올립니다.
runtime export에는 실제 client secret과 credential hash가 포함될 수 있어
gitignored `build/keycloak-export/`에 권한 `0600`으로만 저장됩니다.
## AP1: SPA Direct + Resource Server
`develop-keycloak-pattern1`은 vanilla JavaScript SPA가 `spa-public` client로
Authorization Code + PKCE S256 로그인을 수행하는 패턴입니다. access/refresh
token은 명시적인 in-memory store에만 보관되므로 새로고침하면 사라집니다.
```bash
./scripts/verify-pattern1.sh
```
브라우저에서 `http://localhost:8088`을 열어 로그인한 뒤 보호 API를 호출할 수
있습니다. SPA는 `http://localhost:8081/api/me`를 직접 호출하며 Spring
Resource Server가 Bearer JWT를 검증합니다.
Keycloak의 dedicated audience mapper는 `spa-public` access token에
`keycloak-pattern-api`를 추가합니다. Spring은 signature, `iss`, `exp`
아니라 이 `aud`도 검사합니다. `verify-pattern1.sh`는 같은 정상 토큰을
`deliberately-wrong-audience`를 기대하는 진단 인스턴스에도 제출해 `401`
확인합니다.
Keycloak은 `KC_HOSTNAME=http://localhost:8080`을 기준으로 token의 `iss`
발급합니다. 정상 Resource Server는 이 외부 issuer 문자열을 검증하되 JWKS는
Docker 내부의 `http://keycloak:8080`에서 가져옵니다. 진단 인스턴스는 일부러
`http://wrong-issuer.invalid`를 기대하도록 구성되어, 서명과 audience가
정상이더라도 issuer mismatch로 `401`을 반환합니다.
token 저장 위치와 XSS 범위는
[`docs/ap1-token-storage.md`](docs/ap1-token-storage.md)에 정리했습니다.
E2E는 Web Storage token이 0개임과 동시에 실행 중 fetch hook이 Bearer
header를 관찰할 수 있음을 재현합니다.
refresh rotation, 소비된 refresh token 재사용, RP-Initiated Logout,
revocation과 stateless JWT의 차이는
[`docs/ap1-refresh-logout.md`](docs/ap1-refresh-logout.md)에 정리했으며 같은
E2E에서 실제 Keycloak 26.7.0 동작을 검증합니다.
@@ -27,4 +27,9 @@ public class ApiController {
response.put("audience", jwt.getAudience());
return response;
}
@GetMapping("/admin")
public Map<String, String> adminEndpoint() {
return Map.of("status", "ok", "authorization", "admin-role");
}
}
@@ -0,0 +1,29 @@
package com.example.keycloakpattern;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
final class AudienceValidator implements OAuth2TokenValidator<Jwt> {
private static final OAuth2Error MISSING_AUDIENCE = new OAuth2Error(
"invalid_token",
"The required resource audience is missing",
null
);
private final String expectedAudience;
AudienceValidator(String expectedAudience) {
this.expectedAudience = expectedAudience;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
if (jwt.getAudience().contains(expectedAudience)) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(MISSING_AUDIENCE);
}
}
@@ -0,0 +1,31 @@
package com.example.keycloakpattern;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
@Configuration
public class JwtDecoderConfig {
@Bean
JwtDecoder jwtDecoder(
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer,
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri,
@Value("${security.expected-audience}") String expectedAudience
) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
OAuth2TokenValidator<Jwt> issuerAndTimestamp =
JwtValidators.createDefaultWithIssuer(issuer);
OAuth2TokenValidator<Jwt> audience = new AudienceValidator(expectedAudience);
decoder.setJwtValidator(
new DelegatingOAuth2TokenValidator<>(issuerAndTimestamp, audience)
);
return decoder;
}
}
@@ -0,0 +1,28 @@
package com.example.keycloakpattern;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
final class KeycloakRealmRoleConverter
implements Converter<Jwt, Collection<GrantedAuthority>> {
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null || !(realmAccess.get("roles") instanceof Collection<?> roles)) {
return List.of();
}
return roles.stream()
.filter(String.class::isInstance)
.map(String.class::cast)
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.map(GrantedAuthority.class::cast)
.toList();
}
}
@@ -1,11 +1,17 @@
package com.example.keycloakpattern;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration
public class SecurityConfig {
@@ -13,15 +19,40 @@ public class SecurityConfig {
@Bean
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
return http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public")
.permitAll()
.requestMatchers("/api/admin")
.hasRole("admin-role")
.anyRequest()
.authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
.build();
}
private JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(new KeycloakRealmRoleConverter());
return converter;
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of(
"http://localhost:8088",
"http://127.0.0.1:8088"
));
configuration.setAllowedMethods(List.of("GET", "OPTIONS"));
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
}
@@ -11,6 +11,9 @@ spring:
issuer-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI:http://localhost:8080/realms/keycloak-patterns}
jwk-set-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI:http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/certs}
security:
expected-audience: ${SECURITY_EXPECTED_AUDIENCE:keycloak-pattern-api}
management:
endpoint:
health:
@@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
@SpringBootTest
@AutoConfigureMockMvc
@@ -40,4 +41,19 @@ class ApiSecurityTest {
.andExpect(jsonPath("$.subject").value("test-subject"))
.andExpect(jsonPath("$.username").value("regular-user"));
}
@Test
void regularUserCannotCallAdminEndpoint() throws Exception {
mockMvc.perform(get("/api/admin").with(jwt()
.authorities(new SimpleGrantedAuthority("ROLE_user-role"))))
.andExpect(status().isForbidden());
}
@Test
void adminRoleCanCallAdminEndpoint() throws Exception {
mockMvc.perform(get("/api/admin").with(jwt()
.authorities(new SimpleGrantedAuthority("ROLE_admin-role"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.authorization").value("admin-role"));
}
}
@@ -0,0 +1,49 @@
package com.example.keycloakpattern;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
class AudienceValidatorTest {
private final AudienceValidator validator =
new AudienceValidator("keycloak-pattern-api");
@Test
void acceptsRequiredAudience() {
OAuth2TokenValidatorResult result = validator.validate(jwtWithAudience(
List.of("account", "keycloak-pattern-api")
));
assertThat(result.hasErrors()).isFalse();
}
@Test
void rejectsForeignAudience() {
OAuth2TokenValidatorResult result = validator.validate(jwtWithAudience(
List.of("another-resource")
));
assertThat(result.hasErrors()).isTrue();
assertThat(result.getErrors())
.extracting(error -> error.getErrorCode())
.containsExactly("invalid_token");
}
private Jwt jwtWithAudience(List<String> audience) {
Instant now = Instant.now();
return new Jwt(
"test-token",
now,
now.plusSeconds(300),
Map.of("alg", "none"),
Map.of("sub", "test-subject", "aud", audience)
);
}
}
@@ -0,0 +1,46 @@
package com.example.keycloakpattern;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.jwt.Jwt;
class KeycloakRealmRoleConverterTest {
private final KeycloakRealmRoleConverter converter =
new KeycloakRealmRoleConverter();
@Test
void mapsRealmRolesWithExactlyOneRolePrefix() {
Jwt jwt = new Jwt(
"token",
Instant.now(),
Instant.now().plusSeconds(60),
Map.of("alg", "none"),
Map.of("sub", "subject", "realm_access", Map.of(
"roles", List.of("admin-role", "user-role")
))
);
assertThat(converter.convert(jwt))
.extracting("authority")
.containsExactly("ROLE_admin-role", "ROLE_user-role");
}
@Test
void missingRealmAccessProducesNoAuthorities() {
Jwt jwt = new Jwt(
"token",
Instant.now(),
Instant.now().plusSeconds(60),
Map.of("alg", "none"),
Map.of("sub", "subject")
);
assertThat(converter.convert(jwt)).isEmpty();
}
}
+52
View File
@@ -88,6 +88,58 @@ services:
- keycloak-net
restart: unless-stopped
app-wrong-audience:
profiles:
- diagnostics
build:
context: ./backend
environment:
SERVER_PORT: "8081"
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: http://localhost:8080/realms/keycloak-patterns
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/certs
SECURITY_EXPECTED_AUDIENCE: deliberately-wrong-audience
ports:
- "127.0.0.1:18081:8081"
depends_on:
keycloak:
condition: service_healthy
healthcheck:
test:
- CMD-SHELL
- wget -q -O - http://127.0.0.1:8081/actuator/health | grep -q '"status":"UP"'
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
networks:
- keycloak-net
app-wrong-issuer:
profiles:
- diagnostics
build:
context: ./backend
environment:
SERVER_PORT: "8081"
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: http://wrong-issuer.invalid/realms/keycloak-patterns
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/certs
SECURITY_EXPECTED_AUDIENCE: keycloak-pattern-api
ports:
- "127.0.0.1:18082:8081"
depends_on:
keycloak:
condition: service_healthy
healthcheck:
test:
- CMD-SHELL
- wget -q -O - http://127.0.0.1:8081/actuator/health | grep -q '"status":"UP"'
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
networks:
- keycloak-net
nginx:
build:
context: ./frontend
+18
View File
@@ -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을
넣지 않는다.
+30
View File
@@ -0,0 +1,30 @@
# AP1 refresh rotation and logout
Realm 실행 profile:
- Access Token Lifespan: 300초
- Revoke Refresh Token: 활성화
- Refresh Token Max Reuse: 0
`e2e/pattern1.mjs`는 token 원문을 출력하지 않고 다음 순서를 실행한다.
1. browser Authorization Code + PKCE 로그인으로 AT₁/RT₁/ID Token을 받는다.
2. `signoutRedirect()``id_token_hint`를 포함한 Keycloak logout endpoint를
호출하는지 확인한다.
3. logout 이후 새 authorization 요청에서 로그인 화면이 다시 필요한지
확인한다.
4. 새 RT₁으로 refresh하여 AT₂/RT₂를 받고 RT₂가 RT₁과 다른지 확인한다.
5. 이미 소비된 RT₁을 재사용해 성공하지 않는지 확인한다.
6. RT₁ 재사용 뒤 RT₂와 realm session 상태가 어떤 결과를 내는지 status로
기록한다. 이 결과를 사전에 family invalidation이라고 단정하지 않는다.
7. refresh token을 revoke한 뒤 같은 refresh token의 재사용은 실패하지만,
이미 발급된 self-contained access JWT는 `exp` 전까지 Resource Server에서
계속 `200`인 stateless 함정을 확인한다.
logout은 브라우저 SSO session을 종료하는 흐름이고 token revocation은 특정
token grant를 폐기하는 흐름이다. 둘은 목적과 endpoint가 다르다.
즉시 access 차단이 필요한 시스템이라면 짧은 access token TTL 외에
introspection, reference token 또는 별도 deny-list 같은 stateful 검증을
검토해야 한다. 이 AP1 구현은 JWT의 stateless 검증 특성을 의도적으로
유지한다.
+29
View File
@@ -0,0 +1,29 @@
# AP1 token storage trade-off
AP1에서는 `access_token`, `refresh_token`, `id_token`
`oidc-client-ts`의 명시적인 `InMemoryWebStorage`에만 보관한다.
`localStorage``sessionStorage`에는 OAuth token을 저장하지 않는다.
full-page authorization redirect를 생존해야 하는 일회성 transaction
state와 PKCE verifier만 `sessionStorage`를 사용한다. callback 성공 후
라이브러리가 해당 transaction state를 제거한다.
| 저장 위치 | reload 생존 | JavaScript 접근 | AP1 선택 |
|---|---:|---:|---:|
| 메모리 | 아니요 | 실행 중 가능 | 사용 |
| `sessionStorage` | 같은 탭에서 가능 | 가능 | token 저장 금지 |
| `localStorage` | 예 | 가능 | token 저장 금지 |
| HttpOnly cookie | 가능 | raw token 접근 불가 | AP2/AP3의 서버 소유 경계 |
메모리 저장은 XSS를 제거하지 않는다. 악성 스크립트가 실행 중 `fetch`
후킹하면 SPA가 붙이는 `Authorization: Bearer ...` 헤더를 관찰할 수 있다.
다만 persistent storage를 사용하지 않으므로 reload 이후 탈취 가능한 token
복사본이 남지 않는다.
`e2e/pattern1.mjs`는 다음 두 조건을 동시에 검증한다.
1. access token이 Web Storage 어디에도 존재하지 않는다.
2. 실행 중 fetch hook은 Bearer token을 관찰할 수 있다.
따라서 결론은 “메모리면 XSS에 안전”이 아니라 “영속 탈취 범위를 줄이지만
실행 중 XSS에는 여전히 노출”이다.
+16
View File
@@ -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를 함께 실행한다.
+17
View File
@@ -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`가 담당한다.
+15
View File
@@ -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 요청의 필드를 함께 검사한다.
+16
View File
@@ -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`가 수행한다.
+16
View File
@@ -0,0 +1,16 @@
# Keycloak realm roles to Spring authorization
`realm_access.roles`의 각 문자열을 `ROLE_` prefix가 붙은 Spring authority로
변환한다. `/api/admin``hasRole("admin-role")` 계약이므로 최종 authority는
`ROLE_admin-role`이다. `hasRole("ROLE_admin-role")`로 쓰면 prefix가 중복된다.
검증은 세 층으로 구성된다.
- converter 단위 테스트: role claim과 claim 부재
- MockMvc: regular 403, admin 200
- 실제 Authorization Code + PKCE login: Keycloak token의 realm role을
Spring Resource Server가 변환해 regular 403/admin 200을 반환
```sh
./scripts/verify-spring-role-mapping.sh
```
+46
View File
@@ -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();
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "keycloak-pattern-e2e",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "keycloak-pattern-e2e",
"version": "1.0.0",
"devDependencies": {
"playwright-core": "1.62.0"
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "keycloak-pattern-e2e",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"test:pattern1": "node pattern1.mjs",
"test:role-mapping": "node role-mapping.mjs",
"test:federation-zero-change": "node federation-zero-change.mjs"
},
"devDependencies": {
"playwright-core": "1.62.0"
}
}
+210
View File
@@ -0,0 +1,210 @@
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
const username = process.env.E2E_USERNAME ?? "regular-user";
const password = process.env.E2E_PASSWORD;
const frontendUrl = "http://localhost:8088/";
const tokenEndpoint =
"http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/token";
const revokeEndpoint =
"http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/revoke";
assert.ok(password, "E2E_PASSWORD must be set");
async function login(page) {
await page.locator("#login").click();
await page.waitForURL(/localhost:8080/u);
await page.locator("#username").waitFor();
const tokenResponsePromise = page.waitForResponse((response) =>
response.url() === tokenEndpoint
&& response.request().postData()?.includes("grant_type=authorization_code"),
);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
const tokenResponse = await tokenResponsePromise;
assert.equal(tokenResponse.status(), 200);
const tokenSet = await tokenResponse.json();
assert.ok(tokenSet.access_token);
assert.ok(tokenSet.refresh_token);
assert.ok(tokenSet.id_token);
await page.waitForURL(frontendUrl);
await page.locator('[data-authenticated="true"]').waitFor();
return tokenSet;
}
async function postForm(url, values) {
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(values),
});
}
async function refresh(refreshToken) {
return postForm(tokenEndpoint, {
grant_type: "refresh_token",
client_id: "spa-public",
refresh_token: refreshToken,
});
}
async function callResource(accessToken, url = "http://localhost:8081/api/me") {
return fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
}
const browser = await chromium.launch({
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
headless: true,
args: ["--no-sandbox"],
});
try {
const context = await browser.newContext();
const page = await context.newPage();
let authorizationUrl;
page.on("request", (request) => {
if (request.url().includes("/protocol/openid-connect/auth")) {
authorizationUrl = new URL(request.url());
}
});
await page.goto(frontendUrl);
const firstTokenSet = await login(page);
assert.equal(authorizationUrl?.searchParams.get("response_type"), "code");
assert.equal(authorizationUrl?.searchParams.get("code_challenge_method"), "S256");
assert.ok(authorizationUrl?.searchParams.get("code_challenge"));
await page.evaluate(() => {
const originalFetch = window.fetch.bind(window);
window.__xssProbe = { authorization: null };
window.fetch = (input, init = {}) => {
const headers = new Headers(
init.headers ?? (input instanceof Request ? input.headers : undefined),
);
const authorization = headers.get("Authorization");
if (authorization) {
window.__xssProbe.authorization = authorization;
}
return originalFetch(input, init);
};
});
await page.locator("#call-api").click();
await page.waitForFunction(() => {
const text = document.querySelector("#result")?.textContent ?? "";
return text.includes('"httpStatus": 200');
});
const capturedAuthorization = await page.evaluate(
() => window.__xssProbe.authorization,
);
assert.match(capturedAuthorization, /^Bearer /u);
const accessToken = capturedAuthorization.slice("Bearer ".length);
assert.equal(accessToken, firstTokenSet.access_token);
const payload = JSON.parse(
Buffer.from(accessToken.split(".")[1], "base64url").toString("utf8"),
);
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
assert.ok(audiences.includes("keycloak-pattern-api"));
const storageSnapshot = await page.evaluate(() => ({
localStorage: Object.values(localStorage),
sessionStorage: Object.values(sessionStorage),
}));
assert.equal(JSON.stringify(storageSnapshot).includes(accessToken), false);
assert.ok(capturedAuthorization);
if (process.env.WRONG_AUDIENCE_URL) {
assert.equal(
(await callResource(accessToken, process.env.WRONG_AUDIENCE_URL)).status,
401,
);
}
if (process.env.WRONG_ISSUER_URL) {
assert.equal(
(await callResource(accessToken, process.env.WRONG_ISSUER_URL)).status,
401,
);
}
const logoutRequestPromise = page.waitForRequest((request) =>
request.url().includes("/protocol/openid-connect/logout"),
);
await page.locator("#logout").click();
const logoutRequest = await logoutRequestPromise;
assert.ok(new URL(logoutRequest.url()).searchParams.get("id_token_hint"));
await page.waitForURL(frontendUrl);
await page.locator('[data-authenticated="false"]').waitFor();
const secondTokenSet = await login(page);
const rotatedResponse = await refresh(secondTokenSet.refresh_token);
assert.equal(rotatedResponse.status, 200);
const rotated = await rotatedResponse.json();
assert.ok(rotated.refresh_token);
assert.notEqual(rotated.refresh_token, secondTokenSet.refresh_token);
const reusedOldResponse = await refresh(secondTokenSet.refresh_token);
assert.notEqual(
reusedOldResponse.status,
200,
"a consumed refresh token must not be accepted again",
);
const rotatedAfterReuseResponse = await refresh(rotated.refresh_token);
const rotatedAfterReuseStatus = rotatedAfterReuseResponse.status;
assert.ok([200, 400, 401].includes(rotatedAfterReuseStatus));
assert.equal(
(await callResource(rotated.access_token)).status,
200,
"a locally validated access JWT remains usable until exp",
);
await context.clearCookies();
await page.reload();
await page.locator('[data-authenticated="false"]').waitFor();
const thirdTokenSet = await login(page);
const revokeResponse = await postForm(revokeEndpoint, {
token: thirdTokenSet.refresh_token,
token_type_hint: "refresh_token",
client_id: "spa-public",
});
assert.equal(revokeResponse.status, 200);
assert.notEqual((await refresh(thirdTokenSet.refresh_token)).status, 200);
assert.equal(
(await callResource(thirdTokenSet.access_token)).status,
200,
"refresh revoke is not an immediate deny-list for a stateless access JWT",
);
await page.reload();
await page.locator('[data-authenticated="false"]').waitFor();
assert.equal(
await page.evaluate(
(token) => JSON.stringify({
localStorage: Object.values(localStorage),
sessionStorage: Object.values(sessionStorage),
}).includes(token),
thirdTokenSet.access_token,
),
false,
);
console.log(
"pattern1 verified: PKCE, aud/iss negatives, memory/XSS boundary, logout, RT rotation/reuse, revoke-vs-stateless JWT"
+ ` (RT2 after RT1 reuse: ${rotatedAfterReuseStatus})`,
);
} finally {
await browser.close();
}
+65
View File
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
const keycloakUrl = "http://localhost:8080";
const frontendUrl = "http://localhost:8088/";
async function accessToken(browser, username, password) {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(frontendUrl);
const tokenResponse = page.waitForResponse((response) =>
response.url().includes("/protocol/openid-connect/token")
&& response.request().postData()?.includes("grant_type=authorization_code"),
);
await page.locator("#login").click();
await page.locator("#username").fill(username);
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;
await context.close();
return token;
}
async function adminStatus(token) {
return (
await fetch("http://localhost:8081/api/admin", {
headers: { Authorization: `Bearer ${token}` },
})
).status;
}
const regularPassword = process.env.REGULAR_USER_PASSWORD;
const adminPassword = process.env.ADMIN_USER_PASSWORD;
assert.ok(regularPassword && adminPassword);
const browser = await chromium.launch({
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
headless: true,
args: ["--no-sandbox"],
});
try {
const regularToken = await accessToken(
browser,
"regular-user",
regularPassword,
);
const regularPayload = JSON.parse(
Buffer.from(regularToken.split(".")[1], "base64url").toString(),
);
assert.ok(regularPayload.realm_access.roles.includes("user-role"));
assert.equal(await adminStatus(regularToken), 403);
const adminToken = await accessToken(browser, "admin-user", adminPassword);
const adminPayload = JSON.parse(
Buffer.from(adminToken.split(".")[1], "base64url").toString(),
);
assert.ok(adminPayload.realm_access.roles.includes("admin-role"));
assert.equal(await adminStatus(adminToken), 200);
console.log("Spring RBAC verified: realm role -> ROLE_ authority -> 403/200");
} finally {
await browser.close();
}
+12
View File
@@ -1,4 +1,16 @@
FROM node:24-alpine AS build
WORKDIR /workspace
COPY package.json package-lock.json ./
RUN npm ci
COPY src ./src
COPY test ./test
RUN npm test && npm run build
FROM nginx:1.29-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html /usr/share/nginx/html/index.html
COPY --from=build /workspace/dist/app.js /usr/share/nginx/html/app.js
+46 -11
View File
@@ -3,31 +3,66 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Keycloak Authentication Patterns</title>
<meta name="referrer" content="no-referrer">
<title>AP1 · SPA Direct + Resource Server</title>
<style>
:root {
color-scheme: light dark;
font-family: system-ui, sans-serif;
}
body {
max-width: 48rem;
margin: 8vh auto;
max-width: 56rem;
margin: 6vh auto;
padding: 0 1.5rem;
line-height: 1.6;
line-height: 1.55;
}
button {
margin: 0 0.5rem 0.5rem 0;
padding: 0.6rem 0.9rem;
cursor: pointer;
}
code, pre {
border-radius: 0.35rem;
background: color-mix(in srgb, CanvasText 9%, Canvas);
}
code {
padding: 0.15rem 0.35rem;
border-radius: 0.25rem;
background: color-mix(in srgb, CanvasText 10%, Canvas);
padding: 0.1rem 0.3rem;
}
pre {
min-height: 8rem;
padding: 1rem;
overflow: auto;
white-space: pre-wrap;
}
.notice {
border-left: 0.3rem solid #e09f3e;
padding-left: 1rem;
}
</style>
</head>
<body>
<h1>Keycloak Authentication Patterns</h1>
<p>공통 Docker Compose baseline이 실행 중입니다.</p>
<main>
<h1>AP1 · SPA Direct + Resource Server</h1>
<p>
공개 API는 <code>/api/public</code>, 보호 API는
<code>/api/me</code>에서 확인할 수 있습니다.
바닐라 JavaScript SPA가 <code>spa-public</code> client로 Authorization
Code + PKCE를 수행하고, access token을 직접 Spring Resource Server에
전달합니다.
</p>
<p class="notice">
access/refresh token은 메모리에만 존재합니다. 새로고침하면 사라지는 것이
이 패턴의 의도된 보안 경계입니다.
</p>
<section>
<button id="login" type="button">Keycloak 로그인</button>
<button id="call-api" type="button" disabled>보호 API 호출</button>
<button id="pkce-demo" type="button">수동 PKCE 생성</button>
<button id="logout" type="button" disabled>로그아웃</button>
</section>
<p id="session-state" data-authenticated="false">세션 확인 중…</p>
<pre id="result" aria-live="polite"></pre>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>
+1
View File
@@ -21,6 +21,7 @@ server {
}
location / {
add_header Cache-Control "no-store";
try_files $uri $uri/ /index.html;
}
}
+523
View File
@@ -0,0 +1,523 @@
{
"name": "keycloak-pattern1-spa",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "keycloak-pattern1-spa",
"version": "1.0.0",
"dependencies": {
"oidc-client-ts": "3.5.0"
},
"devDependencies": {
"esbuild": "0.28.1"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/oidc-client-ts": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz",
"integrity": "sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==",
"license": "Apache-2.0",
"dependencies": {
"jwt-decode": "^4.0.0"
},
"engines": {
"node": ">=18"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "keycloak-pattern1-spa",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "esbuild src/app.js --bundle --format=esm --outfile=dist/app.js --minify --sourcemap",
"test": "node --test test/*.test.mjs"
},
"dependencies": {
"oidc-client-ts": "3.5.0"
},
"devDependencies": {
"esbuild": "0.28.1"
}
}
+38
View File
@@ -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;
}
+131
View File
@@ -0,0 +1,131 @@
import {
InMemoryWebStorage,
UserManager,
WebStorageStateStore,
} from "oidc-client-ts";
import { createPkcePair } from "./pkce.js";
const authority = "http://localhost:8080/realms/keycloak-patterns";
const backendBaseUrl = "http://localhost:8081";
const userManager = new UserManager({
authority,
client_id: "spa-public",
redirect_uri: "http://localhost:8088/callback.html",
post_logout_redirect_uri: "http://localhost:8088/",
response_type: "code",
scope: "openid profile email",
automaticSilentRenew: true,
monitorSession: false,
loadUserInfo: false,
userStore: new WebStorageStateStore({
store: new InMemoryWebStorage(),
}),
stateStore: new WebStorageStateStore({
store: window.sessionStorage,
}),
});
const loginButton = document.querySelector("#login");
const logoutButton = document.querySelector("#logout");
const apiButton = document.querySelector("#call-api");
const pkceButton = document.querySelector("#pkce-demo");
const sessionState = document.querySelector("#session-state");
const result = document.querySelector("#result");
let currentUser = null;
function renderJson(value) {
result.textContent = JSON.stringify(value, null, 2);
}
function tokenMetadata(user) {
return {
subject: user.profile.sub,
username: user.profile.preferred_username,
expiresAt: new Date(user.expires_at * 1000).toISOString(),
accessTokenHeldBy: "browser memory",
refreshTokenHeldBy: user.refresh_token ? "browser memory" : "not issued",
};
}
function renderSession(user) {
currentUser = user;
const authenticated = Boolean(user && !user.expired);
sessionState.dataset.authenticated = String(authenticated);
sessionState.textContent = authenticated
? `${user.profile.preferred_username} 로그인됨 · token은 메모리에만 보관`
: "로그인되지 않음 · 브라우저 저장소에 token 없음";
loginButton.disabled = authenticated;
logoutButton.disabled = !authenticated;
apiButton.disabled = !authenticated;
if (authenticated) {
renderJson(tokenMetadata(user));
}
}
async function finishSigninCallback() {
const params = new URLSearchParams(window.location.search);
const isCallback = window.location.pathname === "/callback.html"
&& (params.has("code") || params.has("error"));
if (!isCallback) {
return null;
}
const user = await userManager.signinRedirectCallback();
window.history.replaceState({}, document.title, "/");
return user;
}
async function callProtectedApi() {
if (!currentUser || currentUser.expired) {
throw new Error("로그인이 필요합니다.");
}
const response = await fetch(`${backendBaseUrl}/api/me`, {
headers: {
Authorization: `Bearer ${currentUser.access_token}`,
},
});
const body = await response.json();
renderJson({
httpStatus: response.status,
resourceServerResponse: body,
tokenBoundary: tokenMetadata(currentUser),
});
if (!response.ok) {
throw new Error(`보호 API가 ${response.status}를 반환했습니다.`);
}
}
loginButton.addEventListener("click", () => userManager.signinRedirect());
logoutButton.addEventListener("click", () => userManager.signoutRedirect());
apiButton.addEventListener("click", () => {
callProtectedApi().catch((error) => renderJson({ error: error.message }));
});
pkceButton.addEventListener("click", () => {
createPkcePair()
.then(({ verifier, challenge, method }) => renderJson({
method,
verifierLength: verifier.length,
challengeLength: challenge.length,
note: "실제 로그인에서는 oidc-client-ts가 같은 S256 단계를 수행합니다.",
}))
.catch((error) => renderJson({ error: error.message }));
});
userManager.events.addUserLoaded(renderSession);
userManager.events.addUserUnloaded(() => renderSession(null));
userManager.events.addAccessTokenExpired(() => renderSession(null));
try {
const callbackUser = await finishSigninCallback();
renderSession(callbackUser ?? await userManager.getUser());
} catch (error) {
renderSession(null);
renderJson({ error: error.message });
}
+26
View File
@@ -0,0 +1,26 @@
function base64Url(bytes) {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary)
.replaceAll("+", "-")
.replaceAll("/", "_")
.replace(/=+$/u, "");
}
export async function createPkcePair(cryptoApi = globalThis.crypto) {
const verifierBytes = new Uint8Array(32);
cryptoApi.getRandomValues(verifierBytes);
const verifier = base64Url(verifierBytes);
const digest = await cryptoApi.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier),
);
return {
verifier,
challenge: base64Url(new Uint8Array(digest)),
method: "S256",
};
}
+25
View File
@@ -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);
});
+18
View File
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import { webcrypto } from "node:crypto";
import test from "node:test";
globalThis.btoa = (value) => Buffer.from(value, "binary").toString("base64");
const { createPkcePair } = await import("../src/pkce.js");
test("manual PKCE helper creates an RFC 7636 S256 pair", async () => {
const pair = await createPkcePair(webcrypto);
assert.equal(pair.method, "S256");
assert.equal(pair.verifier.length, 43);
assert.equal(pair.challenge.length, 43);
assert.match(pair.verifier, /^[A-Za-z0-9_-]+$/u);
assert.match(pair.challenge, /^[A-Za-z0-9_-]+$/u);
assert.notEqual(pair.verifier, pair.challenge);
});
@@ -55,7 +55,22 @@
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "http://localhost:8088/*##http://127.0.0.1:8088/*"
},
"protocolMappers": [
{
"name": "keycloak-pattern-api-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.custom.audience": "keycloak-pattern-api",
"id.token.claim": "false",
"access.token.claim": "true",
"userinfo.token.claim": "false",
"introspection.token.claim": "true"
}
}
]
},
{
"clientId": "token-mediating-confidential",
+12
View File
@@ -70,6 +70,18 @@ def validate(path: Path, runtime: bool) -> None:
spa.get("attributes", {}).get("pkce.code.challenge.method") == "S256",
"spa-public must enforce PKCE S256",
)
audience_mappers = [
mapper
for mapper in spa.get("protocolMappers", [])
if mapper.get("protocolMapper") == "oidc-audience-mapper"
]
if not runtime:
require(len(audience_mappers) == 1, "spa-public must declare one audience mapper")
require(
audience_mappers[0].get("config", {}).get("included.custom.audience")
== "keycloak-pattern-api",
"spa-public access token must target keycloak-pattern-api",
)
for client_id, placeholder in CONFIDENTIAL_CLIENTS.items():
client = clients[client_id]
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env sh
set -eu
if [ ! -f .env ]; then
echo "missing .env; copy .env.example and set local-only values" >&2
exit 1
fi
set -a
. ./.env
set +a
docker compose down --volumes --remove-orphans
docker compose up --build -d --wait
docker compose --profile diagnostics up -d --wait \
app-wrong-audience \
app-wrong-issuer
npm --prefix e2e ci
E2E_USERNAME=regular-user \
E2E_PASSWORD="$REGULAR_USER_PASSWORD" \
WRONG_AUDIENCE_URL=http://localhost:18081/api/me \
WRONG_ISSUER_URL=http://localhost:18082/api/me \
npm --prefix e2e run test:pattern1
echo "AP1 verified end to end"
+21
View File
@@ -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
View File
@@ -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"
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env sh
set -eu
set -a
. ./.env
set +a
docker compose down --volumes --remove-orphans
docker compose up --build -d --wait
npm --prefix e2e ci
REGULAR_USER_PASSWORD="$REGULAR_USER_PASSWORD" \
ADMIN_USER_PASSWORD="$ADMIN_USER_PASSWORD" \
npm --prefix e2e run test:role-mapping