The authorized client repository is AuthenticatedPrincipalOAuth2AuthorizedClientRepository, keyed by principal with no session id in it, which is the mechanism behind the sharing problem Q1 and Q3 describe. Sharing a store does not fix a lookup key. Five problems on the way in: only build output was committed under bff/, a duplicate YAML key broke the image build and was invisible until the full log was captured, env placeholders without defaults broke the tests, actuator was behind the login redirect so a 200 was the login page, and the 117KB beans response failed through the proxy. Deploying two replicas made the login itself fail before any experiment started, because the authorization request lives in per-instance memory and the callback lands elsewhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
91 lines
4.2 KiB
Java
91 lines
4.2 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",
|
|
"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());
|
|
}
|
|
}
|