diff --git a/server-spi/src/main/java/org/keycloak/services/clientpolicy/ClientPolicyEvent.java b/server-spi/src/main/java/org/keycloak/services/clientpolicy/ClientPolicyEvent.java index 4cc8779a6213..a9ab46a42174 100644 --- a/server-spi/src/main/java/org/keycloak/services/clientpolicy/ClientPolicyEvent.java +++ b/server-spi/src/main/java/org/keycloak/services/clientpolicy/ClientPolicyEvent.java @@ -51,6 +51,7 @@ public enum ClientPolicyEvent { DEVICE_AUTHORIZATION_REQUEST, DEVICE_TOKEN_REQUEST, DEVICE_TOKEN_RESPONSE, + TOKEN_EXCHANGE_REQUEST, RESOURCE_OWNER_PASSWORD_CREDENTIALS_REQUEST, RESOURCE_OWNER_PASSWORD_CREDENTIALS_RESPONSE, diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/TokenExchangeGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/TokenExchangeGrantType.java index b334f1d7331e..502513e82f8b 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/TokenExchangeGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/TokenExchangeGrantType.java @@ -27,6 +27,9 @@ import org.keycloak.events.EventType; import org.keycloak.protocol.oidc.TokenExchangeContext; import org.keycloak.protocol.oidc.TokenExchangeProvider; +import org.keycloak.services.CorsErrorResponseException; +import org.keycloak.services.clientpolicy.ClientPolicyException; +import org.keycloak.services.clientpolicy.context.TokenExchangeRequestContext; /** * OAuth 2.0 Authorization Code Grant @@ -57,14 +60,24 @@ public Response process(Context context) { tokenManager, clientAuthAttributes); - return session.getKeycloakSessionFactory() + TokenExchangeProvider tokenExchangeProvider = session.getKeycloakSessionFactory() .getProviderFactoriesStream(TokenExchangeProvider.class) .sorted((f1, f2) -> f2.order() - f1.order()) .map(f -> session.getProvider(TokenExchangeProvider.class, f.getId())) .filter(p -> p.supports(exchange)) .findFirst() - .orElseThrow(() -> new InternalServerErrorException("No token exchange provider available")) - .exchange(exchange); + .orElseThrow(() -> new InternalServerErrorException("No token exchange provider available")); + + try { + //trigger if there is a supported token exchange provider + session.clientPolicy().triggerOnEvent(new TokenExchangeRequestContext(exchange)); + } catch (ClientPolicyException cpe) { + event.detail(Details.REASON, cpe.getErrorDetail()); + event.error(cpe.getError()); + throw new CorsErrorResponseException(cors, cpe.getError(), cpe.getErrorDetail(), cpe.getErrorStatus()); + } + + return tokenExchangeProvider.exchange(exchange); } @Override diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientScopesCondition.java b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientScopesCondition.java index 555bcc5d5840..30fb2c6307c4 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientScopesCondition.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientScopesCondition.java @@ -28,6 +28,7 @@ import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.TokenExchangeContext; import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest; import org.keycloak.protocol.oidc.grants.ciba.channel.CIBAAuthenticationRequest; import org.keycloak.protocol.oidc.grants.ciba.clientpolicy.context.BackchannelAuthenticationRequestContext; @@ -40,6 +41,7 @@ import org.keycloak.services.clientpolicy.context.AuthorizationRequestContext; import org.keycloak.services.clientpolicy.context.ServiceAccountTokenRequestContext; import org.keycloak.services.clientpolicy.context.ServiceAccountTokenResponseContext; +import org.keycloak.services.clientpolicy.context.TokenExchangeRequestContext; import org.keycloak.services.clientpolicy.context.TokenRequestContext; import org.keycloak.services.clientpolicy.context.TokenResponseContext; @@ -113,6 +115,9 @@ public ClientPolicyVote applyPolicy(ClientPolicyContext context) throws ClientPo case BACKCHANNEL_TOKEN_RESPONSE: if (isScopeMatched(((BackchannelTokenResponseContext)context).getParsedRequest())) return ClientPolicyVote.YES; return ClientPolicyVote.NO; + case TOKEN_EXCHANGE_REQUEST: + if (isScopeMatched(((TokenExchangeRequestContext) context).getTokenExchangeContext())) return ClientPolicyVote.YES; + return ClientPolicyVote.NO; default: return ClientPolicyVote.ABSTAIN; } @@ -133,6 +138,11 @@ private boolean isScopeMatched(CIBAAuthenticationRequest request) { return isScopeMatched(request.getScope(), session.getContext().getRealm().getClientByClientId(request.getClient().getClientId())); } + private boolean isScopeMatched(TokenExchangeContext context) { + if (context == null) return false; + return isScopeMatched(context.getParams().getScope(), context.getClient()); + } + private boolean isScopeMatched(String explicitScopes, ClientModel client) { if (explicitScopes == null) explicitScopes = ""; Collection explicitSpecifiedScopes = new HashSet<>(Arrays.asList(explicitScopes.split(" "))); diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/context/TokenExchangeRequestContext.java b/services/src/main/java/org/keycloak/services/clientpolicy/context/TokenExchangeRequestContext.java new file mode 100644 index 000000000000..6f175e2eccd5 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientpolicy/context/TokenExchangeRequestContext.java @@ -0,0 +1,44 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.services.clientpolicy.context; + +import org.keycloak.protocol.oidc.TokenExchangeContext; +import org.keycloak.services.clientpolicy.ClientPolicyContext; +import org.keycloak.services.clientpolicy.ClientPolicyEvent; + +/** + * @author Giuseppe Graziano + */ +public class TokenExchangeRequestContext implements ClientPolicyContext { + + private final TokenExchangeContext tokenExchangeContext; + + public TokenExchangeRequestContext(TokenExchangeContext tokenExchangeContext) { + this.tokenExchangeContext = tokenExchangeContext; + } + + @Override + public ClientPolicyEvent getEvent() { + return ClientPolicyEvent.TOKEN_EXCHANGE_REQUEST; + } + + + public TokenExchangeContext getTokenExchangeContext() { + return tokenExchangeContext; + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java index 9ee89be98a36..7b532a3fda63 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java @@ -27,21 +27,23 @@ import org.keycloak.OAuth2Constants; import org.keycloak.OAuthErrorException; import org.keycloak.TokenVerifier; -import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.admin.client.resource.UserResource; import org.keycloak.common.Profile; import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.representations.AccessToken; import org.keycloak.representations.IDToken; -import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.RealmRepresentation; -import org.keycloak.testsuite.AbstractKeycloakTest; +import org.keycloak.services.clientpolicy.ClientPolicyEvent; +import org.keycloak.services.clientpolicy.condition.ClientScopesConditionFactory; import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; import org.keycloak.testsuite.arquillian.annotation.UncaughtServerErrorExpected; +import org.keycloak.testsuite.client.policies.AbstractClientPoliciesTest; import org.keycloak.testsuite.pages.ConsentPage; +import org.keycloak.testsuite.services.clientpolicy.executor.TestRaiseExceptionExecutorFactory; import org.keycloak.testsuite.updaters.ClientAttributeUpdater; +import org.keycloak.testsuite.util.ClientPoliciesUtil; import org.keycloak.testsuite.util.oauth.AccessTokenResponse; import org.keycloak.testsuite.util.oauth.TokenExchangeRequest; import org.keycloak.util.TokenUtil; @@ -56,12 +58,14 @@ import static org.junit.Assert.assertNull; import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; import static org.keycloak.testsuite.auth.page.AuthRealm.TEST; +import static org.keycloak.testsuite.util.ClientPoliciesUtil.createClientScopesConditionConfig; +import static org.keycloak.testsuite.util.ClientPoliciesUtil.createTestRaiseExeptionExecutorConfig; /** * @author Marek Posolda */ @EnableFeature(value = Profile.Feature.TOKEN_EXCHANGE_STANDARD_V2, skipRestart = true) -public class StandardTokenExchangeV2Test extends AbstractKeycloakTest { +public class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest { @Rule public AssertEvents events = new AssertEvents(this); @@ -435,6 +439,10 @@ public void testScopeFilter() throws Exception { assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError()); assertEquals("Requested audience not available: target-client2", response.getErrorDescription()); + oauth.scope("optional-scope2"); + response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1"), null); + assertAudiencesAndScopes(response, List.of("target-client1"), List.of("default-scope1")); + oauth.scope("optional-scope2"); response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client2"), null); assertAudiencesAndScopes(response, List.of("target-client2"), List.of("optional-scope2")); @@ -549,6 +557,40 @@ public void testOfflineAccessNotAllowed() throws Exception { } } + @Test + public void testClientPolicies() throws Exception { + + String json = (new ClientPoliciesUtil.ClientProfilesBuilder()).addProfile( + (new ClientPoliciesUtil.ClientProfileBuilder()).createProfile(PROFILE_NAME, "Profilo") + .addExecutor(TestRaiseExceptionExecutorFactory.PROVIDER_ID, + createTestRaiseExeptionExecutorConfig(List.of(ClientPolicyEvent.TOKEN_EXCHANGE_REQUEST))) + .toRepresentation() + ).toString(); + updateProfiles(json); + + // register policy with condition on client scope optional-scope2 + json = (new ClientPoliciesUtil.ClientPoliciesBuilder()).addPolicy( + (new ClientPoliciesUtil.ClientPolicyBuilder()).createPolicy(POLICY_NAME, "Client Scope Policy", Boolean.TRUE) + .addCondition(ClientScopesConditionFactory.PROVIDER_ID, + createClientScopesConditionConfig(ClientScopesConditionFactory.ANY, List.of("optional-scope2"))) + .addProfile(PROFILE_NAME) + .toRepresentation() + ).toString(); + updatePolicies(json); + + String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret"); + + AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1"), null); + assertAudiencesAndScopes(response, List.of("target-client1"), List.of("default-scope1")); + + //block token exchange request if optional-scope2 is requested + oauth.scope("optional-scope2"); + response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client2"), null); + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode()); + assertEquals(ClientPolicyEvent.TOKEN_EXCHANGE_REQUEST.toString(), response.getError()); + assertEquals("Exception thrown intentionally", response.getErrorDescription()); + } + private void assertAudiences(AccessToken token, List expectedAudiences) { MatcherAssert.assertThat("Incompatible audiences", token.getAudience() == null ? List.of() : List.of(token.getAudience()), containsInAnyOrder(expectedAudiences.toArray())); MatcherAssert.assertThat("Incompatible resource access", token.getResourceAccess().keySet(), containsInAnyOrder(expectedAudiences.toArray()));