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