Adding Spring Session Redis grows the context by 81 beans and swaps sessionRepository for RedisSessionRepository, while authorizedClientService stays InMemoryOAuth2AuthorizedClientService. The user then reads as logged in with principal labuser while accessTokenStoredOnServer is false, which is worse than being logged out. Redis holds only the security context, serialized with Java native serialization, and the refresh token is not there to encrypt in the first place. Three problems on the way: Kubernetes service links overwrote REDIS_PORT with a tcp:// URL, the tests tried to reach Redis, and the resource server was never deployed so a DNS failure looked like a token failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
94 lines
4.4 KiB
Java
94 lines
4.4 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",
|
|
"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());
|
|
}
|
|
}
|