Compare commits

..
42 changed files with 1388 additions and 900 deletions
-1
View File
@@ -4,7 +4,6 @@
*.iml *.iml
backend/target/ backend/target/
bff/target/
**/node_modules/ **/node_modules/
frontend/dist/ frontend/dist/
build/ build/
+30 -10
View File
@@ -104,18 +104,38 @@ Keycloak을 잠시 중지하고 export한 뒤 자동으로 다시 올립니다.
runtime export에는 실제 client secret과 credential hash가 포함될 수 있어 runtime export에는 실제 client secret과 credential hash가 포함될 수 있어
gitignored `build/keycloak-export/`에 권한 `0600`으로만 저장됩니다. gitignored `build/keycloak-export/`에 권한 `0600`으로만 저장됩니다.
## AP3: Backend-for-Frontend ## AP1: SPA Direct + Resource Server
`develop-keycloak-pattern3`의 Spring BFF는 `http://localhost:8083`에서 `develop-keycloak-pattern1`은 vanilla JavaScript SPA가 `spa-public` client로
실행됩니다. 브라우저에는 HttpOnly session cookie만 두고 access/refresh Authorization Code + PKCE S256 로그인을 수행하는 패턴입니다. access/refresh
token은 BFF가 서버에 보관합니다. `/bff/api/me`는 BFF가 보유 access token은 명시적인 in-memory store에만 보관되므로 새로고침하면 사라집니다.
token을 붙여 Resource Server로 proxy합니다.
```bash ```bash
./scripts/verify-pattern3.sh ./scripts/verify-pattern1.sh
``` ```
자세한 경계와 session 저장소 trade-off는 브라우저에서 `http://localhost:8088`을 열어 로그인한 뒤 보호 API를 호출할 수
[`docs/ap3-bff-boundary.md`](docs/ap3-bff-boundary.md)를 참고하세요. 있습니다. SPA는 `http://localhost:8081/api/me`를 직접 호출하며 Spring
최종 AP3 branch는 `AP3_SESSION; HttpOnly; SameSite=Lax`와 Spring CSRF Resource Server가 Bearer JWT를 검증합니다.
token을 함께 사용하며, token 없는 상태 변경 요청은 403으로 거부합니다.
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()); response.put("audience", jwt.getAudience());
return response; return response;
} }
@GetMapping("/admin")
public Map<String, String> adminEndpoint() {
return Map.of("status", "ok", "authorization", "admin-role");
}
} }
@@ -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; package com.example.keycloakpattern;
import java.util.List;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer; import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy; 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.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration @Configuration
public class SecurityConfig { public class SecurityConfig {
@@ -13,15 +19,40 @@ public class SecurityConfig {
@Bean @Bean
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception { SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
return http return http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable()) .csrf(csrf -> csrf.disable())
.sessionManagement(session -> .sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize .authorizeHttpRequests(authorize -> authorize
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public") .requestMatchers("/actuator/health", "/actuator/health/**", "/api/public")
.permitAll() .permitAll()
.requestMatchers("/api/admin")
.hasRole("admin-role")
.anyRequest() .anyRequest()
.authenticated()) .authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
.build(); .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;
}
} }
@@ -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.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@@ -40,4 +41,19 @@ class ApiSecurityTest {
.andExpect(jsonPath("$.subject").value("test-subject")) .andExpect(jsonPath("$.subject").value("test-subject"))
.andExpect(jsonPath("$.username").value("regular-user")); .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,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();
}
}
-1
View File
@@ -1 +0,0 @@
target/
-14
View File
@@ -1,14 +0,0 @@
FROM maven:3.9.11-eclipse-temurin-21-alpine AS build
WORKDIR /workspace
COPY pom.xml .
RUN mvn --batch-mode dependency:go-offline
COPY src src
RUN mvn --batch-mode verify
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
WORKDIR /app
COPY --from=build /workspace/target/keycloak-bff.jar app.jar
USER spring:spring
EXPOSE 8083
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
-58
View File
@@ -1,58 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>keycloak-bff</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>keycloak-bff</name>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>keycloak-bff</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -1,12 +0,0 @@
package com.example.keycloakpattern.bff;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BffApplication {
public static void main(String[] args) {
SpringApplication.run(BffApplication.class, args);
}
}
@@ -1,112 +0,0 @@
package com.example.keycloakpattern.bff;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
@RestController
public class BffController {
private final OAuth2AuthorizedClientService authorizedClientService;
private final OAuth2AuthorizedClientManager authorizedClientManager;
private final RestClient resourceApi;
private final AtomicReference<String> theme = new AtomicReference<>("system");
public BffController(
OAuth2AuthorizedClientService authorizedClientService,
OAuth2AuthorizedClientManager authorizedClientManager,
RestClient.Builder restClientBuilder,
@Value("${resource-api.base-url}") String resourceApiBaseUrl
) {
this.authorizedClientService = authorizedClientService;
this.authorizedClientManager = authorizedClientManager;
this.resourceApi = restClientBuilder.baseUrl(resourceApiBaseUrl).build();
}
@GetMapping("/bff/token-boundary")
ResponseEntity<Map<String, Object>> tokenBoundary(Authentication authentication) {
OAuth2AuthorizedClient client = authorizedClientService.loadAuthorizedClient(
"keycloak",
authentication.getName()
);
Map<String, Object> response = new LinkedHashMap<>();
response.put("pattern", "AP3-backend-for-frontend");
response.put("principal", authentication.getName());
response.put("accessTokenStoredOnServer", client != null
&& client.getAccessToken() != null);
response.put("refreshTokenStoredOnServer", client != null
&& client.getRefreshToken() != null);
response.put("browserTokenCount", 0);
response.put("csrfProtectionEnabled", true);
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.header("Pragma", "no-cache")
.body(response);
}
@GetMapping("/bff/api/me")
ResponseEntity<?> currentUser(Authentication authentication) {
OAuth2AuthorizedClient client = authorizedClient(authentication);
return resourceApi.get()
.uri("/api/me")
.header(
HttpHeaders.AUTHORIZATION,
"Bearer " + client.getAccessToken().getTokenValue()
)
.retrieve()
.toEntity(Map.class);
}
@PostMapping("/bff/api/preferences")
Map<String, Object> updatePreference(
Authentication authentication,
@RequestParam(defaultValue = "system") String theme
) {
this.theme.set(theme);
return Map.of(
"updated", true,
"theme", this.theme.get(),
"principal", authentication.getName()
);
}
@GetMapping("/bff/api/preferences")
Map<String, String> preference() {
return Map.of("theme", theme.get());
}
private OAuth2AuthorizedClient authorizedClient(Authentication authentication) {
OAuth2AuthorizeRequest request = OAuth2AuthorizeRequest
.withClientRegistrationId("keycloak")
.principal(authentication)
.build();
OAuth2AuthorizedClient client = authorizedClientManager.authorize(request);
if (client == null || client.getAccessToken() == null) {
throw new ResponseStatusException(
UNAUTHORIZED,
"No authorized Keycloak client is available"
);
}
return client;
}
}
@@ -1,25 +0,0 @@
package com.example.keycloakpattern.bff;
import java.util.Map;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CsrfController {
@GetMapping("/bff/csrf")
ResponseEntity<Map<String, String>> csrf(CsrfToken csrfToken) {
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.header("Pragma", "no-cache")
.body(Map.of(
"headerName", csrfToken.getHeaderName(),
"parameterName", csrfToken.getParameterName(),
"token", csrfToken.getToken()
));
}
}
@@ -1,80 +0,0 @@
package com.example.keycloakpattern.bff;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain bffSecurity(
HttpSecurity http,
ClientRegistrationRepository clientRegistrationRepository
) throws Exception {
DefaultOAuth2AuthorizationRequestResolver authorizationRequestResolver =
new DefaultOAuth2AuthorizationRequestResolver(
clientRegistrationRepository,
"/oauth2/authorization"
);
authorizationRequestResolver.setAuthorizationRequestCustomizer(
OAuth2AuthorizationRequestCustomizers.withPkce()
);
CookieCsrfTokenRepository csrfTokenRepository =
CookieCsrfTokenRepository.withHttpOnlyFalse();
csrfTokenRepository.setCookiePath("/");
return http
.csrf(csrf -> csrf
.csrfTokenRepository(csrfTokenRepository)
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(
"/",
"/index.html",
"/app.js",
"/favicon.ico",
"/actuator/health",
"/actuator/health/**"
)
.permitAll()
.anyRequest()
.authenticated())
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(endpoint -> endpoint
.authorizationRequestResolver(authorizationRequestResolver))
.defaultSuccessUrl("/", true))
.build();
}
@Bean
OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientService authorizedClientService
) {
OAuth2AuthorizedClientProvider authorizedClientProvider =
OAuth2AuthorizedClientProviderBuilder.builder()
.authorizationCode()
.refreshToken()
.build();
AuthorizedClientServiceOAuth2AuthorizedClientManager manager =
new AuthorizedClientServiceOAuth2AuthorizedClientManager(
clientRegistrationRepository,
authorizedClientService
);
manager.setAuthorizedClientProvider(authorizedClientProvider);
return manager;
}
}
@@ -1,40 +0,0 @@
package com.example.keycloakpattern.bff;
import java.util.function.Supplier;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;
import org.springframework.util.StringUtils;
final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {
private final CsrfTokenRequestHandler plain =
new CsrfTokenRequestAttributeHandler();
private final CsrfTokenRequestHandler xor =
new XorCsrfTokenRequestAttributeHandler();
@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
Supplier<CsrfToken> deferredCsrfToken
) {
xor.handle(request, response, deferredCsrfToken);
}
@Override
public String resolveCsrfTokenValue(
HttpServletRequest request,
CsrfToken csrfToken
) {
if (StringUtils.hasText(request.getHeader(csrfToken.getHeaderName()))) {
return plain.resolveCsrfTokenValue(request, csrfToken);
}
return xor.resolveCsrfTokenValue(request, csrfToken);
}
}
-47
View File
@@ -1,47 +0,0 @@
server:
port: ${SERVER_PORT:8083}
servlet:
session:
cookie:
name: AP3_SESSION
http-only: true
same-site: lax
spring:
application:
name: keycloak-bff
security:
oauth2:
client:
registration:
keycloak:
provider: keycloak
client-id: bff-confidential
client-secret: ${KEYCLOAK_CLIENT_SECRET}
client-authentication-method: client_secret_basic
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope:
- openid
- profile
- email
provider:
keycloak:
authorization-uri: http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/auth
token-uri: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/token
jwk-set-uri: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/certs
user-info-uri: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/userinfo
user-name-attribute: preferred_username
resource-api:
base-url: ${RESOURCE_API_BASE_URL:http://localhost:8081}
management:
endpoint:
health:
probes:
enabled: true
endpoints:
web:
exposure:
include: health,info
-59
View File
@@ -1,59 +0,0 @@
const result = document.querySelector("#result");
function render(value) {
result.textContent = JSON.stringify(value, null, 2);
}
function readCookie(name) {
const prefix = `${encodeURIComponent(name)}=`;
const value = document.cookie
.split("; ")
.find((cookie) => cookie.startsWith(prefix));
return value ? decodeURIComponent(value.slice(prefix.length)) : null;
}
async function request(path, options = {}) {
const response = await fetch(path, {
...options,
headers: { Accept: "application/json", ...options.headers },
});
if (response.redirected || response.status === 401) {
window.location.assign("/oauth2/authorization/keycloak");
return null;
}
const body = await response.json();
render({ status: response.status, ...body });
return { response, body };
}
document.querySelector("#login").addEventListener("click", () => {
window.location.assign("/oauth2/authorization/keycloak");
});
document.querySelector("#inspect").addEventListener("click", () => {
void request("/bff/token-boundary");
});
document.querySelector("#call-bff").addEventListener("click", () => {
void request("/bff/api/me");
});
document.querySelector("#change-with-csrf").addEventListener("click", async () => {
const csrfResponse = await fetch("/bff/csrf", {
headers: { Accept: "application/json" },
});
const csrf = await csrfResponse.json();
const csrfToken = readCookie("XSRF-TOKEN");
if (!csrfToken) {
render({ status: 500, error: "XSRF-TOKEN cookie was not created" });
return;
}
await request("/bff/api/preferences", {
method: "POST",
body: new URLSearchParams({ theme: "dark" }),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
[csrf.headerName]: csrfToken,
},
});
});
-31
View File
@@ -1,31 +0,0 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AP3 · Backend-for-Frontend</title>
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { max-width: 58rem; margin: 6vh auto; padding: 0 1.5rem; line-height: 1.6; }
button { margin: 0 0.5rem 0.5rem 0; padding: 0.6rem 0.9rem; cursor: pointer; }
pre { min-height: 9rem; padding: 1rem; border-radius: 0.4rem;
background: color-mix(in srgb, CanvasText 9%, Canvas); white-space: pre-wrap; }
</style>
</head>
<body>
<main>
<h1>AP3 · Backend-for-Frontend</h1>
<p>
브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session
cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource
Server 요청에 붙입니다.
</p>
<button id="login" type="button">Keycloak 로그인</button>
<button id="inspect" type="button">token 경계 확인</button>
<button id="call-bff" type="button">BFF 경유 API 호출</button>
<button id="change-with-csrf" type="button">CSRF token으로 상태 변경</button>
<pre id="result" aria-live="polite"></pre>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>
@@ -1,90 +0,0 @@
package com.example.keycloakpattern.bff;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
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.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(properties = {
"KEYCLOAK_CLIENT_SECRET=test-only-secret",
"resource-api.base-url=http://127.0.0.1:9"
})
@AutoConfigureMockMvc
class BffControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private OAuth2AuthorizedClientService authorizedClientService;
@MockitoBean
private OAuth2AuthorizedClientManager authorizedClientManager;
@Test
void reportsServerTokenCustodyWithoutReturningTokens() throws Exception {
OAuth2AuthorizedClient client = mock(OAuth2AuthorizedClient.class);
when(client.getAccessToken()).thenReturn(mock(OAuth2AccessToken.class));
when(client.getRefreshToken()).thenReturn(mock(OAuth2RefreshToken.class));
when(authorizedClientService.loadAuthorizedClient("keycloak", "test-subject"))
.thenReturn(client);
mockMvc.perform(get("/bff/token-boundary").with(oidcLogin()
.idToken(token -> token.subject("test-subject"))))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(jsonPath("$.accessTokenStoredOnServer").value(true))
.andExpect(jsonPath("$.refreshTokenStoredOnServer").value(true))
.andExpect(jsonPath("$.browserTokenCount").value(0))
.andExpect(jsonPath("$.csrfProtectionEnabled").value(true))
.andExpect(jsonPath("$.access_token").doesNotExist())
.andExpect(jsonPath("$.refresh_token").doesNotExist());
}
@Test
void rejectsStateChangeWithoutCsrfToken() throws Exception {
mockMvc.perform(post("/bff/api/preferences")
.param("theme", "attacker")
.with(oidcLogin().idToken(token -> token.subject("test-subject"))))
.andExpect(status().isForbidden());
}
@Test
void acceptsStateChangeWithCsrfToken() throws Exception {
mockMvc.perform(post("/bff/api/preferences")
.param("theme", "dark")
.with(oidcLogin().idToken(token -> token.subject("test-subject")))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updated").value(true))
.andExpect(jsonPath("$.theme").value("dark"));
}
@Test
void exposesSpaCsrfTokenWithoutCaching() throws Exception {
mockMvc.perform(get("/bff/csrf").with(oidcLogin()
.idToken(token -> token.subject("test-subject"))))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(header().exists("Set-Cookie"))
.andExpect(jsonPath("$.headerName").value("X-XSRF-TOKEN"))
.andExpect(jsonPath("$.token").isNotEmpty());
}
}
+36 -10
View File
@@ -88,31 +88,57 @@ services:
- keycloak-net - keycloak-net
restart: unless-stopped restart: unless-stopped
bff: app-wrong-audience:
profiles:
- diagnostics
build: build:
context: ./bff context: ./backend
environment: environment:
SERVER_PORT: "8083" SERVER_PORT: "8081"
KEYCLOAK_CLIENT_SECRET: ${BFF_CLIENT_SECRET:?set BFF_CLIENT_SECRET in .env} SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: http://localhost:8080/realms/keycloak-patterns
RESOURCE_API_BASE_URL: http://app:8081 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: ports:
- "127.0.0.1:8083:8083" - "127.0.0.1:18081:8081"
depends_on: depends_on:
keycloak: keycloak:
condition: service_healthy condition: service_healthy
app:
condition: service_healthy
healthcheck: healthcheck:
test: test:
- CMD-SHELL - CMD-SHELL
- wget -q -O - http://127.0.0.1:8083/actuator/health | grep -q '"status":"UP"' - 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 interval: 10s
timeout: 5s timeout: 5s
retries: 12 retries: 12
start_period: 20s start_period: 20s
networks: networks:
- keycloak-net - keycloak-net
restart: unless-stopped
nginx: nginx:
build: build:
+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에는 여전히 노출”이다.
-36
View File
@@ -1,36 +0,0 @@
# AP3 · Backend-for-Frontend
## 요청과 token 경계
1. 브라우저는 BFF의 `/oauth2/authorization/keycloak`로 로그인을 시작합니다.
2. Spring `oauth2Login`은 PKCE S256 authorization code flow를 수행합니다.
3. BFF가 client secret으로 code를 교환하고 access/refresh token을 서버의
`OAuth2AuthorizedClientService`에 보관합니다.
4. 브라우저에는 OAuth token 대신 HttpOnly `AP3_SESSION` 식별자만 남습니다.
5. 브라우저가 `/bff/api/me`를 cookie로 호출하면 BFF가 access token을
`Authorization: Bearer`로 붙여 Resource Server에 fan-out합니다.
Resource Server는 `aud=keycloak-pattern-api`를 검증합니다. 브라우저에서는
8081로 직접 요청하거나 Keycloak token endpoint를 호출하지 않습니다.
학습용 구성은 단일 인스턴스 메모리에 session과 authorized client를
보관합니다. BFF를 재시작하면 세션이 사라집니다. 다중 인스턴스 운영에서는
Spring Session/Redis 같은 공유 저장소와 저장 token 암호화 정책이 필요합니다.
## CSRF와 SameSite 방어
`feature/keycloak-bff-oauth2login-session`에서는 방어 전 비교를 위해
CSRF를 끄고, 다른 origin의 자동 제출 form이 `/bff/api/preferences`
상태를 바꾸는 것을 재현합니다.
`feature/keycloak-bff-csrf-samesite-defense`에서는 다음 방어를 함께
적용합니다.
- Spring synchronizer CSRF token과 `CookieCsrfTokenRepository`
- JS가 읽는 `XSRF-TOKEN`과 요청의 `X-XSRF-TOKEN` header
- HttpOnly `AP3_SESSION` cookie의 명시적 `SameSite=Lax`
E2E는 token 없는 동일 위조 POST가 403이 되는 것, CSRF header가 있는
정상 POST는 200인 것, cross-site POST에는 AP3 session cookie가 제외되는
것을 각각 확인합니다. SameSite는 CSRF token을 대체하지 않는
defense-in-depth입니다.
-22
View File
@@ -1,22 +0,0 @@
# BFF vs SPA direct
| 항목 | SPA direct (AP1) | BFF (AP3) |
|---|---|---|
| OAuth client | public + PKCE | confidential + server secret |
| token holder | browser JavaScript memory | BFF server |
| browser credential | access/refresh token | HttpOnly session cookie |
| XSS 영향 | 실행 중 token/API 권한 탈취 가능 | cookie 읽기는 막지만 same-origin 요청 악용 가능 |
| CSRF | bearer header 중심이라 상대적으로 작음 | session cookie이므로 명시적 방어 필요 |
| backend 상태 | stateless 가능 | session store 필요 |
| logout/revocation | browser와 Keycloak 조정 | BFF session과 Keycloak logout 조정 |
| scale-out | JWT 검증 위주 | sticky session 또는 shared store |
이 repository의 AP3는 Spring `oauth2Login`, confidential `bff-confidential`
client, server-side authorized-client/token 보관, HttpOnly/SameSite session,
CSRF token을 실제 구현한다. AP1은 token을 메모리에만 두지만 XSS가 실행되면
현재 token을 사용할 수 있다는 경계를 E2E로 보여 준다.
선택은 “BFF가 항상 더 안전하다”가 아니라 threat model과 운영비의 교환이다.
브라우저에 OAuth token을 노출하지 않는 것이 우선이고 stateful 운영을 감당할
수 있으면 BFF, protocol과 stateless Resource Server를 직접 학습하려면 AP1이
더 투명하다.
+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
```
+2 -1
View File
@@ -4,7 +4,8 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test:pattern3": "node pattern3.mjs" "test:pattern1": "node pattern1.mjs",
"test:role-mapping": "node role-mapping.mjs"
}, },
"devDependencies": { "devDependencies": {
"playwright-core": "1.62.0" "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();
}
-198
View File
@@ -1,198 +0,0 @@
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
const password = process.env.E2E_PASSWORD;
assert.ok(password, "E2E_PASSWORD must be set");
async function completeKeycloakLogin(page) {
for (let attempt = 1; attempt <= 2; attempt += 1) {
await page.locator("#username").fill(
process.env.E2E_USERNAME ?? "regular-user",
);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForLoadState("domcontentloaded");
if (page.url() === "http://localhost:8083/") {
return;
}
if (attempt === 1) {
await page.goto(
"http://localhost:8083/oauth2/authorization/keycloak",
);
await page.waitForURL(/localhost:8080/u);
}
}
throw new Error(`Keycloak login did not return to AP3: ${page.url()}`);
}
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();
const browserRequests = [];
page.on("request", (request) =>
browserRequests.push({ method: request.method(), url: request.url() }),
);
await page.goto("http://localhost:8083");
const authorizationRequestPromise = page.waitForRequest((request) =>
request.url().includes(
"/protocol/openid-connect/auth?response_type=code",
),
);
await page.locator("#login").click();
const authorizationRequest = await authorizationRequestPromise;
const authorizationUrl = new URL(authorizationRequest.url());
assert.equal(authorizationUrl.searchParams.get("client_id"), "bff-confidential");
assert.equal(authorizationUrl.searchParams.get("code_challenge_method"), "S256");
assert.ok(authorizationUrl.searchParams.get("code_challenge"));
await page.waitForURL(/localhost:8080/u);
await completeKeycloakLogin(page);
const callbackRequest = browserRequests.find(({ url }) =>
url.startsWith("http://localhost:8083/login/oauth2/code/keycloak?"),
);
assert.ok(callbackRequest, "authorization response must use the BFF callback");
assert.equal(callbackRequest.method, "GET");
const boundaryResponsePromise = page.waitForResponse((response) =>
response.url().endsWith("/bff/token-boundary"),
);
await page.locator("#inspect").click();
const boundaryResponse = await boundaryResponsePromise;
assert.equal(boundaryResponse.status(), 200);
const boundary = await boundaryResponse.json();
assert.equal(boundary.accessTokenStoredOnServer, true);
assert.equal(boundary.refreshTokenStoredOnServer, true);
assert.equal(boundary.browserTokenCount, 0);
assert.equal(boundary.csrfProtectionEnabled, true);
assert.equal(JSON.stringify(boundary).includes("access_token"), false);
assert.equal(JSON.stringify(boundary).includes("refresh_token"), false);
const proxyResponsePromise = page.waitForResponse((response) =>
response.url().endsWith("/bff/api/me"),
);
await page.locator("#call-bff").click();
const proxyResponse = await proxyResponsePromise;
assert.equal(proxyResponse.status(), 200);
const resource = await proxyResponse.json();
assert.equal(resource.username, "regular-user");
assert.ok(resource.audience.includes("keycloak-pattern-api"));
assert.equal(
browserRequests.some(({ url }) =>
url.startsWith("http://localhost:8081/"),
),
false,
"the browser must not bypass the BFF",
);
assert.equal(
browserRequests.some(({ url }) =>
url.includes("/protocol/openid-connect/token"),
),
false,
"the token exchange must be server-to-server",
);
const cookies = await context.cookies("http://localhost:8083/");
const sessionCookie = cookies.find((cookie) => cookie.name === "AP3_SESSION");
assert.ok(sessionCookie);
assert.equal(sessionCookie.httpOnly, true);
assert.equal(sessionCookie.sameSite, "Lax");
const storage = await page.evaluate(() => ({
localStorage: Object.values(localStorage),
sessionStorage: Object.values(sessionStorage),
readableCookies: document.cookie,
}));
assert.deepEqual(storage.localStorage, []);
assert.deepEqual(storage.sessionStorage, []);
assert.equal(storage.readableCookies.includes("AP3_SESSION"), false);
const missingCsrfResponse = await page.evaluate(async () => {
const response = await fetch("/bff/api/preferences", {
method: "POST",
body: new URLSearchParams({ theme: "missing-csrf" }),
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
return response.status;
});
assert.equal(missingCsrfResponse, 403);
const csrfResponsePromise = page.waitForResponse((response) =>
response.url().endsWith("/bff/csrf"),
);
const validChangeResponsePromise = page.waitForResponse(
(response) =>
response.url().endsWith("/bff/api/preferences") &&
response.request().method() === "POST" &&
response.status() === 200,
);
await page.locator("#change-with-csrf").click();
const csrfResponse = await csrfResponsePromise;
const validChangeResponse = await validChangeResponsePromise;
assert.equal(csrfResponse.status(), 200);
assert.equal(validChangeResponse.status(), 200);
const validChange = await validChangeResponse.json();
assert.equal(validChange.theme, "dark");
const csrfCookies = await context.cookies("http://localhost:8083/");
const csrfCookie = csrfCookies.find((cookie) => cookie.name === "XSRF-TOKEN");
assert.ok(csrfCookie);
assert.equal(csrfCookie.httpOnly, false);
await page.goto("http://localhost:8088");
const [forgedResponse] = await Promise.all([
page.waitForNavigation(),
page.evaluate(() => {
const form = document.createElement("form");
form.method = "POST";
form.action = "http://localhost:8083/bff/api/preferences";
const input = document.createElement("input");
input.name = "theme";
input.value = "attacker";
form.append(input);
document.body.append(form);
form.submit();
}),
]);
assert.ok(forgedResponse);
assert.equal(forgedResponse.status(), 403);
await page.goto("http://127.0.0.1:8088");
const crossSiteRequestPromise = page.waitForRequest(
(request) =>
request.url() === "http://localhost:8083/bff/api/preferences" &&
request.method() === "POST",
);
await page.evaluate(() => {
const form = document.createElement("form");
form.method = "POST";
form.action = "http://localhost:8083/bff/api/preferences";
const input = document.createElement("input");
input.name = "theme";
input.value = "cross-site-attacker";
form.append(input);
document.body.append(form);
form.submit();
});
const crossSiteRequest = await crossSiteRequestPromise;
const crossSiteHeaders = await crossSiteRequest.allHeaders();
assert.equal(
(crossSiteHeaders.cookie ?? "").includes("AP3_SESSION="),
false,
"SameSite=Lax must omit the session cookie on a cross-site POST",
);
console.log(
"pattern3 BFF verified: tokenless POST 403, CSRF header 200, SameSite=Lax session",
);
} 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 FROM nginx:1.29-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html /usr/share/nginx/html/index.html 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> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <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> <style>
:root { :root {
color-scheme: light dark; color-scheme: light dark;
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
} }
body { body {
max-width: 48rem; max-width: 56rem;
margin: 8vh auto; margin: 6vh auto;
padding: 0 1.5rem; 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 { code {
padding: 0.15rem 0.35rem; padding: 0.1rem 0.3rem;
border-radius: 0.25rem; }
background: color-mix(in srgb, CanvasText 10%, Canvas); pre {
min-height: 8rem;
padding: 1rem;
overflow: auto;
white-space: pre-wrap;
}
.notice {
border-left: 0.3rem solid #e09f3e;
padding-left: 1rem;
} }
</style> </style>
</head> </head>
<body> <body>
<h1>Keycloak Authentication Patterns</h1> <main>
<p>공통 Docker Compose baseline이 실행 중입니다.</p> <h1>AP1 · SPA Direct + Resource Server</h1>
<p> <p>
공개 API는 <code>/api/public</code>, 보호 API는 바닐라 JavaScript SPA가 <code>spa-public</code> client로 Authorization
<code>/api/me</code>에서 확인할 수 있습니다. Code + PKCE를 수행하고, access token을 직접 Spring Resource Server에
전달합니다.
</p> </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> </body>
</html> </html>
+1
View File
@@ -21,6 +21,7 @@ server {
} }
location / { location / {
add_header Cache-Control "no-store";
try_files $uri $uri/ /index.html; 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"
}
}
+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",
};
}
+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);
});
+15 -16
View File
@@ -55,7 +55,22 @@
"attributes": { "attributes": {
"pkce.code.challenge.method": "S256", "pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "http://localhost:8088/*##http://127.0.0.1:8088/*" "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", "clientId": "token-mediating-confidential",
@@ -102,24 +117,8 @@
"http://localhost:8083" "http://localhost:8083"
], ],
"attributes": { "attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "http://localhost:8083/*" "post.logout.redirect.uris": "http://localhost:8083/*"
},
"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": "edge-proxy", "clientId": "edge-proxy",
+12
View File
@@ -70,6 +70,18 @@ def validate(path: Path, runtime: bool) -> None:
spa.get("attributes", {}).get("pkce.code.challenge.method") == "S256", spa.get("attributes", {}).get("pkce.code.challenge.method") == "S256",
"spa-public must enforce PKCE 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(): for client_id, placeholder in CONFIDENTIAL_CLIENTS.items():
client = clients[client_id] client = clients[client_id]
+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
@@ -1,21 +0,0 @@
#!/usr/bin/env sh
set -eu
if [ ! -f .env ]; then
echo "missing .env; copy .env.example and set development values" >&2
exit 1
fi
set -a
. ./.env
set +a
docker compose down --volumes --remove-orphans
docker compose up --build -d --wait
npm --prefix e2e ci
E2E_USERNAME=regular-user \
E2E_PASSWORD="$REGULAR_USER_PASSWORD" \
npm --prefix e2e run test:pattern3
echo "AP3 BFF session and CSRF behavior verified"
+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