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
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,9 @@ public TokenExchangeGranter tokenExchangeGranter(
@Qualifier("tokenServices") AuthorizationServerTokenServices tokenServices,
@Qualifier("jdbcClientDetailsService") MultitenantClientServices clientDetailsService,
@Qualifier("authorizationRequestManager") OAuth2RequestFactory requestFactory,
@Qualifier("revocableTokenProvisioning") RevocableTokenProvisioning revocableTokenProvisioning) {
TokenExchangeGranter tokenExchangeGranter = new TokenExchangeGranter(tokenServices, clientDetailsService, requestFactory, revocableTokenProvisioning);
@Qualifier("revocableTokenProvisioning") RevocableTokenProvisioning revocableTokenProvisioning,
@Qualifier("externalOAuthAuthenticationManager") ExternalOAuthAuthenticationManager externalOAuthAuthenticationManager) {
TokenExchangeGranter tokenExchangeGranter = new TokenExchangeGranter(tokenServices, clientDetailsService, requestFactory, revocableTokenProvisioning, externalOAuthAuthenticationManager);
compositeTokenGranter.addTokenGranter(tokenExchangeGranter);
return tokenExchangeGranter;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import com.nimbusds.jwt.JWTClaimsSet;
import org.cloudfoundry.identity.uaa.oauth.common.OAuth2AccessToken;
import org.cloudfoundry.identity.uaa.oauth.common.exceptions.InvalidGrantException;
import org.cloudfoundry.identity.uaa.oauth.jwt.JwtHelper;
import org.cloudfoundry.identity.uaa.oauth.common.exceptions.InvalidTokenException;
import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetails;
import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2Authentication;
import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2Request;
Expand All @@ -12,13 +12,15 @@
import org.cloudfoundry.identity.uaa.oauth.provider.token.AbstractTokenGranter;
import org.cloudfoundry.identity.uaa.oauth.provider.token.AuthorizationServerTokenServices;
import org.cloudfoundry.identity.uaa.provider.ClientRegistrationException;
import org.cloudfoundry.identity.uaa.provider.oauth.ExternalOAuthAuthenticationManager;
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.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;

import java.text.ParseException;
Expand All @@ -35,15 +37,18 @@ public class TokenExchangeGranter extends AbstractTokenGranter {

private final MultitenantClientServices clientDetailsService;
private final RevocableTokenProvisioning revocableTokenProvisioning;
private final ExternalOAuthAuthenticationManager externalOAuthAuthenticationManager;

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

protected Authentication validateRequest(TokenRequest request) {
Expand Down Expand Up @@ -143,7 +148,13 @@ protected TokenActor getTokenActor(TokenRequest tokenRequest) {
throw new InvalidGrantException("Invalid subject_token: not a JWT and not found in the revocable token store");
}
}
JWTClaimsSet claims = JwtHelper.decode(subjectToken).getClaimSet();
JWTClaimsSet claims;
try {
claims = externalOAuthAuthenticationManager.verifySubjectToken(subjectToken);
} catch (AuthenticationException | InvalidTokenException e) {
logger.debug("subject_token signature verification failed", e);
throw new InvalidGrantException("Invalid subject_token");
}
Comment thread
Copilot marked this conversation as resolved.
Comment on lines +151 to +157
String clientId = tokenRequest.getClientId();
try {
return new TokenActor(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jwt.JWTClaimsSet;
import jakarta.servlet.http.HttpServletRequest;
import lombok.Data;
import lombok.EqualsAndHashCode;
Expand Down Expand Up @@ -719,6 +720,26 @@ protected String hmacSignAndEncode(String data, String key) {
}
}

/**
* Resolves the registered identity provider from the token's {@code iss} claim,
* verifies the JWT signature against that provider's keys, and checks expiry.
*
* <p>Intended for callers (e.g. {@code TokenExchangeGranter}) that need independent
* verification of a {@code subject_token} without going through the full authentication
* pipeline.
*
* @param idToken the JWT to verify
* @return the verified and expiry-checked {@link JWTClaimsSet}
* @throws InsufficientAuthenticationException if the issuer cannot be mapped to a registered provider
* @throws InvalidTokenException if the signature or expiry checks fail
*/
public JWTClaimsSet verifySubjectToken(String idToken) {
IdentityProvider provider = resolveOriginProvider(idToken);
AbstractExternalOAuthIdentityProviderDefinition config =
(AbstractExternalOAuthIdentityProviderDefinition) provider.getConfig();
return validateToken(idToken, config).getJwt().getClaimSet();
}
Comment on lines +736 to +741

private JwtTokenSignedByThisUAA validateToken(String idToken, AbstractExternalOAuthIdentityProviderDefinition config) {
log.debug("Validating id_token");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,26 @@
import org.cloudfoundry.identity.uaa.oauth.TokenTestSupport;
import org.cloudfoundry.identity.uaa.oauth.UaaOauth2Authentication;
import org.cloudfoundry.identity.uaa.oauth.common.exceptions.InvalidGrantException;
import org.cloudfoundry.identity.uaa.oauth.common.exceptions.InvalidTokenException;
import org.cloudfoundry.identity.uaa.oauth.common.util.OAuth2Utils;
import org.cloudfoundry.identity.uaa.oauth.jwt.JwtHelper;
import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetails;
import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2Authentication;
import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2Request;
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.ExternalOAuthAuthenticationManager;
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.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;

import java.util.Collections;
Expand All @@ -40,6 +45,8 @@
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
Expand All @@ -58,6 +65,7 @@ class TokenExchangeGranterTests {
private MultitenantClientServices clientDetailsService;
private OAuth2RequestFactory requestFactory;
private RevocableTokenProvisioning revocableTokenProvisioning;
private ExternalOAuthAuthenticationManager externalOAuthAuthenticationManager;
private Map<String, String> requestParameters;

@BeforeEach
Expand All @@ -66,7 +74,13 @@ void setUp() {
clientDetailsService = mock(MultitenantClientServices.class);
requestFactory = mock(OAuth2RequestFactory.class);
revocableTokenProvisioning = mock(RevocableTokenProvisioning.class);
granter = spy(new TokenExchangeGranter(tokenServices, clientDetailsService, requestFactory, revocableTokenProvisioning));
externalOAuthAuthenticationManager = mock(ExternalOAuthAuthenticationManager.class);
// Default: parse-only (mirrors the pre-fix behaviour for tests that don't need
// signature checking). Individual tests that want verification to fail should
// override this stub to throw InsufficientAuthenticationException.
lenient().when(externalOAuthAuthenticationManager.verifySubjectToken(anyString()))
.thenAnswer(inv -> JwtHelper.decode(inv.getArgument(0, String.class)).getClaimSet());
granter = spy(new TokenExchangeGranter(tokenServices, clientDetailsService, requestFactory, revocableTokenProvisioning, externalOAuthAuthenticationManager));
tokenRequest = new TokenRequest(Collections.emptyMap(), "client_ID", Collections.emptySet(), GRANT_TYPE_TOKEN_EXCHANGE);

authentication = mock(UaaOauth2Authentication.class);
Expand Down Expand Up @@ -273,4 +287,56 @@ void jwt_subject_token_does_not_query_revocable_store() throws Exception {
support.clear();
}
}

/**
* Verifies that {@link TokenExchangeGranter#getTokenActor} translates every failure mode of
* {@link ExternalOAuthAuthenticationManager#verifySubjectToken} into {@link InvalidGrantException}.
*
* <p>These are unit tests of the granter's contract with its collaborator, so
* {@code verifySubjectToken} is mocked. The granter catches two unrelated exception
* hierarchies — Spring Security's {@link org.springframework.security.core.AuthenticationException}
* (thrown by issuer resolution, e.g. when the {@code iss} claim maps to no registered IdP) and
* UAA's {@link InvalidTokenException} (thrown by signature/expiry/audience checks in
* {@code validateToken}) — and must map both to {@code invalid_grant} without leaking detail.
*
* <p>End-to-end cryptographic verification against real provider keys is covered by the
* MockMvc tests ({@code TokenExchangeDefaultConfigMockMvcTests} and
* {@code TokenExchangeSubjectTokenSignatureBypassMockMvcTests}).
*/
@Nested
class SubjectTokenSignatureVerification {

// Structurally valid JWT (matches UaaTokenUtils.isJwtToken) so the granter does not divert
// to the revocable-token store. The contents are irrelevant: verifySubjectToken is mocked.
private static final String SUBJECT_TOKEN = "header.payload.signature";

@BeforeEach
void setUpSubjectToken() {
requestParameters.put("subject_token", SUBJECT_TOKEN);
requestParameters.put("subject_token_type", TOKEN_TYPE_ACCESS);
tokenRequest.setRequestParameters(requestParameters);
}

@Test
void getTokenActor_whenIssuerCannotBeResolved_throwsInvalidGrant() {
doThrow(new InsufficientAuthenticationException("Unable to map issuer to a registered provider"))
.when(externalOAuthAuthenticationManager).verifySubjectToken(SUBJECT_TOKEN);

assertThatThrownBy(() -> granter.getTokenActor(tokenRequest))
.isInstanceOf(InvalidGrantException.class)
.hasMessage("Invalid subject_token");
}

@Test
void getTokenActor_whenSignatureOrExpiryCheckFails_throwsInvalidGrant() {
// validateToken() reports signature/expiry/audience failures as InvalidTokenException,
// which is NOT a Spring AuthenticationException — this exercises the second catch branch.
doThrow(new InvalidTokenException("Could not verify token signature."))
.when(externalOAuthAuthenticationManager).verifySubjectToken(SUBJECT_TOKEN);

assertThatThrownBy(() -> granter.getTokenActor(tokenRequest))
.isInstanceOf(InvalidGrantException.class)
.hasMessage("Invalid subject_token");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.web.servlet.ResultActions;

import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.Map;

Expand Down Expand Up @@ -263,6 +265,56 @@ void token_exchange_three_idps_using_client_assertion() throws Exception {
.containsEntry(ClaimConstants.ORIGIN, subjectTokenClaims.get(ClaimConstants.ORIGIN));
}

/**
* Confirms that a {@code subject_token} with a tampered payload is rejected in the
* default configuration (no bean overrides).
*
* <p>Takes a legitimately signed access token from the control server, replaces its
* {@code sub} and {@code user_id} claims, and submits the result as {@code subject_token}.
* The {@code iss} claim is left intact so that the registered IdP can be resolved;
* the signature is now cryptographically invalid for the modified payload.
*
* <p>The default {@code tokenExchangeAuthenticationManager} wraps
* {@link org.cloudfoundry.identity.uaa.provider.oauth.ExternalOAuthAuthenticationManager},
* which resolves the IdP by the token's {@code iss} claim and then verifies the
* signature against that IdP's published keys. The mismatch is caught at the filter
* layer, before {@code TokenExchangeGranter.getTokenActor()} is reached, and translated
* to HTTP 401.
*/
@Test
void forged_subject_token_is_blocked_by_filter_in_default_configuration() throws Exception {
ThreeWayUAASetup setup = getThreeWayUaaSetUp();
AuthorizationServer workerServer = setup.workerServer();

String legitimateJwt = (String) setup.controlServerTokens().get("access_token");

// Tamper sub and user_id; leave iss intact so the registered IdP can be resolved.
String[] parts = legitimateJwt.split("\\.");
Map<String, Object> claims = JsonUtils.readValueAsMap(
new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8));
claims.put(ClaimConstants.SUB, "FORGED-" + claims.get(ClaimConstants.SUB));
claims.put(ClaimConstants.USER_ID, "FORGED-" + claims.get(ClaimConstants.USER_ID));
String tamperedPayload = Base64.getUrlEncoder().withoutPadding()
.encodeToString(JsonUtils.writeValueAsString(claims).getBytes(StandardCharsets.UTF_8));

// Original header · tampered payload · original signature (now invalid).
String tamperedJwt = parts[0] + "." + tamperedPayload + "." + parts[2];

ResultActions result = performTokenExchangeGrantForJWT(
workerServer.zone().getIdentityZone(),
tamperedJwt,
TokenConstants.TOKEN_TYPE_ACCESS,
TokenConstants.TOKEN_TYPE_ACCESS,
null, null,
workerServer.client(),
ClientAuthType.FORM,
null
);

// The filter's validateToken() catches the invalid signature → 401.
result.andExpect(status().isUnauthorized());
}

@Test
void token_exchange_impersonate_client() throws Exception {

Expand Down
Loading
Loading