feat(ap2): hand off access token only
This commit is contained in:
+56
@@ -0,0 +1,56 @@
|
||||
package com.example.keycloakpattern.mediator;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.CacheControl;
|
||||
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.core.OAuth2AccessToken;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
|
||||
|
||||
@RestController
|
||||
public class AccessTokenController {
|
||||
|
||||
private final OAuth2AuthorizedClientManager authorizedClientManager;
|
||||
|
||||
public AccessTokenController(OAuth2AuthorizedClientManager authorizedClientManager) {
|
||||
this.authorizedClientManager = authorizedClientManager;
|
||||
}
|
||||
|
||||
@GetMapping("/token/access")
|
||||
ResponseEntity<Map<String, Object>> accessToken(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"
|
||||
);
|
||||
}
|
||||
|
||||
OAuth2AccessToken token = client.getAccessToken();
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("access_token", token.getTokenValue());
|
||||
response.put("token_type", token.getTokenType().getValue());
|
||||
response.put(
|
||||
"expires_at",
|
||||
token.getExpiresAt() == null ? null : token.getExpiresAt().toString()
|
||||
);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.header("Pragma", "no-cache")
|
||||
.body(response);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,12 @@ package com.example.keycloakpattern.mediator;
|
||||
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.web.SecurityFilterChain;
|
||||
|
||||
@Configuration
|
||||
@@ -26,4 +32,24 @@ public class SecurityConfig {
|
||||
.oauth2Login(oauth2 -> oauth2.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,3 +18,33 @@ document.querySelector("#inspect").addEventListener("click", async () => {
|
||||
}
|
||||
render(await response.json());
|
||||
});
|
||||
|
||||
document.querySelector("#call-api").addEventListener("click", async () => {
|
||||
const tokenResponse = await fetch("/token/access", {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (tokenResponse.redirected || tokenResponse.status === 401) {
|
||||
window.location.assign("/oauth2/authorization/keycloak");
|
||||
return;
|
||||
}
|
||||
if (!tokenResponse.ok) {
|
||||
render({ tokenEndpointStatus: tokenResponse.status });
|
||||
return;
|
||||
}
|
||||
|
||||
const { access_token: accessToken, expires_at: expiresAt } =
|
||||
await tokenResponse.json();
|
||||
const apiResponse = await fetch("http://localhost:8081/api/me", {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
render({
|
||||
accessTokenHeldInMemoryOnly: true,
|
||||
refreshTokenReceived: false,
|
||||
accessTokenExpiresAt: expiresAt,
|
||||
resourceApiStatus: apiResponse.status,
|
||||
resource: await apiResponse.json(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
</p>
|
||||
<button id="login" type="button">Keycloak 로그인</button>
|
||||
<button id="inspect" type="button">서버 token 경계 확인</button>
|
||||
<button id="call-api" type="button">access token으로 API 직접 호출</button>
|
||||
<pre id="result" aria-live="polite"></pre>
|
||||
</main>
|
||||
<script type="module" src="/app.js"></script>
|
||||
|
||||
+34
@@ -1,5 +1,6 @@
|
||||
package com.example.keycloakpattern.mediator;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
|
||||
@@ -8,11 +9,15 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
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.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.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
|
||||
@@ -29,6 +34,9 @@ class TokenBoundaryControllerTest {
|
||||
@MockitoBean
|
||||
private OAuth2AuthorizedClientService authorizedClientService;
|
||||
|
||||
@MockitoBean
|
||||
private OAuth2AuthorizedClientManager authorizedClientManager;
|
||||
|
||||
@Test
|
||||
void reportsServerSideTokensWithoutReturningTheirValues() throws Exception {
|
||||
OAuth2AuthorizedClient client = mock(OAuth2AuthorizedClient.class);
|
||||
@@ -47,4 +55,30 @@ class TokenBoundaryControllerTest {
|
||||
.andExpect(jsonPath("$.access_token").doesNotExist())
|
||||
.andExpect(jsonPath("$.refresh_token").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void handsOffAccessTokenOnlyAndMarksResponseNoStore() throws Exception {
|
||||
Instant issuedAt = Instant.parse("2026-07-25T00:00:00Z");
|
||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
"test-access-token",
|
||||
issuedAt,
|
||||
issuedAt.plusSeconds(300)
|
||||
);
|
||||
OAuth2AuthorizedClient client = mock(OAuth2AuthorizedClient.class);
|
||||
when(client.getAccessToken()).thenReturn(accessToken);
|
||||
when(authorizedClientManager.authorize(any(OAuth2AuthorizeRequest.class)))
|
||||
.thenReturn(client);
|
||||
|
||||
mockMvc.perform(get("/token/access").with(oidcLogin()
|
||||
.idToken(token -> token.subject("test-subject"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("Cache-Control", "no-store"))
|
||||
.andExpect(header().string("Pragma", "no-cache"))
|
||||
.andExpect(jsonPath("$.length()").value(3))
|
||||
.andExpect(jsonPath("$.access_token").value("test-access-token"))
|
||||
.andExpect(jsonPath("$.token_type").value("Bearer"))
|
||||
.andExpect(jsonPath("$.expires_at").value("2026-07-25T00:05:00Z"))
|
||||
.andExpect(jsonPath("$.refresh_token").doesNotExist());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user