feat(ap2): hand off access token only
This commit is contained in:
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
+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