Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions docs/UAA-Configuration-Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ or `$CLOUDFOUNDRY_CONFIG_PATH/uaa.yml`.
| <a href="#jwttokenpolicyglobalaccesstokenvalidityseconds"><img src="images/click-me.png" width="14" height="14"/></a> `jwt.token.policy.global.accessTokenValiditySeconds` | `43200`| Global access token validity (s)|
| <a href="#jwttokenpolicyglobalrefreshtokenvalidityseconds"><img src="images/click-me.png" width="14" height="14"/></a> `jwt.token.policy.global.refreshTokenValiditySeconds` | `2592000`| Global refresh token validity (s)|
| <a href="#jwttokenrefreshformat"><img src="images/click-me.png" width="14" height="14"/></a> `jwt.token.refresh.format` | `opaque`| Refresh token format|
| <a href="#jwttokenrefreshunique"><img src="images/click-me.png" width="14" height="14"/></a> `jwt.token.refresh.unique` | `false`| Unique refresh tokens|
| <a href="#jwttokenrefreshunique"><img src="images/click-me.png" width="14" height="14"/></a> `jwt.token.refresh.unique` | `false`| Max concurrent refresh-token sessions per user/client|
| <a href="#jwttokenrefreshrotate"><img src="images/click-me.png" width="14" height="14"/></a> `jwt.token.refresh.rotate` | `false`| Rotate refresh tokens|
| <a href="#jwttokenrefreshrestrict_grant"><img src="images/click-me.png" width="14" height="14"/></a> `jwt.token.refresh.restrict_grant` | —| Restrict refresh token grant|
| <a href="#jwttokenclaimsexclude"><img src="images/click-me.png" width="14" height="14"/></a> `jwt.token.claims.exclude` | `[]`| Claims excluded from tokens|
Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -126,12 +127,47 @@ public void setKeys(Map<String, String> 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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"accessTokenValidity": -1,
"refreshTokenValidity": -1,
"jwtRevocable": false,
"refreshTokenUnique": false,
"refreshTokenUnique": -1,
"refreshTokenFormat": "jwt",
"activeKeyId": "key-id-1",
"keys" : {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -780,23 +786,21 @@ 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)
.setExpiresAt(refreshToken.getExpiration().getTime())
.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());
}
Expand Down Expand Up @@ -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<RevocableToken> 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<RevocableToken> 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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;
}
Expand Down
Loading