Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2RequestFactory;
import org.cloudfoundry.identity.uaa.oauth.provider.error.OAuth2AuthenticationEntryPoint;
import org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants;
import org.cloudfoundry.identity.uaa.oauth.token.RevocableToken;
import org.cloudfoundry.identity.uaa.oauth.token.RevocableTokenProvisioning;
import org.cloudfoundry.identity.uaa.provider.IdentityProvider;
import org.cloudfoundry.identity.uaa.provider.OIDCIdentityProviderDefinition;
import org.cloudfoundry.identity.uaa.provider.oauth.ExternalOAuthAuthenticationManager;
Expand All @@ -40,6 +42,9 @@
import org.cloudfoundry.identity.uaa.util.SessionUtils;
import org.cloudfoundry.identity.uaa.util.UaaSecurityContextUtils;
import org.cloudfoundry.identity.uaa.util.UaaStringUtils;
import org.cloudfoundry.identity.uaa.util.UaaTokenUtils;
import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.security.authentication.AuthenticationDetailsSource;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
Expand Down Expand Up @@ -102,9 +107,11 @@ public class BackwardsCompatibleTokenEndpointAuthenticationFilter implements Fil

private final AntPathRequestMatcher requestMatcher;

private final RevocableTokenProvisioning revocableTokenProvisioning;

public BackwardsCompatibleTokenEndpointAuthenticationFilter(AuthenticationManager authenticationManager,
OAuth2RequestFactory oAuth2RequestFactory) {
this(DEFAULT_FILTER_PROCESSES_URI, authenticationManager, oAuth2RequestFactory, null, null, null);
this(DEFAULT_FILTER_PROCESSES_URI, authenticationManager, oAuth2RequestFactory, null, null, null, null);
}

public BackwardsCompatibleTokenEndpointAuthenticationFilter(
Expand All @@ -119,7 +126,8 @@ public BackwardsCompatibleTokenEndpointAuthenticationFilter(
oAuth2RequestFactory,
saml2BearerGrantAuthenticationConverter,
externalOAuthAuthenticationManager,
tokenExchangeAuthenticationManager
tokenExchangeAuthenticationManager,
null
);
}

Expand All @@ -129,7 +137,8 @@ public BackwardsCompatibleTokenEndpointAuthenticationFilter(
OAuth2RequestFactory oAuth2RequestFactory,
Saml2BearerGrantAuthenticationConverter saml2BearerGrantAuthenticationConverter,
ExternalOAuthAuthenticationManager externalOAuthAuthenticationManager,
AuthenticationManager tokenExchangeAuthenticationManager) {
AuthenticationManager tokenExchangeAuthenticationManager,
RevocableTokenProvisioning revocableTokenProvisioning) {
super();
Assert.isTrue(requestMatcherUrl.contains("{registrationId}"),
"filterProcessesUrl must contain a {registrationId} match variable");
Expand All @@ -140,6 +149,7 @@ public BackwardsCompatibleTokenEndpointAuthenticationFilter(
this.saml2BearerGrantAuthenticationConverter = saml2BearerGrantAuthenticationConverter;
this.externalOAuthAuthenticationManager = externalOAuthAuthenticationManager;
this.tokenExchangeAuthenticationManager = tokenExchangeAuthenticationManager;
this.revocableTokenProvisioning = revocableTokenProvisioning;
}

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
Expand Down Expand Up @@ -318,6 +328,19 @@ protected TokenExchangeData getSubjectToken(HttpServletRequest request) {
String idToken = null;
String accessToken = null;
if (TOKEN_TYPE_ACCESS.equals(subjectTokenType)) {
// If the access token is opaque (not a JWT), resolve it to the backing JWT from the
// UAA revocable token store before passing it downstream. If not found in the store,
// leave it unchanged - downstream will handle or reject it.
if (!UaaTokenUtils.isJwtToken(subjectToken)
&& revocableTokenProvisioning != null) {
try {
RevocableToken revocableToken = revocableTokenProvisioning.retrieve(
subjectToken, IdentityZoneHolder.get().getId());
subjectToken = revocableToken.getValue();
} catch (EmptyResultDataAccessException e) {
log.debug("Opaque subject_token not found in revocable token store, passing through unchanged");
}
}
accessToken = subjectToken;
} else if (TOKEN_TYPE_ID.equals(subjectTokenType)) {
idToken = subjectToken;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,14 @@ FilterRegistrationBean<BackwardsCompatibleTokenEndpointAuthenticationFilter> tok
ExternalOAuthAuthenticationManager externalOAuthAuthenticationManager,
@Qualifier("tokenExchangeAuthenticationManager") AuthenticationManager tokenExchangeAuthenticationManager,
AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource,
AuthenticationEntryPoint basicAuthenticationEntryPoint
AuthenticationEntryPoint basicAuthenticationEntryPoint,
@Qualifier("revocableTokenProvisioning") RevocableTokenProvisioning revocableTokenProvisioning
) {

BackwardsCompatibleTokenEndpointAuthenticationFilter filter =
new BackwardsCompatibleTokenEndpointAuthenticationFilter("/oauth/token/alias/{registrationId}",
passwordGrantAuthenticationManager, authorizationRequestManager, samlBearerGrantAuthenticationProvider,
externalOAuthAuthenticationManager, tokenExchangeAuthenticationManager);
externalOAuthAuthenticationManager, tokenExchangeAuthenticationManager, revocableTokenProvisioning);
filter.setAuthenticationDetailsSource(authenticationDetailsSource);
filter.setAuthenticationEntryPoint(basicAuthenticationEntryPoint);
FilterRegistrationBean<BackwardsCompatibleTokenEndpointAuthenticationFilter> bean = new FilterRegistrationBean<>(filter);
Expand Down Expand Up @@ -96,8 +97,9 @@ public TokenExchangeGranter tokenExchangeGranter(
@Qualifier("oauth2TokenGranter") CompositeTokenGranter compositeTokenGranter,
@Qualifier("tokenServices") AuthorizationServerTokenServices tokenServices,
@Qualifier("jdbcClientDetailsService") MultitenantClientServices clientDetailsService,
@Qualifier("authorizationRequestManager") OAuth2RequestFactory requestFactory) {
TokenExchangeGranter tokenExchangeGranter = new TokenExchangeGranter(tokenServices, clientDetailsService, requestFactory);
@Qualifier("authorizationRequestManager") OAuth2RequestFactory requestFactory,
@Qualifier("revocableTokenProvisioning") RevocableTokenProvisioning revocableTokenProvisioning) {
TokenExchangeGranter tokenExchangeGranter = new TokenExchangeGranter(tokenServices, clientDetailsService, requestFactory, revocableTokenProvisioning);
compositeTokenGranter.addTokenGranter(tokenExchangeGranter);
return tokenExchangeGranter;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
import org.cloudfoundry.identity.uaa.provider.ClientRegistrationException;
import org.cloudfoundry.identity.uaa.provider.oauth.TokenActor;
import org.cloudfoundry.identity.uaa.security.beans.DefaultSecurityContextAccessor;
import org.cloudfoundry.identity.uaa.util.UaaTokenUtils;
import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder;
import org.cloudfoundry.identity.uaa.zone.MultitenantClientServices;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

Expand All @@ -31,13 +34,16 @@ public class TokenExchangeGranter extends AbstractTokenGranter {
final DefaultSecurityContextAccessor defaultSecurityContextAccessor;

private final MultitenantClientServices clientDetailsService;
private final RevocableTokenProvisioning revocableTokenProvisioning;

public TokenExchangeGranter(AuthorizationServerTokenServices tokenServices,
MultitenantClientServices clientDetailsService,
OAuth2RequestFactory requestFactory) {
OAuth2RequestFactory requestFactory,
RevocableTokenProvisioning revocableTokenProvisioning) {
super(tokenServices, clientDetailsService, requestFactory, GRANT_TYPE_TOKEN_EXCHANGE);
defaultSecurityContextAccessor = new DefaultSecurityContextAccessor();
this.clientDetailsService = clientDetailsService;
this.revocableTokenProvisioning = revocableTokenProvisioning;
}

protected Authentication validateRequest(TokenRequest request) {
Expand Down Expand Up @@ -129,6 +135,14 @@ public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {

protected TokenActor getTokenActor(TokenRequest tokenRequest) {
String subjectToken = tokenRequest.getRequestParameters().get("subject_token");
if (!UaaTokenUtils.isJwtToken(subjectToken)) {
try {
RevocableToken revocableToken = revocableTokenProvisioning.retrieve(subjectToken, IdentityZoneHolder.get().getId());
subjectToken = revocableToken.getValue();
} catch (EmptyResultDataAccessException e) {
throw new InvalidGrantException("subject_token is not a JWT and is not found in the revocable tokens");
Comment thread
strehle marked this conversation as resolved.
Outdated
}
}
JWTClaimsSet claims = JwtHelper.decode(subjectToken).getClaimSet();
String clientId = tokenRequest.getClientId();
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
Comment thread
strehle marked this conversation as resolved.
Outdated
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.lang3.ObjectUtils;
Expand Down Expand Up @@ -240,6 +241,7 @@ private IdentityProvider buildInternalUaaIdpConfig(String issuer, String originK
protected AuthenticationData getExternalAuthenticationDetails(final Authentication authentication) {
final ExternalOAuthCodeToken codeToken = (ExternalOAuthCodeToken) authentication;


Comment thread
strehle marked this conversation as resolved.
Outdated
IdentityProvider provider = null;
if (!hasLength(codeToken.getOrigin())) {
provider = resolveOriginProvider(codeToken.getIdToken());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@
import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2RequestFactory;
import org.cloudfoundry.identity.uaa.oauth.provider.TokenRequest;
import org.cloudfoundry.identity.uaa.oauth.provider.token.AuthorizationServerTokenServices;
import org.cloudfoundry.identity.uaa.provider.oauth.TokenActor;
import org.cloudfoundry.identity.uaa.user.UaaUser;
import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder;
import org.cloudfoundry.identity.uaa.zone.MultitenantClientServices;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.security.core.context.SecurityContextHolder;

import java.util.Collections;
Expand All @@ -33,6 +35,7 @@
import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.TOKEN_TYPE_ACCESS;
import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.TOKEN_TYPE_ID;
import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.TOKEN_TYPE_REFRESH;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
Comment thread
strehle marked this conversation as resolved.
import static org.mockito.ArgumentMatchers.same;
Expand All @@ -52,14 +55,16 @@ class TokenExchangeGranterTests {
private AuthorizationServerTokenServices tokenServices;
private MultitenantClientServices clientDetailsService;
private OAuth2RequestFactory requestFactory;
private RevocableTokenProvisioning revocableTokenProvisioning;
private Map<String, String> requestParameters;

@BeforeEach
void setUp() {
tokenServices = mock(AuthorizationServerTokenServices.class);
clientDetailsService = mock(MultitenantClientServices.class);
requestFactory = mock(OAuth2RequestFactory.class);
granter = spy(new TokenExchangeGranter(tokenServices, clientDetailsService, requestFactory));
revocableTokenProvisioning = mock(RevocableTokenProvisioning.class);
granter = spy(new TokenExchangeGranter(tokenServices, clientDetailsService, requestFactory, revocableTokenProvisioning));
tokenRequest = new TokenRequest(Collections.emptyMap(), "client_ID", Collections.emptySet(), GRANT_TYPE_TOKEN_EXCHANGE);

authentication = mock(UaaOauth2Authentication.class);
Expand Down Expand Up @@ -211,4 +216,43 @@ void invalid_requested_token_type() {
.isInstanceOf(InvalidGrantException.class)
.hasMessageContaining("Invalid requested token type, only urn:ietf:params:oauth:token-type:access_token is supported");
}

@Test
void opaque_subject_token_is_resolved_from_db() {
// A real JWT signed with a trivial key — three dot-separated parts
String jwtValue = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIiwiaXNzIjoiaHR0cHM6Ly91YWEuZXhhbXBsZS5jb20iLCJ1c2VyX25hbWUiOiJqb2huIiwidXNlcl9pZCI6InVzZXIxMjMiLCJvcmlnaW4iOiJ1YWEifQ.signature";
Comment on lines +221 to +222
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JWT test fixture value for jwtValue is not guaranteed to be parseable by Nimbus JWTParser.parse(...) because the signature segment ("signature") is not valid Base64URL (length mod 4 = 1). This can cause JwtHelper.decode(jwtValue) to throw InvalidTokenException and make the test flaky/fail. Use a fully valid 3-part Base64URL token (all segments Base64URL-decodable) or generate one via existing helpers (e.g., UaaTokenUtils.constructToken(...) / JwtHelper.encode(...)) so the parser reliably accepts it.

Suggested change
// A real JWT signed with a trivial key — three dot-separated parts
String jwtValue = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIiwiaXNzIjoiaHR0cHM6Ly91YWEuZXhhbXBsZS5jb20iLCJ1c2VyX25hbWUiOiJqb2huIiwidXNlcl9pZCI6InVzZXIxMjMiLCJvcmlnaW4iOiJ1YWEifQ.signature";
// A parseable JWT fixture with three Base64URL-encoded parts
String jwtValue = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIiwiaXNzIjoiaHR0cHM6Ly91YWEuZXhhbXBsZS5jb20iLCJ1c2VyX25hbWUiOiJqb2huIiwidXNlcl9pZCI6InVzZXIxMjMiLCJvcmlnaW4iOiJ1YWEifQ.c2lnbmF0dXJl";

Copilot uses AI. Check for mistakes.
String opaqueTokenId = "opaque-token-id-no-dots";

RevocableToken revocableToken = mock(RevocableToken.class);
when(revocableToken.getValue()).thenReturn(jwtValue);
when(revocableTokenProvisioning.retrieve(eq(opaqueTokenId), anyString())).thenReturn(revocableToken);

requestParameters.put("subject_token", opaqueTokenId);
requestParameters.put("subject_token_type", TOKEN_TYPE_ACCESS);
tokenRequest.setRequestParameters(requestParameters);

// getTokenActor will call revocableTokenProvisioning.retrieve() for the opaque token
// then decode the backing JWT — verify the provisioning was called
try {
Comment thread
strehle marked this conversation as resolved.
Outdated
granter.getTokenActor(tokenRequest);
} catch (Exception ignored) {
// JWT decode may fail on a fake signature; what matters is the DB lookup occurred
}
Comment thread
strehle marked this conversation as resolved.
Outdated
verify(revocableTokenProvisioning, times(1)).retrieve(eq(opaqueTokenId), anyString());
}

@Test
void opaque_subject_token_not_found_throws_invalid_grant() {
String opaqueTokenId = "expired-or-missing-opaque-token";
when(revocableTokenProvisioning.retrieve(eq(opaqueTokenId), anyString()))
.thenThrow(new EmptyResultDataAccessException(1));

requestParameters.put("subject_token", opaqueTokenId);
requestParameters.put("subject_token_type", TOKEN_TYPE_ACCESS);
tokenRequest.setRequestParameters(requestParameters);

assertThatThrownBy(() -> granter.getTokenActor(tokenRequest))
.isInstanceOf(InvalidGrantException.class)
.hasMessageContaining("subject_token is not a JWT and is not found in the revocable tokens");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import org.cloudfoundry.identity.uaa.oauth.jwt.Jwt;
import org.cloudfoundry.identity.uaa.oauth.jwt.JwtHelper;
import org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants;
import org.cloudfoundry.identity.uaa.oauth.token.TokenConstants;
import org.cloudfoundry.identity.uaa.util.JsonUtils;
import org.cloudfoundry.identity.uaa.zone.MultitenantJdbcClientDetailsService;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -126,6 +127,65 @@ void token_exchange_three_idps_using_access_token() throws Exception {
assertThat(claims.get("origin")).isEqualTo(workerServer.identityProvider().getOriginKey());
}

@Test
void token_exchange_three_idps_using_opaque_access_token() throws Exception {

ThreeWayUAASetup multiAuthSetup = getThreeWayUaaSetUp();
AuthorizationServer thirdParty = multiAuthSetup.thirdPartyIdp();
AuthorizationServer workerServer = multiAuthSetup.workerServer();

// First, perform a token exchange at the worker server zone requesting an opaque token.
// This stores the opaque access token in the worker server's revocable token store.
String controlServerAccessToken = (String) multiAuthSetup.controlServerTokens().get("access_token");
ResultActions firstExchangeResult = performTokenExchangeGrant(
workerServer.zone().getIdentityZone(),
controlServerAccessToken,
TOKEN_TYPE_ACCESS,
TOKEN_TYPE_ACCESS,
null,
null,
workerServer.client(),
ClientAuthType.FORM,
null,
TokenConstants.TokenFormat.OPAQUE.getStringValue()
);
firstExchangeResult.andExpect(status().isOk());
Map<String, Object> firstExchangeTokens = JsonUtils.readValueAsMap(firstExchangeResult.andReturn().getResponse().getContentAsString());
String opaqueAccessToken = (String) firstExchangeTokens.get("access_token");

// The opaque token must not be a JWT (no dots separating header.payload.signature)
assertThat(opaqueAccessToken).doesNotContain(".");

// Now use the opaque access token (stored in the worker zone) as subject_token
// in a second token exchange – this exercises the opaque→JWT resolution path
ResultActions tokenExchangeResult = performTokenExchangeGrantForJWT(
workerServer.zone().getIdentityZone(),
opaqueAccessToken,
TOKEN_TYPE_ACCESS,
TOKEN_TYPE_ACCESS,
null,
null,
workerServer.client(),
ClientAuthType.FORM,
null
);

tokenExchangeResult
.andExpect(status().isOk())
.andExpect(jsonPath(".access_token").isNotEmpty());
Map<String, Object> tokens = JsonUtils.readValueAsMap(tokenExchangeResult.andReturn().getResponse().getContentAsString());

assertThat(tokens.get(ISSUED_TOKEN_TYPE)).isEqualTo(TOKEN_TYPE_ACCESS);
assertThat(tokens.get(TOKEN_TYPE)).isEqualTo(BEARER_TYPE.toLowerCase());

Jwt tokenClaims = JwtHelper.decode((String) tokens.get("access_token"));
Map<String, Object> claims = JsonUtils.readValueAsMap(tokenClaims.getClaims());

assertThat(claims.get("user_name")).isEqualTo(thirdParty.user().getUserName());
assertThat(claims.get("email")).isEqualTo(thirdParty.user().getEmails().get(0).getValue());
assertThat(claims.get("origin")).isEqualTo(workerServer.identityProvider().getOriginKey());
}

@Test
void token_exchange_three_idps_using_client_assertion() throws Exception {

Expand Down
Loading
Loading