diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/TokenValidityResolver.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/TokenValidityResolver.java index b0fbe093f12..0e158df992e 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/TokenValidityResolver.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/TokenValidityResolver.java @@ -1,6 +1,7 @@ package org.cloudfoundry.identity.uaa.oauth; import lombok.Setter; +import org.cloudfoundry.identity.uaa.oauth.refresh.RefreshTokenRequestData; import org.cloudfoundry.identity.uaa.util.TimeService; import java.util.Date; @@ -36,4 +37,17 @@ public Date resolve(String clientId) { return Date.from(Instant.ofEpochMilli(timeService.getCurrentTimeMillis()).plusSeconds(tokenValiditySeconds)); } + + /** + * Extension point for enterprise implementations that need to constrain refresh token + * validity based on request context (e.g. a JIT session boundary carried in the assertion). + * The default implementation ignores {@code requestData} and delegates to {@link #resolve(String)}. + * + * @param clientId the OAuth client identifier + * @param requestData the refresh token request context; may be {@code null} + * @return the effective expiration date + */ + public Date resolve(String clientId, RefreshTokenRequestData requestData) { + return resolve(clientId); + } } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointBeanConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointBeanConfiguration.java index 679bca691f0..a55037e52b8 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointBeanConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointBeanConfiguration.java @@ -729,6 +729,7 @@ JdbcRevocableTokenProvisioning revocableTokenProvisioning( } @Bean("refreshTokenValidityResolver") + @ConditionalOnMissingBean(name = "refreshTokenValidityResolver") TokenValidityResolver refreshTokenValidityResolver( @Qualifier("clientRefreshTokenValidity") ClientTokenValidity clientRefreshTokenValidity, @Value("${jwt.token.policy.global.refreshTokenValiditySeconds:2592000}") int globalTokenValiditySeconds diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/refresh/RefreshTokenCreator.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/refresh/RefreshTokenCreator.java index d9616f94957..aa27e85fc8e 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/refresh/RefreshTokenCreator.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/refresh/RefreshTokenCreator.java @@ -54,7 +54,7 @@ public CompositeExpiringOAuth2RefreshToken createRefreshToken(UaaUser user, Map additionalAuthorizationAttributes = new AuthorizationAttributesParser().getAdditionalAuthorizationAttributes(tokenRequestData.authorities); - Date expirationDate = refreshTokenValidityResolver.resolve(tokenRequestData.clientId); + Date expirationDate = refreshTokenValidityResolver.resolve(tokenRequestData.clientId, tokenRequestData); String tokenId = UUID.randomUUID().toString().replace("-", "") + REFRESH_TOKEN_SUFFIX; String jwtToken = buildJwtToken(user, diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/refresh/RefreshTokenCreatorTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/refresh/RefreshTokenCreatorTest.java index 69d536a96c9..e1e568a3fd0 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/refresh/RefreshTokenCreatorTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/refresh/RefreshTokenCreatorTest.java @@ -38,6 +38,8 @@ import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_SAML2_BEARER; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_TOKEN_EXCHANGE; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_USER_TOKEN; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -47,7 +49,7 @@ class RefreshTokenCreatorTest { @BeforeEach void setup() throws Exception { TokenValidityResolver validityResolver = mock(TokenValidityResolver.class); - when(validityResolver.resolve("someclient")).thenReturn(new Date()); + when(validityResolver.resolve(eq("someclient"), any())).thenReturn(new Date()); TokenEndpointBuilder tokenEndpointBuilder = new TokenEndpointBuilder("http://localhost"); refreshTokenCreator = new RefreshTokenCreator(false, validityResolver, tokenEndpointBuilder, new TimeServiceImpl(), new KeyInfoService("http://localhost")); IdentityZoneHolder.get().getConfig().getTokenPolicy().setActiveKeyId("newKey"); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/JitRefreshTokenExpirationMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/JitRefreshTokenExpirationMockMvcTests.java new file mode 100644 index 00000000000..80225a44241 --- /dev/null +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/JitRefreshTokenExpirationMockMvcTests.java @@ -0,0 +1,224 @@ +package org.cloudfoundry.identity.uaa.mock.token; + +import org.cloudfoundry.identity.uaa.DefaultTestContext; +import org.cloudfoundry.identity.uaa.oauth.ClientTokenValidity; +import org.cloudfoundry.identity.uaa.oauth.TokenValidityResolver; +import org.cloudfoundry.identity.uaa.oauth.UaaTokenEnhancer; +import org.cloudfoundry.identity.uaa.oauth.jwt.JwtHelper; +import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetails; +import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2Authentication; +import org.cloudfoundry.identity.uaa.oauth.refresh.RefreshTokenRequestData; +import org.cloudfoundry.identity.uaa.oauth.token.RevocableTokenProvisioning; +import org.cloudfoundry.identity.uaa.oauth.token.TokenConstants; +import org.cloudfoundry.identity.uaa.util.JsonUtils; +import org.cloudfoundry.identity.uaa.util.TimeService; +import org.cloudfoundry.identity.uaa.zone.IdentityZone; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; + +import java.time.Instant; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; +import static org.cloudfoundry.identity.uaa.oauth.TokenTestSupport.GRANT_TYPE; +import static org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants.EXTERNAL_ATTR; +import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_JWT_BEARER; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Demonstrates the pluggable {@link TokenValidityResolver} extension point. + * + *

The two inner beans are an example what a proprietary module could provide: + *

    + *
  1. A {@link UaaTokenEnhancer} that deposits a {@code exampleRefreshTokenExpiration} claim + * (read in production from {@code UaaAuthentication#getIdpIdToken()}) into the + * token's external attributes map.
  2. + *
  3. A {@link TokenValidityResolver} subclass that reads that claim and clamps the + * refresh token's TTL to {@code min(requestedExpiration, defaultExpiration)}.
  4. + *
+ */ +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@Import(JitRefreshTokenExpirationMockMvcTests.JitConfiguration.class) +@DefaultTestContext +public class JitRefreshTokenExpirationMockMvcTests extends JwtBearerGrantMockMvcTests { + + /** + * Controls the {@code exampleRefreshTokenExpiration} claim injected by the test enhancer. + * Set to a specific {@link Instant} before each grant call; set to {@code null} to + * simulate an absent claim (fallback to default TTL). + */ + static final AtomicReference requestedExpiration = new AtomicReference<>(); + + @Autowired + private RevocableTokenProvisioning revocableTokenProvisioning; + + // --------------------------------------------------------------------------- + // Inner Spring configuration — simulates the two proprietary extension beans + // --------------------------------------------------------------------------- + + static class JitConfiguration { + + /** + * Simulates an extension to {@link UaaTokenEnhancer} that reads + * {@code exampleRefreshTokenExpiration} from the incoming JWT assertion and deposits it + * as a top-level entry in the token's external-attributes map. + * + *

In this example the value would be extracted from + * {@code ((UaaAuthentication) auth.getUserAuthentication()).getIdpIdToken()}. + * Here we use the static {@link #requestedExpiration} so each test can control it. + */ + @Bean + UaaTokenEnhancer uaaTokenEnhancer() { + return new UaaTokenEnhancer() { + @Override + public Map getExternalAttributes(OAuth2Authentication authentication) { + return Map.of(); + } + + @Override + public Map enhance(Map claims, OAuth2Authentication authentication) { + Map result = new HashMap<>(); + result.put(EXTERNAL_ATTR, getExternalAttributes(authentication)); + Instant exp = requestedExpiration.get(); + if (exp != null) { + result.put("exampleRefreshTokenExpiration", exp.toString()); + } + return result; + } + }; + } + + /** + * Simulates an extension {@link TokenValidityResolver} subclass that applies + * session-bound clamping: the effective expiry is the earlier of the requested + * {@code exampleRefreshTokenExpiration} claim and the zone/client/global default. + * + *

Registered under the well-known qualifier so the + * {@code @ConditionalOnMissingBean} on the OSS default skips creating its own. + */ + @Bean("refreshTokenValidityResolver") + TokenValidityResolver refreshTokenValidityResolver( + @Qualifier("clientRefreshTokenValidity") ClientTokenValidity clientRefreshTokenValidity, + @Value("${jwt.token.policy.global.refreshTokenValiditySeconds:2592000}") int globalTokenValiditySeconds, + TimeService timeService + ) { + return new TokenValidityResolver(clientRefreshTokenValidity, globalTokenValiditySeconds, timeService) { + @Override + public Date resolve(String clientId, RefreshTokenRequestData requestData) { + Date defaultExpiration = resolve(clientId); + if (requestData == null || requestData.externalAttributes == null) { + return defaultExpiration; + } + Object claim = requestData.externalAttributes.get("exampleRefreshTokenExpiration"); + if (claim == null) { + return defaultExpiration; + } + Instant requested = Instant.parse(claim.toString()); + return requested.isBefore(defaultExpiration.toInstant()) + ? Date.from(requested) + : defaultExpiration; + } + }; + } + } + + // --------------------------------------------------------------------------- + // Test scenarios + // --------------------------------------------------------------------------- + + @Test + void refreshTokenExpiresAtAssertionTime_whenClaimIsBeforeDefault() throws Exception { + Instant expiresAt = Instant.now().plusSeconds(60); + requestedExpiration.set(expiresAt); + + IdentityZone defaultZone = IdentityZone.getUaa(); + createProvider(defaultZone, getTokenVerificationKey(originZone.getIdentityZone())); + String assertion = getUaaIdToken(originZone.getIdentityZone(), originClient, originUser); + + String refreshToken = performJwtBearerGrantForRefreshToken(defaultZone, assertion); + + long expClaim = getRefreshTokenExpClaim(refreshToken); + assertThat(expClaim).isCloseTo(expiresAt.getEpochSecond(), within(5L)); + } + + @Test + void refreshTokenExpiresAtDefault_whenClaimExceedsDefault() throws Exception { + // Far future — must be clamped to the zone/global refreshTokenValidity (default: 30 days) + requestedExpiration.set(Instant.now().plusSeconds(Integer.MAX_VALUE)); + + IdentityZone defaultZone = IdentityZone.getUaa(); + createProvider(defaultZone, getTokenVerificationKey(originZone.getIdentityZone())); + String assertion = getUaaIdToken(originZone.getIdentityZone(), originClient, originUser); + + String refreshToken = performJwtBearerGrantForRefreshToken(defaultZone, assertion); + + long expectedDefault = Instant.now().plusSeconds(2592000).getEpochSecond(); + long expClaim = getRefreshTokenExpClaim(refreshToken); + assertThat(expClaim).isCloseTo(expectedDefault, within(10L)); + } + + @Test + void refreshTokenExpiresAtDefault_whenClaimAbsent() throws Exception { + requestedExpiration.set(null); + + IdentityZone defaultZone = IdentityZone.getUaa(); + createProvider(defaultZone, getTokenVerificationKey(originZone.getIdentityZone())); + String assertion = getUaaIdToken(originZone.getIdentityZone(), originClient, originUser); + + String refreshToken = performJwtBearerGrantForRefreshToken(defaultZone, assertion); + + long expectedDefault = Instant.now().plusSeconds(2592000).getEpochSecond(); + long expClaim = getRefreshTokenExpClaim(refreshToken); + assertThat(expClaim).isCloseTo(expectedDefault, within(10L)); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private String performJwtBearerGrantForRefreshToken(IdentityZone zone, String assertion) throws Exception { + ClientDetails client = createJwtBearerClient(zone); + String json = mockMvc.perform( + post("/oauth/token") + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .param("client_id", client.getClientId()) + .param("client_secret", client.getClientSecret()) + .param(GRANT_TYPE, GRANT_TYPE_JWT_BEARER) + .param(TokenConstants.REQUEST_TOKEN_FORMAT, TokenConstants.TokenFormat.JWT.getStringValue()) + .param("response_type", "token id_token") + .param("scope", "openid") + .param("assertion", assertion) + ) + .andDo(print()) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + return (String) JsonUtils.readValueAsMap(json).get("refresh_token"); + } + + private long getRefreshTokenExpClaim(String refreshTokenJtiOrJwt) { + // The default zone uses OPAQUE refresh token format, so the "refresh_token" field + // in the response is the JTI. Look up the actual JWT from the revocable token store. + String jwtValue = refreshTokenJtiOrJwt.contains(".") + ? refreshTokenJtiOrJwt + : revocableTokenProvisioning.retrieve(refreshTokenJtiOrJwt, IdentityZoneHolder.get().getId()).getValue(); + String claimsJson = JwtHelper.decode(jwtValue).getClaims(); + Map claims = JsonUtils.readValueAsMap(claimsJson); + return ((Number) claims.get("exp")).longValue(); + } +}