diff --git a/docs/UAA-Configuration-Reference.md b/docs/UAA-Configuration-Reference.md index 77cb0ee81b4..fa39c6591af 100644 --- a/docs/UAA-Configuration-Reference.md +++ b/docs/UAA-Configuration-Reference.md @@ -111,7 +111,7 @@ or `$CLOUDFOUNDRY_CONFIG_PATH/uaa.yml`. | `jwt.token.policy.global.accessTokenValiditySeconds` | `43200`| Global access token validity (s)| | `jwt.token.policy.global.refreshTokenValiditySeconds` | `2592000`| Global refresh token validity (s)| | `jwt.token.refresh.format` | `opaque`| Refresh token format| -| `jwt.token.refresh.unique` | `false`| Unique refresh tokens| +| `jwt.token.refresh.unique` | `false`| Max concurrent refresh-token sessions per user/client| | `jwt.token.refresh.rotate` | `false`| Rotate refresh tokens| | `jwt.token.refresh.restrict_grant` | —| Restrict refresh token grant| | `jwt.token.claims.exclude` | `[]`| Claims excluded from tokens| @@ -1043,10 +1043,36 @@ Format of issued refresh tokens. Accepted values: **Default:** `false` **Source:** `@Value("${jwt.token.refresh.unique:false}")` in [`OauthEndpointBeanConfiguration`](../server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointBeanConfiguration.java) -**Type:** `boolean` +**Type:** `boolean | integer` + +Maximum number of concurrent active refresh tokens (sessions) allowed per user/client pair. + +Accepted values: + +- `false` or `0` (or any non-positive integer) — no limit (default behaviour) +- `true` — equivalent to `1`; at most one active refresh token per user/client pair +- A positive integer `N` — at most `N` concurrent refresh tokens per user/client pair + +When the limit is reached, the **oldest** refresh token is revoked to make room for the new +one. Enforcement only takes effect when `jwt.token.revocable` is also `true` (revocable +tokens must be enabled so UAA can track and delete them). + +Per-client overrides are supported: set `refreshTokenUnique` in the client's +`additionalInformation` map to an integer to override the zone-level policy for that +specific client. + +**Example — limit all users to two concurrent sessions:** + +```yaml +jwt: + token: + revocable: true + refresh: + unique: 2 +``` -When `true`, only one refresh token can exist per user/client combination. Issuing a new -refresh token invalidates the previous one. +**Related properties:** [`jwt.token.revocable`](#jwttokenrevocable), +[`jwt.token.refresh.rotate`](#jwttokenrefreshrotate) [Back to table](#jwt-token-policy) diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/oauth/client/ClientConstants.java b/model/src/main/java/org/cloudfoundry/identity/uaa/oauth/client/ClientConstants.java index 3c974a30ec0..abd928d48da 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/oauth/client/ClientConstants.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/oauth/client/ClientConstants.java @@ -23,4 +23,5 @@ public class ClientConstants { public static final String TOKEN_SALT = "token_salt"; public static final String REQUIRED_USER_GROUPS = "required_user_groups"; public static final String LAST_MODIFIED = "lastModified"; + public static final String REFRESH_TOKEN_UNIQUE = "refreshTokenUnique"; } diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/zone/TokenPolicy.java b/model/src/main/java/org/cloudfoundry/identity/uaa/zone/TokenPolicy.java index e9ea9bc540e..1813e84a39b 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/zone/TokenPolicy.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/zone/TokenPolicy.java @@ -15,6 +15,7 @@ package org.cloudfoundry.identity.uaa.zone; import com.fasterxml.jackson.annotation.*; +import tools.jackson.databind.JsonNode; import org.cloudfoundry.identity.uaa.oauth.token.TokenConstants; import org.springframework.util.StringUtils; @@ -42,7 +43,7 @@ public class TokenPolicy { private int accessTokenValidity; private int refreshTokenValidity; private boolean jwtRevocable; - private boolean refreshTokenUnique; + private int refreshTokenUnique = -1; private boolean refreshTokenRotate; private String refreshTokenFormat = OPAQUE.getStringValue(); @@ -126,12 +127,47 @@ public void setKeys(Map keys) { } } + @JsonIgnore public boolean isRefreshTokenUnique() { - return refreshTokenUnique; + return refreshTokenUnique > 0; } + @JsonIgnore public void setRefreshTokenUnique(boolean refreshTokenUnique) { - this.refreshTokenUnique = refreshTokenUnique; + this.refreshTokenUnique = refreshTokenUnique ? 1 : -1; + } + + @JsonGetter("refreshTokenUnique") + public int getMaxSessionLimit() { + return refreshTokenUnique; + } + + public void setMaxSessionLimit(int maxSessionLimit) { + this.refreshTokenUnique = maxSessionLimit <= 0 ? -1 : maxSessionLimit; + } + + @JsonSetter("refreshTokenUnique") + private void setRefreshTokenUniqueFromJson(JsonNode node) { + int parsedValue = -1; + if (node.isBoolean()) { + parsedValue = node.asBoolean() ? 1 : -1; + } else if (node.isNumber()) { + parsedValue = node.asInt(); + } else if (node.isTextual()) { + String text = node.asText(); + if ("true".equalsIgnoreCase(text)) { + parsedValue = 1; + } else if ("false".equalsIgnoreCase(text)) { + parsedValue = -1; + } else { + try { + parsedValue = Integer.parseInt(text); + } catch (NumberFormatException e) { + parsedValue = -1; + } + } + } + setMaxSessionLimit(parsedValue); } public boolean isRefreshTokenRotate() { diff --git a/model/src/test/java/org/cloudfoundry/identity/uaa/zone/TokenPolicyTest.java b/model/src/test/java/org/cloudfoundry/identity/uaa/zone/TokenPolicyTest.java index ba97a8caba3..4fea89fec3e 100644 --- a/model/src/test/java/org/cloudfoundry/identity/uaa/zone/TokenPolicyTest.java +++ b/model/src/test/java/org/cloudfoundry/identity/uaa/zone/TokenPolicyTest.java @@ -56,6 +56,23 @@ void set_values() { assertThat(policy.getRefreshTokenFormat()).isEqualTo(TokenConstants.TokenFormat.JWT.getStringValue()); } + @Test + void set_refresh_token_unique_boolean_true_sets_max_session_limit_1() { + TokenPolicy policy = new TokenPolicy(); + policy.setRefreshTokenUnique(true); + assertThat(policy.isRefreshTokenUnique()).isTrue(); + assertThat(policy.getMaxSessionLimit()).isEqualTo(1); + } + + @Test + void set_refresh_token_unique_boolean_false_sets_max_session_limit_unlimited() { + TokenPolicy policy = new TokenPolicy(); + policy.setMaxSessionLimit(5); + policy.setRefreshTokenUnique(false); + assertThat(policy.isRefreshTokenUnique()).isFalse(); + assertThat(policy.getMaxSessionLimit()).isEqualTo(-1); + } + @Test void nullSigningKey() { TokenPolicy tokenPolicy = new TokenPolicy(); diff --git a/model/src/test/resources/org/cloudfoundry/identity/uaa/zone/SampleIdentityZone.json b/model/src/test/resources/org/cloudfoundry/identity/uaa/zone/SampleIdentityZone.json index e4609346418..61cfd257f11 100644 --- a/model/src/test/resources/org/cloudfoundry/identity/uaa/zone/SampleIdentityZone.json +++ b/model/src/test/resources/org/cloudfoundry/identity/uaa/zone/SampleIdentityZone.json @@ -14,7 +14,7 @@ "accessTokenValidity": -1, "refreshTokenValidity": -1, "jwtRevocable": false, - "refreshTokenUnique": false, + "refreshTokenUnique": -1, "refreshTokenFormat": "jwt", "activeKeyId": "key-id-1", "keys" : { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServices.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServices.java index c6cbebdd8b1..2b19fd44605 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServices.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/UaaTokenServices.java @@ -21,6 +21,8 @@ import org.cloudfoundry.identity.uaa.authentication.UaaAuthentication; import org.cloudfoundry.identity.uaa.authentication.UaaPrincipal; import org.cloudfoundry.identity.uaa.client.UaaClientDetails; +import org.cloudfoundry.identity.uaa.oauth.client.ClientConstants; +import org.cloudfoundry.identity.uaa.oauth.event.TokenRevocationEvent; import org.cloudfoundry.identity.uaa.oauth.common.DefaultOAuth2RefreshToken; import org.cloudfoundry.identity.uaa.oauth.common.OAuth2AccessToken; import org.cloudfoundry.identity.uaa.oauth.common.OAuth2RefreshToken; @@ -62,9 +64,12 @@ import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; import org.cloudfoundry.identity.uaa.zone.MultitenantClientServices; import org.cloudfoundry.identity.uaa.zone.TokenPolicy; +import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; +import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManagerImpl; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisherAware; import org.springframework.security.authentication.InternalAuthenticationServiceException; @@ -83,6 +88,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.Comparator; import java.util.Date; import java.util.HashMap; import java.util.HashSet; @@ -780,8 +786,8 @@ CompositeToken persistRevocableToken(String tokenId, boolean isRefreshTokenOpaque = isOpaque || OPAQUE.getStringValue().equals(getActiveTokenPolicy().getRefreshTokenFormat()); boolean refreshTokenRevocable = isRefreshTokenOpaque || getActiveTokenPolicy().isJwtRevocable(); - boolean refreshTokenUnique = getActiveTokenPolicy().isRefreshTokenUnique(); if (refreshToken != null && refreshTokenRevocable) { + String zoneId = IdentityZoneHolder.get().getId(); RevocableToken revocableRefreshToken = new RevocableToken() .setTokenId(refreshToken.getJti()) .setClientId(clientId) @@ -789,14 +795,12 @@ CompositeToken persistRevocableToken(String tokenId, .setIssuedAt(now) .setFormat(isRefreshTokenOpaque ? OPAQUE.getStringValue() : JWT.getStringValue()) .setResponseType(REFRESH_TOKEN) - .setZoneId(IdentityZoneHolder.get().getId()) + .setZoneId(zoneId) .setUserId(userId) .setScope(scope) .setValue(refreshToken.getValue()); - if (refreshTokenUnique) { - tokenProvisioning.deleteRefreshTokensForClientAndUserId(clientId, userId, IdentityZoneHolder.get().getId()); - } - tokenProvisioning.createIfNotExists(revocableRefreshToken, IdentityZoneHolder.get().getId()); + enforceConcurrentSessionLimit(userId, clientId, resolveRefreshTokenUniqueLimit(clientId, zoneId), zoneId, refreshToken.getJti(), tokenIdToBeDeleted); + tokenProvisioning.createIfNotExists(revocableRefreshToken, zoneId); if (tokenIdToBeDeleted != null) { tokenProvisioning.delete(tokenIdToBeDeleted, -1, IdentityZoneHolder.getCurrentZoneId()); } @@ -956,12 +960,73 @@ protected void setClientDetailsService(MultitenantClientServices clientDetailsSe this.clientDetailsService = clientDetailsService; } - private void publish(TokenIssuedEvent event) { + private void publish(ApplicationEvent event) { if (applicationEventPublisher != null) { applicationEventPublisher.publishEvent(event); } } + /** + * Resolves the effective concurrent session limit (the {@code refreshTokenUnique} value) for the given client. + * A client-level override stored in the client's additional information takes precedence over the identity + * zone's token policy. The value is interpreted as: {@code -1} (or any non-positive number) means unlimited, + * while a positive number caps the active refresh tokens per user and client at that count. + */ + private int resolveRefreshTokenUniqueLimit(String clientId, String zoneId) { + int limit = getActiveTokenPolicy().getMaxSessionLimit(); + try { + UaaClientDetails client = (UaaClientDetails) clientDetailsService.loadClientByClientId(clientId, zoneId); + if (client != null && client.getAdditionalInformation().get(ClientConstants.REFRESH_TOKEN_UNIQUE) instanceof Number clientLimit) { + limit = clientLimit.intValue(); + } + } catch (RuntimeException e) { + logger.warn("Failed to read client-level {} override for client {}", ClientConstants.REFRESH_TOKEN_UNIQUE, clientId, e); + } + return limit; + } + + /** + * Enforces the per-user, per-client concurrent session limit by revoking the oldest refresh tokens so that + * at most {@code limit - 1} remain, leaving room for the refresh token that is about to be issued. A + * non-positive limit means unlimited and disables enforcement. This only takes effect when refresh tokens + * are revocable (i.e. {@code jwt.token.revocable=true}), which is guaranteed by the caller. + */ + private void enforceConcurrentSessionLimit(String userId, String clientId, int limit, String zoneId, String newTokenId, String tokenIdToBeDeleted) { + if (limit <= 0) { + return; + } + List allRefreshTokens = ofNullable(tokenProvisioning.getUserTokens(userId, clientId, zoneId)) + .orElseGet(Collections::emptyList) + .stream() + .filter(token -> REFRESH_TOKEN.equals(token.getResponseType())) + .toList(); + + boolean reusingExistingToken = allRefreshTokens.stream().anyMatch(t -> t.getTokenId().equals(newTokenId)); + boolean deletingOldToken = tokenIdToBeDeleted != null && allRefreshTokens.stream().anyMatch(t -> t.getTokenId().equals(tokenIdToBeDeleted)); + + int netIncrease = 1; + if (reusingExistingToken || deletingOldToken) { + netIncrease = 0; + } + + int numberToRevoke = allRefreshTokens.size() + netIncrease - limit; + if (numberToRevoke <= 0) { + return; + } + + List candidatesToRevoke = allRefreshTokens.stream() + .filter(t -> !t.getTokenId().equals(newTokenId)) + .filter(t -> tokenIdToBeDeleted == null || !t.getTokenId().equals(tokenIdToBeDeleted)) + .sorted(Comparator.comparingLong(RevocableToken::getIssuedAt)) + .toList(); + + for (int i = 0; i < numberToRevoke && i < candidatesToRevoke.size(); i++) { + tokenProvisioning.delete(candidatesToRevoke.get(i).getTokenId(), -1, zoneId); + } + + publish(new TokenRevocationEvent(userId, clientId, zoneId, SecurityContextHolder.getContext().getAuthentication())); + } + public TokenPolicy getTokenPolicy() { return tokenPolicy; } 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 a55037e52b8..87c76acff61 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 @@ -683,7 +683,7 @@ TokenPolicy uaaTokenPolicy( @Value("${jwt.token.policy.activeKeyId:#{null}}") String activeKeyId, @Value("${jwt.token.revocable:false}") boolean jwtRevocable, @Value("${jwt.token.refresh.format:#{T(org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.TokenFormat).OPAQUE.getStringValue()}}") String refreshTokenFormat, - @Value("${jwt.token.refresh.unique:false}") boolean refreshTokenUnique, + @Value("${jwt.token.refresh.unique:false}") String refreshTokenUniqueStr, @Value("${jwt.token.refresh.rotate:false}") boolean refreshTokenRotate ) { TokenPolicy bean = new TokenPolicy( @@ -694,7 +694,24 @@ TokenPolicy uaaTokenPolicy( bean.setActiveKeyId(activeKeyId); bean.setJwtRevocable(jwtRevocable); bean.setRefreshTokenFormat(refreshTokenFormat); - bean.setRefreshTokenUnique(refreshTokenUnique); + + int refreshTokenUnique = -1; + if ("true".equalsIgnoreCase(refreshTokenUniqueStr)) { + refreshTokenUnique = 1; + } else if ("false".equalsIgnoreCase(refreshTokenUniqueStr)) { + refreshTokenUnique = -1; + } else { + try { + refreshTokenUnique = Integer.parseInt(refreshTokenUniqueStr); + if (refreshTokenUnique <= 0) { + refreshTokenUnique = -1; + } + } catch (NumberFormatException e) { + refreshTokenUnique = -1; + } + } + bean.setMaxSessionLimit(refreshTokenUnique); + bean.setRefreshTokenRotate(refreshTokenRotate); return bean; } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/DeprecatedUaaTokenServicesTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/DeprecatedUaaTokenServicesTests.java index 2ba9215a175..420bf8c78a0 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/DeprecatedUaaTokenServicesTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/DeprecatedUaaTokenServicesTests.java @@ -227,8 +227,17 @@ void opaque_tokens_are_persisted(TestTokenEnhancer enhancer) { @ParameterizedTest(name = "{index}: {0}") void refresh_tokens_are_uniquely_persisted(TestTokenEnhancer enhancer) { initDeprecatedUaaTokenServicesTests(enhancer); + String currentZoneId = IdentityZoneHolder.get().getId(); IdentityZoneHolder.get().getConfig().getTokenPolicy().setRefreshTokenUnique(true); IdentityZoneHolder.get().getConfig().getTokenPolicy().setRefreshTokenFormat(OPAQUE.getStringValue()); + RevocableToken existingRefreshToken = new RevocableToken() + .setTokenId("existing-refresh-token") + .setClientId("clientId") + .setUserId("userId") + .setResponseType(RevocableToken.TokenType.REFRESH_TOKEN) + .setIssuedAt(1000L); + when(tokenProvisioning.getUserTokens("userId", "clientId", currentZoneId)) + .thenReturn(Collections.singletonList(existingRefreshToken)); tokenServices.persistRevocableToken("id", persistToken, new CompositeExpiringOAuth2RefreshToken("refresh-token-value", expiration, ""), @@ -237,7 +246,8 @@ void refresh_tokens_are_uniquely_persisted(TestTokenEnhancer enhancer) { true, true, null); ArgumentCaptor rt = ArgumentCaptor.forClass(RevocableToken.class); - verify(tokenProvisioning, times(1)).deleteRefreshTokensForClientAndUserId("clientId", "userId", IdentityZoneHolder.get().getId()); + // The limit of 1 means the single pre-existing refresh token is revoked to make room for the new one. + verify(tokenProvisioning, times(1)).delete("existing-refresh-token", -1, currentZoneId); verify(tokenProvisioning, times(1)).upsert(anyString(), rt.capture(), anyString()); verify(tokenProvisioning, times(1)).createIfNotExists(rt.capture(), anyString()); RevocableToken refreshToken = rt.getAllValues().get(1); @@ -258,13 +268,91 @@ void refresh_token_not_unique_when_set_to_false(TestTokenEnhancer enhancer) { true, null); ArgumentCaptor rt = ArgumentCaptor.forClass(RevocableToken.class); String currentZoneId = IdentityZoneHolder.get().getId(); - verify(tokenProvisioning, times(0)).deleteRefreshTokensForClientAndUserId(anyString(), anyString(), eq(currentZoneId)); + // An unlimited (-1) policy must not revoke any existing refresh tokens. + verify(tokenProvisioning, never()).delete(anyString(), eq(-1), eq(currentZoneId)); verify(tokenProvisioning, times(1)).upsert(anyString(), rt.capture(), anyString()); verify(tokenProvisioning, times(1)).createIfNotExists(rt.capture(), anyString()); RevocableToken refreshToken = rt.getAllValues().get(1); assertThat(refreshToken.getResponseType()).isEqualTo(RevocableToken.TokenType.REFRESH_TOKEN); } + @MethodSource("data") + @ParameterizedTest(name = "{index}: {0}") + void refresh_token_limit_revokes_oldest_sessions_over_threshold(TestTokenEnhancer enhancer) { + initDeprecatedUaaTokenServicesTests(enhancer); + String currentZoneId = IdentityZoneHolder.get().getId(); + IdentityZoneHolder.get().getConfig().getTokenPolicy().setMaxSessionLimit(2); + IdentityZoneHolder.get().getConfig().getTokenPolicy().setRefreshTokenFormat(OPAQUE.getStringValue()); + RevocableToken oldest = new RevocableToken().setTokenId("oldest").setClientId("clientId").setUserId("userId") + .setResponseType(RevocableToken.TokenType.REFRESH_TOKEN).setIssuedAt(1000L); + RevocableToken newer = new RevocableToken().setTokenId("newer").setClientId("clientId").setUserId("userId") + .setResponseType(RevocableToken.TokenType.REFRESH_TOKEN).setIssuedAt(2000L); + when(tokenProvisioning.getUserTokens("userId", "clientId", currentZoneId)) + .thenReturn(Arrays.asList(newer, oldest)); + tokenServices.persistRevocableToken("id", + persistToken, + new CompositeExpiringOAuth2RefreshToken("refresh-token-value", expiration, ""), + "clientId", + "userId", + true, + true, null); + // With a limit of 2 and 2 existing sessions, only the single oldest session is revoked. + verify(tokenProvisioning, times(1)).delete("oldest", -1, currentZoneId); + verify(tokenProvisioning, never()).delete("newer", -1, currentZoneId); + } + + @MethodSource("data") + @ParameterizedTest(name = "{index}: {0}") + void refresh_token_limit_is_applied_per_user_and_client(TestTokenEnhancer enhancer) { + initDeprecatedUaaTokenServicesTests(enhancer); + String currentZoneId = IdentityZoneHolder.get().getId(); + IdentityZoneHolder.get().getConfig().getTokenPolicy().setMaxSessionLimit(2); + IdentityZoneHolder.get().getConfig().getTokenPolicy().setRefreshTokenFormat(OPAQUE.getStringValue()); + + // User A has 2 sessions + RevocableToken userA_oldest = new RevocableToken().setTokenId("userA_oldest").setClientId("clientId").setUserId("userA") + .setResponseType(RevocableToken.TokenType.REFRESH_TOKEN).setIssuedAt(1000L); + RevocableToken userA_newer = new RevocableToken().setTokenId("userA_newer").setClientId("clientId").setUserId("userA") + .setResponseType(RevocableToken.TokenType.REFRESH_TOKEN).setIssuedAt(2000L); + + // User B has 1 session + RevocableToken userB_session = new RevocableToken().setTokenId("userB_session").setClientId("clientId").setUserId("userB") + .setResponseType(RevocableToken.TokenType.REFRESH_TOKEN).setIssuedAt(1500L); + + when(tokenProvisioning.getUserTokens("userA", "clientId", currentZoneId)) + .thenReturn(Arrays.asList(userA_newer, userA_oldest)); + when(tokenProvisioning.getUserTokens("userB", "clientId", currentZoneId)) + .thenReturn(Collections.singletonList(userB_session)); + + // User B logs in (2nd session for User B) + tokenServices.persistRevocableToken("id", + persistToken, + new CompositeExpiringOAuth2RefreshToken("refresh-token-value", expiration, ""), + "clientId", + "userB", + true, + true, null); + + // User B's new session should NOT revoke User A's sessions, and User B has not exceeded the limit of 2. + verify(tokenProvisioning, never()).delete("userA_oldest", -1, currentZoneId); + verify(tokenProvisioning, never()).delete("userA_newer", -1, currentZoneId); + verify(tokenProvisioning, never()).delete("userB_session", -1, currentZoneId); + + // Now User A logs in (3rd session for User A) + tokenServices.persistRevocableToken("id", + persistToken, + new CompositeExpiringOAuth2RefreshToken("refresh-token-value", expiration, ""), + "clientId", + "userA", + true, + true, null); + + // User A's oldest session should be revoked because they exceeded the limit of 2. + verify(tokenProvisioning, times(1)).delete("userA_oldest", -1, currentZoneId); + verify(tokenProvisioning, never()).delete("userA_newer", -1, currentZoneId); + verify(tokenProvisioning, never()).delete("userB_session", -1, currentZoneId); + } + @MethodSource("data") @ParameterizedTest(name = "{index}: {0}") void refreshAccessToken_buildsIdToken_withRolesAndAttributesAndACR(TestTokenEnhancer enhancer) throws Exception { diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/RefreshRotationTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/RefreshRotationTest.java index 854da70be4a..4c4b742531c 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/RefreshRotationTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/RefreshRotationTest.java @@ -42,6 +42,160 @@ import static org.mockito.Mockito.when; class RefreshRotationTest { + + @Test + @DisplayName("Refresh Token with concurrent session limit") + void refreshWithConcurrentSessionLimit() { + UaaClientDetails clientDetails = new UaaClientDetails(tokenSupport.defaultClient); + clientDetails.setAutoApproveScopes(singleton("true")); + tokenSupport.clientDetailsService.setClientDetailsStore(IdentityZoneHolder.get().getId(), Collections.singletonMap(CLIENT_ID, clientDetails)); + + IdentityZoneHolder.get().getConfig().getTokenPolicy().setJwtRevocable(true); + IdentityZoneHolder.get().getConfig().getTokenPolicy().setMaxSessionLimit(2); + IdentityZoneHolder.get().getConfig().getTokenPolicy().setRefreshTokenRotate(false); + + // Stub getUserTokens + when(tokenSupport.getTokenProvisioning().getUserTokens(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> { + String userId = invocation.getArgument(0); + String clientId = invocation.getArgument(1); + return tokenSupport.tokens.values().stream() + .filter(t -> userId.equals(t.getUserId()) && clientId.equals(t.getClientId())) + .collect(java.util.stream.Collectors.toList()); + }); + + // Stub delete + org.mockito.Mockito.doAnswer(invocation -> { + String tokenId = invocation.getArgument(0); + tokenSupport.tokens.remove(tokenId); + return null; + }).when(tokenSupport.getTokenProvisioning()).delete(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyString()); + + AuthorizationRequest authorizationRequest = new AuthorizationRequest(CLIENT_ID, tokenSupport.requestedAuthScopes); + authorizationRequest.setResourceIds(new HashSet<>(tokenSupport.resourceIds)); + Map azParameters = new HashMap<>(authorizationRequest.getRequestParameters()); + azParameters.put(GRANT_TYPE, GRANT_TYPE_AUTHORIZATION_CODE); + authorizationRequest.setRequestParameters(azParameters); + Authentication userAuthentication = tokenSupport.defaultUserAuthentication; + OAuth2Authentication authentication = new OAuth2Authentication(authorizationRequest.createOAuth2Request(), userAuthentication); + + // 1st session + CompositeToken accessToken1 = (CompositeToken) tokenServices.createAccessToken(authentication); + String refreshTokenValue1 = accessToken1.getRefreshToken().getValue(); + + // 2nd session + CompositeToken accessToken2 = (CompositeToken) tokenServices.createAccessToken(authentication); + String refreshTokenValue2 = accessToken2.getRefreshToken().getValue(); + + long refreshTokensCount = tokenSupport.tokens.values().stream().filter(t -> org.cloudfoundry.identity.uaa.oauth.token.RevocableToken.TokenType.REFRESH_TOKEN.equals(t.getResponseType())).count(); + assertThat(refreshTokensCount).isEqualTo(2); + + // Refresh 2nd session (should reuse the refresh token and NOT delete the 1st session) + OAuth2AccessToken refreshedToken = tokenServices.refreshAccessToken(refreshTokenValue2, new TokenRequest(new HashMap<>(), CLIENT_ID, Lists.newArrayList("openid"), GRANT_TYPE_REFRESH_TOKEN)); + assertThat(refreshedToken.getRefreshToken().getValue()).isEqualTo(refreshTokenValue2); + + refreshTokensCount = tokenSupport.tokens.values().stream().filter(t -> org.cloudfoundry.identity.uaa.oauth.token.RevocableToken.TokenType.REFRESH_TOKEN.equals(t.getResponseType())).count(); + assertThat(refreshTokensCount).isEqualTo(2); + + // 3rd session (should delete the oldest session, which is the 1st session) + CompositeToken accessToken3 = (CompositeToken) tokenServices.createAccessToken(authentication); + + refreshTokensCount = tokenSupport.tokens.values().stream().filter(t -> org.cloudfoundry.identity.uaa.oauth.token.RevocableToken.TokenType.REFRESH_TOKEN.equals(t.getResponseType())).count(); + assertThat(refreshTokensCount).isEqualTo(2); + + // Verify that the 1st session's refresh token is no longer in the DB. + // For opaque-format tokens the tokens map is keyed by tokenId (== the opaque value returned to the client). + assertThat(tokenSupport.tokens.containsKey(refreshTokenValue1)) + .as("1st session refresh token must be gone after 3rd login").isFalse(); + } + + @Test + @DisplayName("Concurrent session limit with public client rotation (tp_cli_app scenario)") + void refreshWithConcurrentSessionLimitAndPublicClientRotation() { + // tp_cli_app is a public client (allowpublic=true, no secret). + // shouldRotateRefreshTokens() returns true whenever clientAuth == "none", + // so every access-token refresh produces a new refresh token JTI. + // The bug: enforceConcurrentSessionLimit treated each rotation as a net +1 + // new session, causing premature revocation of older sessions. + + UaaClientDetails clientDetails = new UaaClientDetails(tokenSupport.defaultClient); + clientDetails.setAutoApproveScopes(singleton("true")); + tokenSupport.clientDetailsService.setClientDetailsStore(IdentityZoneHolder.get().getId(), Collections.singletonMap(CLIENT_ID, clientDetails)); + + IdentityZoneHolder.get().getConfig().getTokenPolicy().setJwtRevocable(true); + IdentityZoneHolder.get().getConfig().getTokenPolicy().setMaxSessionLimit(2); + IdentityZoneHolder.get().getConfig().getTokenPolicy().setRefreshTokenRotate(false); // global off; rotation is forced by clientAuth=none + + when(tokenSupport.getTokenProvisioning().getUserTokens(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> { + String userId = invocation.getArgument(0); + String clientId = invocation.getArgument(1); + return tokenSupport.tokens.values().stream() + .filter(t -> userId.equals(t.getUserId()) && clientId.equals(t.getClientId())) + .collect(java.util.stream.Collectors.toList()); + }); + org.mockito.Mockito.doAnswer(invocation -> { + tokenSupport.tokens.remove((String) invocation.getArgument(0)); + return null; + }).when(tokenSupport.getTokenProvisioning()).delete(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyString()); + + // Build a public-client OAuth2Request (clientAuth = "none") + AuthorizationRequest authorizationRequest = new AuthorizationRequest(CLIENT_ID, tokenSupport.requestedAuthScopes); + authorizationRequest.setResourceIds(new HashSet<>(tokenSupport.resourceIds)); + Map azParameters = new HashMap<>(authorizationRequest.getRequestParameters()); + azParameters.put(GRANT_TYPE, GRANT_TYPE_AUTHORIZATION_CODE); + authorizationRequest.setRequestParameters(azParameters); + authorizationRequest.setExtensions(Map.of(CLIENT_AUTH_METHOD, CLIENT_AUTH_NONE)); + OAuth2Request publicOAuth2Request = authorizationRequest.createOAuth2Request(); + OAuth2Authentication publicAuthentication = new OAuth2Authentication(publicOAuth2Request, tokenSupport.defaultUserAuthentication); + + // Host A logs in (1st session) + CompositeToken accessToken1 = (CompositeToken) tokenServices.createAccessToken(publicAuthentication); + String refreshTokenValue1 = accessToken1.getRefreshToken().getValue(); + + // Host B logs in (2nd session) + CompositeToken accessToken2 = (CompositeToken) tokenServices.createAccessToken(publicAuthentication); + String refreshTokenValue2 = accessToken2.getRefreshToken().getValue(); + + long refreshTokensCount = tokenSupport.tokens.values().stream() + .filter(t -> org.cloudfoundry.identity.uaa.oauth.token.RevocableToken.TokenType.REFRESH_TOKEN.equals(t.getResponseType())).count(); + assertThat(refreshTokensCount).isEqualTo(2); + + // Host B's CLI proactively refreshes (60 s access-token TTL triggers rotation). + // This must NOT revoke Host A's session. + setupOAuth2Authentication(publicOAuth2Request); + OAuth2AccessToken refreshedToken2 = tokenServices.refreshAccessToken(refreshTokenValue2, + new TokenRequest(new HashMap<>(), CLIENT_ID, Lists.newArrayList("openid"), GRANT_TYPE_REFRESH_TOKEN)); + String rotatedRefreshTokenValue2 = refreshedToken2.getRefreshToken().getValue(); + assertThat(rotatedRefreshTokenValue2).as("rotation must produce a new token value").isNotEqualTo(refreshTokenValue2); + + refreshTokensCount = tokenSupport.tokens.values().stream() + .filter(t -> org.cloudfoundry.identity.uaa.oauth.token.RevocableToken.TokenType.REFRESH_TOKEN.equals(t.getResponseType())).count(); + assertThat(refreshTokensCount).as("rotation must not shrink the session count below the limit").isEqualTo(2); + assertThat(tokenSupport.tokens.containsKey(refreshTokenValue1)) + .as("Host A session must still be active after Host B rotates").isTrue(); + + // Host C logs in (3rd session) — must evict only Host A (the oldest session). + CompositeToken accessToken3 = (CompositeToken) tokenServices.createAccessToken(publicAuthentication); + + refreshTokensCount = tokenSupport.tokens.values().stream() + .filter(t -> org.cloudfoundry.identity.uaa.oauth.token.RevocableToken.TokenType.REFRESH_TOKEN.equals(t.getResponseType())).count(); + assertThat(refreshTokensCount).isEqualTo(2); + // For opaque-format tokens the map key is the token ID (the opaque value returned to the client), + // not the full JWT stored as the value, so we check with containsKey. + assertThat(tokenSupport.tokens.containsKey(refreshTokenValue1)) + .as("Host A's original refresh token must be evicted after 3rd login").isFalse(); + + // Host C's CLI immediately rotates its own token — must NOT evict Host B. + setupOAuth2Authentication(publicOAuth2Request); + OAuth2AccessToken refreshedToken3 = tokenServices.refreshAccessToken(accessToken3.getRefreshToken().getValue(), + new TokenRequest(new HashMap<>(), CLIENT_ID, Lists.newArrayList("openid"), GRANT_TYPE_REFRESH_TOKEN)); + + refreshTokensCount = tokenSupport.tokens.values().stream() + .filter(t -> org.cloudfoundry.identity.uaa.oauth.token.RevocableToken.TokenType.REFRESH_TOKEN.equals(t.getResponseType())).count(); + assertThat(refreshTokensCount).as("Host C's rotation must not evict Host B").isEqualTo(2); + assertThat(tokenSupport.tokens.containsKey(rotatedRefreshTokenValue2)) + .as("Host B session must still be active after Host C rotates").isTrue(); + } + private CompositeToken persistToken; private Date expiration; private TokenTestSupport tokenSupport; @@ -75,6 +229,94 @@ void teardown() { SecurityContextHolder.clearContext(); } + @Test + @DisplayName("Concurrent session limit with confidential client (tp_app / Hub UI scenario)") + void refreshWithConcurrentSessionLimitAndConfidentialClient() { + // tp_app (Hub UI) authenticates with a client secret. + // shouldRotateRefreshTokens() returns false because clientAuth is null (not "none"). + // The same refresh-token JTI is reused on every access-token refresh, so the old + // formula also caused no problem here — but we add this test to confirm our fix + // does not regress that behaviour. + + UaaClientDetails clientDetails = new UaaClientDetails(tokenSupport.defaultClient); + clientDetails.setAutoApproveScopes(singleton("true")); + tokenSupport.clientDetailsService.setClientDetailsStore(IdentityZoneHolder.get().getId(), Collections.singletonMap(CLIENT_ID, clientDetails)); + + IdentityZoneHolder.get().getConfig().getTokenPolicy().setJwtRevocable(true); + IdentityZoneHolder.get().getConfig().getTokenPolicy().setMaxSessionLimit(2); + IdentityZoneHolder.get().getConfig().getTokenPolicy().setRefreshTokenRotate(false); + + when(tokenSupport.getTokenProvisioning().getUserTokens(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> { + String userId = invocation.getArgument(0); + String clientId = invocation.getArgument(1); + return tokenSupport.tokens.values().stream() + .filter(t -> userId.equals(t.getUserId()) && clientId.equals(t.getClientId())) + .collect(java.util.stream.Collectors.toList()); + }); + org.mockito.Mockito.doAnswer(invocation -> { + tokenSupport.tokens.remove((String) invocation.getArgument(0)); + return null; + }).when(tokenSupport.getTokenProvisioning()).delete(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyString()); + + // Confidential-client request: no CLIENT_AUTH_NONE extension → clientAuth = null + AuthorizationRequest authorizationRequest = new AuthorizationRequest(CLIENT_ID, tokenSupport.requestedAuthScopes); + authorizationRequest.setResourceIds(new HashSet<>(tokenSupport.resourceIds)); + Map azParameters = new HashMap<>(authorizationRequest.getRequestParameters()); + azParameters.put(GRANT_TYPE, GRANT_TYPE_AUTHORIZATION_CODE); + authorizationRequest.setRequestParameters(azParameters); + OAuth2Request confidentialOAuth2Request = authorizationRequest.createOAuth2Request(); + OAuth2Authentication confidentialAuthentication = new OAuth2Authentication(confidentialOAuth2Request, tokenSupport.defaultUserAuthentication); + + // Session 1 (browser tab on machine A) + CompositeToken accessToken1 = (CompositeToken) tokenServices.createAccessToken(confidentialAuthentication); + String refreshTokenValue1 = accessToken1.getRefreshToken().getValue(); + + // Session 2 (browser tab on machine B) + CompositeToken accessToken2 = (CompositeToken) tokenServices.createAccessToken(confidentialAuthentication); + String refreshTokenValue2 = accessToken2.getRefreshToken().getValue(); + + long refreshTokensCount = tokenSupport.tokens.values().stream() + .filter(t -> org.cloudfoundry.identity.uaa.oauth.token.RevocableToken.TokenType.REFRESH_TOKEN.equals(t.getResponseType())).count(); + assertThat(refreshTokensCount).isEqualTo(2); + + // Session 2 refreshes its access token (no rotation: same JTI is reused). + // This must NOT evict session 1. + setupOAuth2Authentication(confidentialOAuth2Request); + OAuth2AccessToken refreshedToken2 = tokenServices.refreshAccessToken(refreshTokenValue2, + new TokenRequest(new HashMap<>(), CLIENT_ID, Lists.newArrayList("openid"), GRANT_TYPE_REFRESH_TOKEN)); + assertThat(refreshedToken2.getRefreshToken().getValue()) + .as("confidential client must reuse the same refresh token (no rotation)").isEqualTo(refreshTokenValue2); + + refreshTokensCount = tokenSupport.tokens.values().stream() + .filter(t -> org.cloudfoundry.identity.uaa.oauth.token.RevocableToken.TokenType.REFRESH_TOKEN.equals(t.getResponseType())).count(); + assertThat(refreshTokensCount).as("refresh without rotation must not change the session count").isEqualTo(2); + assertThat(tokenSupport.tokens.containsKey(refreshTokenValue1)) + .as("session 1 must still be active after session 2 refreshes").isTrue(); + + // Session 3 logs in — must evict only session 1 (the oldest). + CompositeToken accessToken3 = (CompositeToken) tokenServices.createAccessToken(confidentialAuthentication); + + refreshTokensCount = tokenSupport.tokens.values().stream() + .filter(t -> org.cloudfoundry.identity.uaa.oauth.token.RevocableToken.TokenType.REFRESH_TOKEN.equals(t.getResponseType())).count(); + assertThat(refreshTokensCount).as("3rd login must keep the count at the limit").isEqualTo(2); + assertThat(tokenSupport.tokens.containsKey(refreshTokenValue1)) + .as("session 1 (oldest) must be evicted after 3rd login").isFalse(); + assertThat(tokenSupport.tokens.containsKey(refreshTokenValue2)) + .as("session 2 must still be active after 3rd login").isTrue(); + + // Session 3 refreshes (still no rotation for confidential client). + // Must NOT evict session 2. + setupOAuth2Authentication(confidentialOAuth2Request); + tokenServices.refreshAccessToken(accessToken3.getRefreshToken().getValue(), + new TokenRequest(new HashMap<>(), CLIENT_ID, Lists.newArrayList("openid"), GRANT_TYPE_REFRESH_TOKEN)); + + refreshTokensCount = tokenSupport.tokens.values().stream() + .filter(t -> org.cloudfoundry.identity.uaa.oauth.token.RevocableToken.TokenType.REFRESH_TOKEN.equals(t.getResponseType())).count(); + assertThat(refreshTokensCount).as("session 3 refresh must not evict session 2").isEqualTo(2); + assertThat(tokenSupport.tokens.containsKey(refreshTokenValue2)) + .as("session 2 must still be active after session 3 refreshes").isTrue(); + } + @Test @DisplayName("Refresh Token with rotation") void refreshRotation() { diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneEndpointDocs.java index 720da77088b..32c4d164a22 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneEndpointDocs.java @@ -67,7 +67,10 @@ class IdentityZoneEndpointDocs extends EndpointDocs { private static final String ACCESS_TOKEN_VALIDITY_DESC = "Time in seconds between when a access token is issued and when it expires. Defaults to global `accessTokenValidity`"; private static final String REFRESH_TOKEN_VALIDITY_DESC = "Time in seconds between when a refresh token is issued and when it expires. Defaults to global `refreshTokenValidity`"; private static final String REFRESH_TOKEN_FORMAT = "The format for the refresh token. Allowed values are `jwt`, `opaque`. Defaults to `opaque`."; - private static final String REFRESH_TOKEN_UNIQUE = "If true, uaa will only issue one refresh token per client_id/user_id combination. Defaults to `false`."; + private static final String REFRESH_TOKEN_UNIQUE = "Maximum number of concurrent active refresh tokens (sessions) per user/client pair. " + + "Accepts a boolean or an integer for backwards compatibility: `true` is equivalent to `1` (one session), " + + "`false` or any non-positive value means no limit. A positive integer N allows up to N concurrent sessions. " + + "Only enforced when `jwtRevocable` is also `true`. Defaults to `false` (no limit)."; private static final String REFRESH_TOKEN_ROTATE = "If true, uaa will issue a new refresh token value in grant type refresh_token. Defaults to `false`."; private static final String JWT_REVOCABLE_DESC = "Set to true if JWT tokens should be stored in the token store, and thus made individually revocable. Opaque tokens are always stored and revocable."; private static final String ENTITY_ID_DESC = "Unique ID of the SAML2 entity"; @@ -236,7 +239,7 @@ void createIdentityZone() throws Exception { fieldWithPath("config.tokenPolicy.accessTokenValidity").description(ACCESS_TOKEN_VALIDITY_DESC).attributes(key("constraints").value("Optional")), fieldWithPath("config.tokenPolicy.refreshTokenValidity").description(REFRESH_TOKEN_VALIDITY_DESC).attributes(key("constraints").value("Optional")), fieldWithPath("config.tokenPolicy.jwtRevocable").type(BOOLEAN).description(JWT_REVOCABLE_DESC).attributes(key("constraints").value("Optional")), - fieldWithPath("config.tokenPolicy.refreshTokenUnique").type(BOOLEAN).description(REFRESH_TOKEN_UNIQUE).attributes(key("constraints").value("Optional")), + fieldWithPath("config.tokenPolicy.refreshTokenUnique").type(VARIES).description(REFRESH_TOKEN_UNIQUE).attributes(key("constraints").value("Optional")), fieldWithPath("config.tokenPolicy.refreshTokenRotate").type(BOOLEAN).description(REFRESH_TOKEN_ROTATE).attributes(key("constraints").value("Optional")), fieldWithPath("config.tokenPolicy.refreshTokenFormat").type(STRING).description(REFRESH_TOKEN_FORMAT).attributes(key("constraints").value("Optional")), @@ -385,7 +388,7 @@ void getAllIdentityZones() throws Exception { fieldWithPath("[].config.tokenPolicy.accessTokenValidity").description(ACCESS_TOKEN_VALIDITY_DESC), fieldWithPath("[].config.tokenPolicy.refreshTokenValidity").description(REFRESH_TOKEN_VALIDITY_DESC), fieldWithPath("[].config.tokenPolicy.jwtRevocable").type(BOOLEAN).description(JWT_REVOCABLE_DESC), - fieldWithPath("[].config.tokenPolicy.refreshTokenUnique").type(BOOLEAN).description(REFRESH_TOKEN_UNIQUE).attributes(key("constraints").value("Optional")), + fieldWithPath("[].config.tokenPolicy.refreshTokenUnique").type(VARIES).description(REFRESH_TOKEN_UNIQUE).attributes(key("constraints").value("Optional")), fieldWithPath("[].config.tokenPolicy.refreshTokenRotate").type(BOOLEAN).description(REFRESH_TOKEN_ROTATE).attributes(key("constraints").value("Optional")), fieldWithPath("[].config.tokenPolicy.refreshTokenFormat").type(STRING).description(REFRESH_TOKEN_FORMAT).attributes(key("constraints").value("Optional")), @@ -533,7 +536,7 @@ void updateIdentityZone() throws Exception { fieldWithPath("config.tokenPolicy.accessTokenValidity").description(ACCESS_TOKEN_VALIDITY_DESC).attributes(key("constraints").value("Optional")), fieldWithPath("config.tokenPolicy.refreshTokenValidity").description(REFRESH_TOKEN_VALIDITY_DESC).attributes(key("constraints").value("Optional")), fieldWithPath("config.tokenPolicy.jwtRevocable").type(BOOLEAN).description(JWT_REVOCABLE_DESC).attributes(key("constraints").value("Optional")), - fieldWithPath("config.tokenPolicy.refreshTokenUnique").type(BOOLEAN).description(REFRESH_TOKEN_UNIQUE).attributes(key("constraints").value("Optional")), + fieldWithPath("config.tokenPolicy.refreshTokenUnique").type(VARIES).description(REFRESH_TOKEN_UNIQUE).attributes(key("constraints").value("Optional")), fieldWithPath("config.tokenPolicy.refreshTokenRotate").type(BOOLEAN).description(REFRESH_TOKEN_ROTATE).attributes(key("constraints").value("Optional")), fieldWithPath("config.tokenPolicy.refreshTokenFormat").type(STRING).description(REFRESH_TOKEN_FORMAT).attributes(key("constraints").value("Optional")), @@ -717,7 +720,7 @@ private Snippet getResponseFields() { fieldWithPath("config.tokenPolicy.accessTokenValidity").description(ACCESS_TOKEN_VALIDITY_DESC), fieldWithPath("config.tokenPolicy.refreshTokenValidity").description(REFRESH_TOKEN_VALIDITY_DESC), fieldWithPath("config.tokenPolicy.jwtRevocable").type(BOOLEAN).description(JWT_REVOCABLE_DESC).attributes(key("constraints").value("Optional")), - fieldWithPath("config.tokenPolicy.refreshTokenUnique").type(BOOLEAN).description(REFRESH_TOKEN_UNIQUE).attributes(key("constraints").value("Optional")), + fieldWithPath("config.tokenPolicy.refreshTokenUnique").type(VARIES).description(REFRESH_TOKEN_UNIQUE).attributes(key("constraints").value("Optional")), fieldWithPath("config.tokenPolicy.refreshTokenRotate").type(BOOLEAN).description(REFRESH_TOKEN_ROTATE).attributes(key("constraints").value("Optional")), fieldWithPath("config.tokenPolicy.refreshTokenFormat").type(STRING).description(REFRESH_TOKEN_FORMAT).attributes(key("constraints").value("Optional")), diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneEndpointsMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneEndpointsMockMvcTests.java index 1b65af0b11a..727e22b1986 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneEndpointsMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneEndpointsMockMvcTests.java @@ -1025,7 +1025,7 @@ void createZoneWithRefreshTokenConfig() throws Exception { .contentType(APPLICATION_JSON) .content(JsonUtils.writeValueAsString(identityZone))) .andExpect(status().isCreated()) - .andExpect(jsonPath("$.config.tokenPolicy.refreshTokenUnique").value(true)) + .andExpect(jsonPath("$.config.tokenPolicy.refreshTokenUnique").value(1)) .andExpect(jsonPath("$.config.tokenPolicy.refreshTokenRotate").value(true)) .andExpect(jsonPath("$.config.tokenPolicy.refreshTokenFormat").value(OPAQUE.getStringValue())); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneEndpointsMockMvcZonePathTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneEndpointsMockMvcZonePathTests.java index 8cee8b89ca3..e01418d7947 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneEndpointsMockMvcZonePathTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneEndpointsMockMvcZonePathTests.java @@ -1033,7 +1033,7 @@ void createZoneWithRefreshTokenConfig() throws Exception { .contentType(APPLICATION_JSON) .content(JsonUtils.writeValueAsString(identityZone))) .andExpect(status().isCreated()) - .andExpect(jsonPath("$.config.tokenPolicy.refreshTokenUnique").value(true)) + .andExpect(jsonPath("$.config.tokenPolicy.refreshTokenUnique").value(1)) .andExpect(jsonPath("$.config.tokenPolicy.refreshTokenRotate").value(true)) .andExpect(jsonPath("$.config.tokenPolicy.refreshTokenFormat").value(OPAQUE.getStringValue()));