Moving the authorized client to JdbcOAuth2AuthorizedClientService makes tokens work across replicas, so the session-in-Redis plus tokens-in-PostgreSQL split holds. The table then shows what sharing cannot fix: the primary key is (client_registration_id, principal_name) with no session in it, so a second login for the same user updates the same row rather than adding one. The refresh token sits in bytea as the raw JWT, readable with convert_from, and logout clears only the Redis session while the plaintext token row and the Keycloak SSO session both survive. The schema itself failed silently first because the default DDL uses blob, which PostgreSQL does not have, and continue-on-error swallowed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
100 lines
4.8 KiB
Java
100 lines
4.8 KiB
Java
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",
|
|
// 테스트는 Redis 를 띄우지 않는다. store-type=none 이면 자동구성이
|
|
// 서블릿 컨테이너 기본 세션으로 되돌아가 컨텍스트가 뜬다.
|
|
"spring.session.store-type=none",
|
|
// 테스트에는 PostgreSQL 이 없다. H2 로 대신하고 Spring Security 의
|
|
// DDL 을 그대로 태워 JdbcOAuth2AuthorizedClientService 가 뜨게 한다.
|
|
"spring.datasource.url=jdbc:h2:mem:bfftest;DB_CLOSE_DELAY=-1",
|
|
"spring.datasource.username=sa",
|
|
"spring.datasource.password=",
|
|
"spring.sql.init.mode=always",
|
|
"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());
|
|
}
|
|
}
|