From bd48516e0fb78f09704f470fe0443a6ee892df45 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 14:28:56 +0900 Subject: [PATCH] feat(ap2): hand off access token only --- README.md | 6 ++ .../keycloakpattern/AudienceValidator.java | 29 ++++++++ .../keycloakpattern/JwtDecoderConfig.java | 31 ++++++++ .../keycloakpattern/SecurityConfig.java | 18 +++++ backend/src/main/resources/application.yml | 3 + .../AudienceValidatorTest.java | 49 +++++++++++++ docs/ap2-token-boundary.md | 38 ++++++++++ e2e/pattern2.mjs | 72 +++++++++++++++++-- keycloak/import/keycloak-patterns-realm.json | 17 ++++- .../mediator/AccessTokenController.java | 56 +++++++++++++++ .../mediator/SecurityConfig.java | 26 +++++++ .../src/main/resources/static/app.js | 30 ++++++++ .../src/main/resources/static/index.html | 1 + .../mediator/TokenBoundaryControllerTest.java | 34 +++++++++ 14 files changed, 402 insertions(+), 8 deletions(-) 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 docs/ap2-token-boundary.md create mode 100644 token-mediator/src/main/java/com/example/keycloakpattern/mediator/AccessTokenController.java diff --git a/README.md b/README.md index 429a201..759060a 100644 --- a/README.md +++ b/README.md @@ -109,3 +109,9 @@ access/refresh token 보관은 backend가 담당합니다. ``` `/token/boundary`는 실제 token 값을 반환하지 않고 서버 저장 여부만 보여줍니다. +`/token/access`는 access token과 만료 메타데이터만 `no-store`로 전달하며, +refresh token은 반환하지 않습니다. 브라우저는 이 access token으로 +`http://localhost:8081/api/me`를 직접 호출합니다. + +자세한 token 경계와 실험 항목은 +[`docs/ap2-token-boundary.md`](docs/ap2-token-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/java/com/example/keycloakpattern/SecurityConfig.java b/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java index d4c2129..adeb6a7 100644 --- a/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java +++ b/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java @@ -1,11 +1,16 @@ package com.example.keycloakpattern; +import java.util.List; + import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @Configuration public class SecurityConfig { @@ -13,6 +18,7 @@ public class SecurityConfig { @Bean SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception { return http + .cors(Customizer.withDefaults()) .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) @@ -24,4 +30,16 @@ public class SecurityConfig { .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) .build(); } + + @Bean + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of("http://localhost:8082")); + configuration.setAllowedMethods(List.of("GET", "OPTIONS")); + configuration.setAllowedHeaders(List.of("Authorization", "Content-Type")); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", configuration); + return source; + } } 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/docs/ap2-token-boundary.md b/docs/ap2-token-boundary.md new file mode 100644 index 0000000..112bc8c --- /dev/null +++ b/docs/ap2-token-boundary.md @@ -0,0 +1,38 @@ +# AP2 · Token-Mediating Backend + +## 책임 경계 + +1. 브라우저는 Spring backend의 `/oauth2/authorization/keycloak`로 로그인을 + 시작합니다. +2. Keycloak은 authorization code를 Spring callback으로 전달합니다. +3. confidential client인 Spring backend가 client secret을 사용해 code를 + 교환하고 access/refresh token을 `OAuth2AuthorizedClientService`에 + 보관합니다. +4. 브라우저가 `/token/access`를 호출하면 backend는 현재 access token, + token type, 만료 시각만 `Cache-Control: no-store`로 반환합니다. +5. 브라우저는 전달받은 access token을 메모리에서만 사용해 Resource + Server를 직접 호출합니다. + +refresh token은 브라우저 응답, Web Storage, cookie에 전달되지 않습니다. +access token이 만료되면 `OAuth2AuthorizedClientManager`가 서버에 보관된 +refresh token으로 갱신한 뒤 새 access token만 전달할 수 있습니다. + +## 확인할 보안 속성 + +- Keycloak client는 `client_secret_basic`을 사용하는 confidential client입니다. +- Resource Server는 서명, issuer, timestamp와 함께 + `aud=keycloak-pattern-api`를 검증합니다. +- CORS는 AP2 UI origin인 `http://localhost:8082`의 `GET`만 허용합니다. +- access-token 응답에는 `refresh_token` 필드가 없고 `no-store`가 적용됩니다. +- 브라우저 cookie에는 HttpOnly, SameSite=Lax인 `AP2_SESSION` 식별자만 + 저장됩니다. 실제 OAuth token은 session cookie 안에 들어가지 않습니다. + +## 실행 + +```bash +./scripts/verify-pattern2.sh +``` + +검증은 실제 Keycloak 로그인 후 서버 token 보관 여부, access-only 응답의 +필드 집합과 audience, 브라우저의 직접 Resource Server 호출, Web Storage +비사용을 확인합니다. diff --git a/e2e/pattern2.mjs b/e2e/pattern2.mjs index 7987e3e..953a720 100644 --- a/e2e/pattern2.mjs +++ b/e2e/pattern2.mjs @@ -4,6 +4,28 @@ 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:8082/") { + return; + } + if (attempt === 1) { + await page.goto( + "http://localhost:8082/oauth2/authorization/keycloak", + ); + await page.waitForURL(/localhost:8080/u); + } + } + throw new Error(`Keycloak login did not return to AP2: ${page.url()}`); +} + const browser = await chromium.launch({ executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome", headless: true, @@ -17,12 +39,7 @@ try { await page.goto("http://localhost:8082"); await page.locator("#login").click(); await page.waitForURL(/localhost:8080/u); - 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.waitForURL("http://localhost:8082/"); + await completeKeycloakLogin(page); const boundaryResponsePromise = page.waitForResponse((response) => response.url().endsWith("/token/boundary"), @@ -37,6 +54,43 @@ try { assert.equal(boundary.browserReceivesRefreshToken, false); assert.equal(JSON.stringify(boundary).includes("refresh_token"), false); + const accessResponsePromise = page.waitForResponse((response) => + response.url().endsWith("/token/access"), + ); + const resourceResponsePromise = page.waitForResponse( + (response) => + response.url() === "http://localhost:8081/api/me" && + response.request().method() === "GET", + ); + await page.locator("#call-api").click(); + + const accessResponse = await accessResponsePromise; + assert.equal(accessResponse.status(), 200); + assert.match(accessResponse.headers()["cache-control"], /no-store/u); + const accessHandoff = await accessResponse.json(); + assert.deepEqual( + Object.keys(accessHandoff).sort(), + ["access_token", "expires_at", "token_type"], + ); + assert.equal(accessHandoff.token_type, "Bearer"); + assert.equal(typeof accessHandoff.access_token, "string"); + assert.ok(accessHandoff.access_token.length > 100); + assert.equal(JSON.stringify(accessHandoff).includes("refresh_token"), false); + + const [, payload] = accessHandoff.access_token.split("."); + const claims = JSON.parse( + Buffer.from(payload, "base64url").toString("utf8"), + ); + const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; + assert.ok(audience.includes("keycloak-pattern-api")); + + const resourceResponse = await resourceResponsePromise; + assert.equal(resourceResponse.status(), 200); + const resource = await resourceResponse.json(); + assert.equal(resource.username, "regular-user"); + assert.ok(resource.audience.includes("keycloak-pattern-api")); + await page.locator("#result").getByText('"resourceApiStatus": 200').waitFor(); + const cookies = await context.cookies("http://localhost:8082/"); const sessionCookie = cookies.find((cookie) => cookie.name === "AP2_SESSION"); assert.ok(sessionCookie); @@ -48,9 +102,13 @@ try { sessionStorage: Object.values(sessionStorage), })); assert.equal(JSON.stringify(storage).includes("refresh_token"), false); + assert.equal( + JSON.stringify(storage).includes(accessHandoff.access_token), + false, + ); console.log( - "pattern2 confidential client verified: server code exchange, server access/refresh custody, HttpOnly session", + "pattern2 verified: server refresh custody, access-only handoff, direct browser resource call", ); } finally { await browser.close(); diff --git a/keycloak/import/keycloak-patterns-realm.json b/keycloak/import/keycloak-patterns-realm.json index 9a4fae9..ff6b93c 100644 --- a/keycloak/import/keycloak-patterns-realm.json +++ b/keycloak/import/keycloak-patterns-realm.json @@ -75,7 +75,22 @@ ], "attributes": { "post.logout.redirect.uris": "http://localhost:8082/*" - } + }, + "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": "bff-confidential", diff --git a/token-mediator/src/main/java/com/example/keycloakpattern/mediator/AccessTokenController.java b/token-mediator/src/main/java/com/example/keycloakpattern/mediator/AccessTokenController.java new file mode 100644 index 0000000..0d90870 --- /dev/null +++ b/token-mediator/src/main/java/com/example/keycloakpattern/mediator/AccessTokenController.java @@ -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> 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 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); + } +} diff --git a/token-mediator/src/main/java/com/example/keycloakpattern/mediator/SecurityConfig.java b/token-mediator/src/main/java/com/example/keycloakpattern/mediator/SecurityConfig.java index 1f2432c..9744a5e 100644 --- a/token-mediator/src/main/java/com/example/keycloakpattern/mediator/SecurityConfig.java +++ b/token-mediator/src/main/java/com/example/keycloakpattern/mediator/SecurityConfig.java @@ -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; + } } diff --git a/token-mediator/src/main/resources/static/app.js b/token-mediator/src/main/resources/static/app.js index 31daa0a..9c41583 100644 --- a/token-mediator/src/main/resources/static/app.js +++ b/token-mediator/src/main/resources/static/app.js @@ -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(), + }); +}); diff --git a/token-mediator/src/main/resources/static/index.html b/token-mediator/src/main/resources/static/index.html index 728f88c..13e5502 100644 --- a/token-mediator/src/main/resources/static/index.html +++ b/token-mediator/src/main/resources/static/index.html @@ -22,6 +22,7 @@

+

   
   
diff --git a/token-mediator/src/test/java/com/example/keycloakpattern/mediator/TokenBoundaryControllerTest.java b/token-mediator/src/test/java/com/example/keycloakpattern/mediator/TokenBoundaryControllerTest.java
index 86dd241..23b6c0c 100644
--- a/token-mediator/src/test/java/com/example/keycloakpattern/mediator/TokenBoundaryControllerTest.java
+++ b/token-mediator/src/test/java/com/example/keycloakpattern/mediator/TokenBoundaryControllerTest.java
@@ -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());
+    }
 }