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
+
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());
+ }
}