From c611acf66288eb1940782ca27bec1c9f75fa0ea4 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 14:38:02 +0900 Subject: [PATCH] feat(ap3): add oauth2Login session BFF --- .gitignore | 3 + README.md | 14 ++ .../keycloakpattern/AudienceValidator.java | 29 ++++ .../keycloakpattern/JwtDecoderConfig.java | 31 ++++ backend/src/main/resources/application.yml | 3 + .../AudienceValidatorTest.java | 49 ++++++ bff/.dockerignore | 1 + bff/Dockerfile | 14 ++ bff/pom.xml | 58 ++++++++ .../keycloakpattern/bff/BffApplication.java | 12 ++ .../keycloakpattern/bff/BffController.java | 112 ++++++++++++++ .../keycloakpattern/bff/SecurityConfig.java | 73 +++++++++ bff/src/main/resources/application.yml | 46 ++++++ bff/src/main/resources/static/app.js | 39 +++++ bff/src/main/resources/static/index.html | 31 ++++ .../bff/BffControllerTest.java | 69 +++++++++ docker-compose.yml | 26 ++++ docs/ap3-bff-boundary.md | 28 ++++ e2e/package-lock.json | 28 ++++ e2e/package.json | 12 ++ e2e/pattern3.mjs | 140 ++++++++++++++++++ keycloak/import/keycloak-patterns-realm.json | 18 ++- scripts/verify-pattern3.sh | 21 +++ 23 files changed, 856 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/com/example/keycloakpattern/AudienceValidator.java create mode 100644 backend/src/main/java/com/example/keycloakpattern/JwtDecoderConfig.java create mode 100644 backend/src/test/java/com/example/keycloakpattern/AudienceValidatorTest.java create mode 100644 bff/.dockerignore create mode 100644 bff/Dockerfile create mode 100644 bff/pom.xml create mode 100644 bff/src/main/java/com/example/keycloakpattern/bff/BffApplication.java create mode 100644 bff/src/main/java/com/example/keycloakpattern/bff/BffController.java create mode 100644 bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java create mode 100644 bff/src/main/resources/application.yml create mode 100644 bff/src/main/resources/static/app.js create mode 100644 bff/src/main/resources/static/index.html create mode 100644 bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java create mode 100644 docs/ap3-bff-boundary.md create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/pattern3.mjs create mode 100755 scripts/verify-pattern3.sh diff --git a/.gitignore b/.gitignore index 8f0f1cc..9fa77c2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,7 @@ *.iml backend/target/ +bff/target/ +**/node_modules/ +frontend/dist/ build/ diff --git a/README.md b/README.md index ac1a49e..610c763 100644 --- a/README.md +++ b/README.md @@ -96,3 +96,17 @@ Keycloak을 잠시 중지하고 export한 뒤 자동으로 다시 올립니다. runtime export에는 실제 client secret과 credential hash가 포함될 수 있어 gitignored `build/keycloak-export/`에 권한 `0600`으로만 저장됩니다. + +## AP3: Backend-for-Frontend + +`develop-keycloak-pattern3`의 Spring BFF는 `http://localhost:8083`에서 +실행됩니다. 브라우저에는 HttpOnly session cookie만 두고 access/refresh +token은 BFF가 서버에 보관합니다. `/bff/api/me`는 BFF가 보유 access +token을 붙여 Resource Server로 proxy합니다. + +```bash +./scripts/verify-pattern3.sh +``` + +자세한 경계와 session 저장소 trade-off는 +[`docs/ap3-bff-boundary.md`](docs/ap3-bff-boundary.md)를 참고하세요. diff --git a/backend/src/main/java/com/example/keycloakpattern/AudienceValidator.java b/backend/src/main/java/com/example/keycloakpattern/AudienceValidator.java new file mode 100644 index 0000000..85679e7 --- /dev/null +++ b/backend/src/main/java/com/example/keycloakpattern/AudienceValidator.java @@ -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 { + + 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); + } +} diff --git a/backend/src/main/java/com/example/keycloakpattern/JwtDecoderConfig.java b/backend/src/main/java/com/example/keycloakpattern/JwtDecoderConfig.java new file mode 100644 index 0000000..a97f71b --- /dev/null +++ b/backend/src/main/java/com/example/keycloakpattern/JwtDecoderConfig.java @@ -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 issuerAndTimestamp = + JwtValidators.createDefaultWithIssuer(issuer); + OAuth2TokenValidator audience = new AudienceValidator(expectedAudience); + decoder.setJwtValidator( + new DelegatingOAuth2TokenValidator<>(issuerAndTimestamp, audience) + ); + return decoder; + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 0046b63..ba329fe 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -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: diff --git a/backend/src/test/java/com/example/keycloakpattern/AudienceValidatorTest.java b/backend/src/test/java/com/example/keycloakpattern/AudienceValidatorTest.java new file mode 100644 index 0000000..6151d5f --- /dev/null +++ b/backend/src/test/java/com/example/keycloakpattern/AudienceValidatorTest.java @@ -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 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) + ); + } +} diff --git a/bff/.dockerignore b/bff/.dockerignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/bff/.dockerignore @@ -0,0 +1 @@ +target/ diff --git a/bff/Dockerfile b/bff/Dockerfile new file mode 100644 index 0000000..b3bf5a6 --- /dev/null +++ b/bff/Dockerfile @@ -0,0 +1,14 @@ +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"] diff --git a/bff/pom.xml b/bff/pom.xml new file mode 100644 index 0000000..093b952 --- /dev/null +++ b/bff/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.16 + + + + com.example + keycloak-bff + 0.0.1-SNAPSHOT + keycloak-bff + + + 21 + + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + keycloak-bff + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/bff/src/main/java/com/example/keycloakpattern/bff/BffApplication.java b/bff/src/main/java/com/example/keycloakpattern/bff/BffApplication.java new file mode 100644 index 0000000..c9b6ea8 --- /dev/null +++ b/bff/src/main/java/com/example/keycloakpattern/bff/BffApplication.java @@ -0,0 +1,12 @@ +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); + } +} diff --git a/bff/src/main/java/com/example/keycloakpattern/bff/BffController.java b/bff/src/main/java/com/example/keycloakpattern/bff/BffController.java new file mode 100644 index 0000000..cda0462 --- /dev/null +++ b/bff/src/main/java/com/example/keycloakpattern/bff/BffController.java @@ -0,0 +1,112 @@ +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 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> tokenBoundary(Authentication authentication) { + OAuth2AuthorizedClient client = authorizedClientService.loadAuthorizedClient( + "keycloak", + authentication.getName() + ); + + Map 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", false); + + 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 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 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; + } +} diff --git a/bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java b/bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java new file mode 100644 index 0000000..9eebf1c --- /dev/null +++ b/bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java @@ -0,0 +1,73 @@ +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; + +@Configuration +public class SecurityConfig { + + @Bean + SecurityFilterChain bffSecurity( + HttpSecurity http, + ClientRegistrationRepository clientRegistrationRepository + ) throws Exception { + DefaultOAuth2AuthorizationRequestResolver authorizationRequestResolver = + new DefaultOAuth2AuthorizationRequestResolver( + clientRegistrationRepository, + "/oauth2/authorization" + ); + authorizationRequestResolver.setAuthorizationRequestCustomizer( + OAuth2AuthorizationRequestCustomizers.withPkce() + ); + + return http + .csrf(csrf -> csrf.disable()) + .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; + } +} diff --git a/bff/src/main/resources/application.yml b/bff/src/main/resources/application.yml new file mode 100644 index 0000000..683e671 --- /dev/null +++ b/bff/src/main/resources/application.yml @@ -0,0 +1,46 @@ +server: + port: ${SERVER_PORT:8083} + servlet: + session: + cookie: + name: AP3_SESSION + http-only: true + +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 diff --git a/bff/src/main/resources/static/app.js b/bff/src/main/resources/static/app.js new file mode 100644 index 0000000..9e00508 --- /dev/null +++ b/bff/src/main/resources/static/app.js @@ -0,0 +1,39 @@ +const result = document.querySelector("#result"); + +function render(value) { + result.textContent = JSON.stringify(value, null, 2); +} + +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-without-csrf").addEventListener("click", () => { + void request("/bff/api/preferences", { + method: "POST", + body: new URLSearchParams({ theme: "dark" }), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }); +}); diff --git a/bff/src/main/resources/static/index.html b/bff/src/main/resources/static/index.html new file mode 100644 index 0000000..36ada2b --- /dev/null +++ b/bff/src/main/resources/static/index.html @@ -0,0 +1,31 @@ + + + + + + AP3 · Backend-for-Frontend + + + +
+

AP3 · Backend-for-Frontend

+

+ 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session + cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource + Server 요청에 붙입니다. +

+ + + + +

+  
+ + + diff --git a/bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java b/bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java new file mode 100644 index 0000000..6ad9e86 --- /dev/null +++ b/bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java @@ -0,0 +1,69 @@ +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.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(false)) + .andExpect(jsonPath("$.access_token").doesNotExist()) + .andExpect(jsonPath("$.refresh_token").doesNotExist()); + } + + @Test + void demonstratesStateChangeWithoutCsrfProtection() throws Exception { + mockMvc.perform(post("/bff/api/preferences") + .param("theme", "attacker") + .with(oidcLogin().idToken(token -> token.subject("test-subject")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.updated").value(true)) + .andExpect(jsonPath("$.theme").value("attacker")); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 585bebf..5f7bbd4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,6 +86,32 @@ services: - keycloak-net restart: unless-stopped + bff: + build: + context: ./bff + environment: + SERVER_PORT: "8083" + KEYCLOAK_CLIENT_SECRET: ${BFF_CLIENT_SECRET:?set BFF_CLIENT_SECRET in .env} + RESOURCE_API_BASE_URL: http://app:8081 + ports: + - "127.0.0.1:8083:8083" + depends_on: + keycloak: + condition: service_healthy + app: + condition: service_healthy + healthcheck: + test: + - CMD-SHELL + - wget -q -O - http://127.0.0.1:8083/actuator/health | grep -q '"status":"UP"' + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + networks: + - keycloak-net + restart: unless-stopped + nginx: build: context: ./frontend diff --git a/docs/ap3-bff-boundary.md b/docs/ap3-bff-boundary.md new file mode 100644 index 0000000..6ddba24 --- /dev/null +++ b/docs/ap3-bff-boundary.md @@ -0,0 +1,28 @@ +# 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 재현 + +이 feature 브랜치에서는 다음 CSRF 방어 feature와 비교하기 위해 CSRF를 +의도적으로 끕니다. 다른 origin의 자동 제출 form이 브라우저 cookie를 +자동으로 포함해 `/bff/api/preferences` 상태를 바꾸는 것을 E2E에서 +재현합니다. + +이 취약 상태는 `feature/keycloak-bff-csrf-samesite-defense`에서 Spring +CSRF token과 명시적 SameSite=Lax를 적용해 차단합니다. diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..78d42d6 --- /dev/null +++ b/e2e/package-lock.json @@ -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" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..dd93ac4 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,12 @@ +{ + "name": "keycloak-pattern-e2e", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "test:pattern3": "node pattern3.mjs" + }, + "devDependencies": { + "playwright-core": "1.62.0" + } +} diff --git a/e2e/pattern3.mjs b/e2e/pattern3.mjs new file mode 100644 index 0000000..fca578a --- /dev/null +++ b/e2e/pattern3.mjs @@ -0,0 +1,140 @@ +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(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"); + + 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, false); + 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); + + 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); + + await page.goto("http://localhost:8088"); + const forgedResponsePromise = page.waitForResponse( + (response) => + response.url() === "http://localhost:8083/bff/api/preferences" && + response.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 = "attacker"; + form.append(input); + document.body.append(form); + form.submit(); + }); + const forgedResponse = await forgedResponsePromise; + assert.equal(forgedResponse.status(), 200); + const forgedResult = await forgedResponse.json(); + assert.equal(forgedResult.updated, true); + assert.equal(forgedResult.theme, "attacker"); + + console.log( + "pattern3 BFF verified: browser token 0, session-only proxy 200, pre-defense CSRF reproduced", + ); +} finally { + await browser.close(); +} diff --git a/keycloak/import/keycloak-patterns-realm.json b/keycloak/import/keycloak-patterns-realm.json index 9a4fae9..a5402b2 100644 --- a/keycloak/import/keycloak-patterns-realm.json +++ b/keycloak/import/keycloak-patterns-realm.json @@ -98,8 +98,24 @@ "http://localhost:8083" ], "attributes": { + "pkce.code.challenge.method": "S256", "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", diff --git a/scripts/verify-pattern3.sh b/scripts/verify-pattern3.sh new file mode 100755 index 0000000..2218ace --- /dev/null +++ b/scripts/verify-pattern3.sh @@ -0,0 +1,21 @@ +#!/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"