diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/BackwardsCompatibleTokenEndpointAuthenticationFilter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/BackwardsCompatibleTokenEndpointAuthenticationFilter.java index ec8453866d3..f1994ef469e 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/BackwardsCompatibleTokenEndpointAuthenticationFilter.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/BackwardsCompatibleTokenEndpointAuthenticationFilter.java @@ -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; @@ -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; @@ -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( @@ -119,7 +126,8 @@ public BackwardsCompatibleTokenEndpointAuthenticationFilter( oAuth2RequestFactory, saml2BearerGrantAuthenticationConverter, externalOAuthAuthenticationManager, - tokenExchangeAuthenticationManager + tokenExchangeAuthenticationManager, + null ); } @@ -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"); @@ -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 { @@ -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; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/provider/config/xml/OAuth2FilterConfig.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/provider/config/xml/OAuth2FilterConfig.java index d59869e5c8f..e7984a2d7f0 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/provider/config/xml/OAuth2FilterConfig.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/provider/config/xml/OAuth2FilterConfig.java @@ -39,13 +39,14 @@ FilterRegistrationBean tok ExternalOAuthAuthenticationManager externalOAuthAuthenticationManager, @Qualifier("tokenExchangeAuthenticationManager") AuthenticationManager tokenExchangeAuthenticationManager, AuthenticationDetailsSource 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 bean = new FilterRegistrationBean<>(filter); @@ -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; } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/TokenExchangeGranter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/TokenExchangeGranter.java index 38fd303545b..37cc0d0057e 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/TokenExchangeGranter.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/TokenExchangeGranter.java @@ -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; @@ -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) { @@ -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("Invalid subject_token: not a JWT and not found in the revocable token store"); + } + } JWTClaimsSet claims = JwtHelper.decode(subjectToken).getClaimSet(); String clientId = tokenRequest.getClientId(); try { diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/token/TokenExchangeGranterTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/token/TokenExchangeGranterTests.java index 79e361b3757..4663c813051 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/token/TokenExchangeGranterTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/oauth/token/TokenExchangeGranterTests.java @@ -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; @@ -52,6 +54,7 @@ class TokenExchangeGranterTests { private AuthorizationServerTokenServices tokenServices; private MultitenantClientServices clientDetailsService; private OAuth2RequestFactory requestFactory; + private RevocableTokenProvisioning revocableTokenProvisioning; private Map requestParameters; @BeforeEach @@ -59,7 +62,8 @@ 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); @@ -211,4 +215,42 @@ 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"; + 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 + TokenActor actor = granter.getTokenActor(tokenRequest); + + verify(revocableTokenProvisioning, times(1)).retrieve(eq(opaqueTokenId), anyString()); + assertThat(actor.getSubject()).isEqualTo("user123"); + assertThat(actor.getIssuer()).isEqualTo("https://uaa.example.com"); + } + + @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("Invalid subject_token: not a JWT and not found in the revocable token store"); + } } \ No newline at end of file diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenExchangeDefaultConfigMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenExchangeDefaultConfigMockMvcTests.java index be2ccac0c41..592dbe56038 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenExchangeDefaultConfigMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenExchangeDefaultConfigMockMvcTests.java @@ -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; @@ -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 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 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 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 { diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenExchangeMockMvcBase.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenExchangeMockMvcBase.java index 1971590f27b..7e679b9309b 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenExchangeMockMvcBase.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenExchangeMockMvcBase.java @@ -293,13 +293,17 @@ AuthorizationServer getAuthorizationServer( Map performJWTBearerGrantForJWT(IdentityZone theZone, ClientDetails client, String assertion) throws Exception { + return performJWTBearerGrantForJWT(theZone, client, assertion, TokenConstants.TokenFormat.JWT.getStringValue()); + } + + Map performJWTBearerGrantForJWT(IdentityZone theZone, ClientDetails client, String assertion, String tokenFormat) throws Exception { MockHttpServletRequestBuilder jwtBearerGrant = post("/oauth/token") .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) .param("client_id", client.getClientId()) .param("client_secret", client.getClientSecret()) .param(GRANT_TYPE, GRANT_TYPE_JWT_BEARER) - .param(TokenConstants.REQUEST_TOKEN_FORMAT, TokenConstants.TokenFormat.JWT.getStringValue()) + .param(TokenConstants.REQUEST_TOKEN_FORMAT, tokenFormat) .param("response_type", "id_token token") .param("scope", "openid") .param("assertion", assertion); @@ -326,6 +330,21 @@ ResultActions performTokenExchangeGrantForJWT( ClientAuthType clientAuthType, String responseTypes ) throws Exception { + return performTokenExchangeGrant(theZone, subjectToken, subjectTokenType, requestTokenType, audience, scope, client, clientAuthType, responseTypes, TokenConstants.TokenFormat.JWT.getStringValue()); + } + + ResultActions performTokenExchangeGrant( + IdentityZone theZone, + String subjectToken, + String subjectTokenType, + String requestTokenType, + String audience, + String scope, + ClientDetails client, + ClientAuthType clientAuthType, + String responseTypes, + String tokenFormat + ) throws Exception { MockHttpServletRequestBuilder tokenExchange = post("/oauth/token") .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) @@ -337,7 +356,7 @@ ResultActions performTokenExchangeGrantForJWT( .param("requested_token_type", requestTokenType) .param("audience", audience) .param("scope", scope) - .param("token_format", "jwt") + .param("token_format", tokenFormat) ; switch (clientAuthType) { case BASIC -> {