feat(ap2): hand off access token only

This commit is contained in:
donghyeon-ka
2026-07-25 14:28:56 +09:00
parent 774f492750
commit bd48516e0f
14 changed files with 402 additions and 8 deletions
+6
View File
@@ -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)를 참고하세요.
@@ -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<Jwt> {
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);
}
}
@@ -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<Jwt> issuerAndTimestamp =
JwtValidators.createDefaultWithIssuer(issuer);
OAuth2TokenValidator<Jwt> audience = new AudienceValidator(expectedAudience);
decoder.setJwtValidator(
new DelegatingOAuth2TokenValidator<>(issuerAndTimestamp, audience)
);
return decoder;
}
}
@@ -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;
}
}
@@ -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:
@@ -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<String> 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)
);
}
}
+38
View File
@@ -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
비사용을 확인합니다.
+65 -7
View File
@@ -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();
@@ -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",
@@ -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>
@@ -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());
}
}