Skip to content

Commit fd3a4a3

Browse files
graziangmposolda
authored andcommitted
Support client policies for token exchange
Closes #37122 Signed-off-by: Giuseppe Graziano <[email protected]>
1 parent 6bc50a7 commit fd3a4a3

File tree

5 files changed

+117
-7
lines changed

5 files changed

+117
-7
lines changed

server-spi/src/main/java/org/keycloak/services/clientpolicy/ClientPolicyEvent.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public enum ClientPolicyEvent {
5151
DEVICE_AUTHORIZATION_REQUEST,
5252
DEVICE_TOKEN_REQUEST,
5353
DEVICE_TOKEN_RESPONSE,
54+
TOKEN_EXCHANGE_REQUEST,
5455
RESOURCE_OWNER_PASSWORD_CREDENTIALS_REQUEST,
5556
RESOURCE_OWNER_PASSWORD_CREDENTIALS_RESPONSE,
5657

services/src/main/java/org/keycloak/protocol/oidc/grants/TokenExchangeGrantType.java

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
import org.keycloak.events.EventType;
2828
import org.keycloak.protocol.oidc.TokenExchangeContext;
2929
import org.keycloak.protocol.oidc.TokenExchangeProvider;
30+
import org.keycloak.services.CorsErrorResponseException;
31+
import org.keycloak.services.clientpolicy.ClientPolicyException;
32+
import org.keycloak.services.clientpolicy.context.TokenExchangeRequestContext;
3033

3134
/**
3235
* OAuth 2.0 Authorization Code Grant
@@ -57,14 +60,24 @@ public Response process(Context context) {
5760
tokenManager,
5861
clientAuthAttributes);
5962

60-
return session.getKeycloakSessionFactory()
63+
TokenExchangeProvider tokenExchangeProvider = session.getKeycloakSessionFactory()
6164
.getProviderFactoriesStream(TokenExchangeProvider.class)
6265
.sorted((f1, f2) -> f2.order() - f1.order())
6366
.map(f -> session.getProvider(TokenExchangeProvider.class, f.getId()))
6467
.filter(p -> p.supports(exchange))
6568
.findFirst()
66-
.orElseThrow(() -> new InternalServerErrorException("No token exchange provider available"))
67-
.exchange(exchange);
69+
.orElseThrow(() -> new InternalServerErrorException("No token exchange provider available"));
70+
71+
try {
72+
//trigger if there is a supported token exchange provider
73+
session.clientPolicy().triggerOnEvent(new TokenExchangeRequestContext(exchange));
74+
} catch (ClientPolicyException cpe) {
75+
event.detail(Details.REASON, cpe.getErrorDetail());
76+
event.error(cpe.getError());
77+
throw new CorsErrorResponseException(cors, cpe.getError(), cpe.getErrorDetail(), cpe.getErrorStatus());
78+
}
79+
80+
return tokenExchangeProvider.exchange(exchange);
6881
}
6982

7083
@Override

services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientScopesCondition.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import org.keycloak.models.AuthenticatedClientSessionModel;
2929
import org.keycloak.models.ClientModel;
3030
import org.keycloak.models.KeycloakSession;
31+
import org.keycloak.protocol.oidc.TokenExchangeContext;
3132
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest;
3233
import org.keycloak.protocol.oidc.grants.ciba.channel.CIBAAuthenticationRequest;
3334
import org.keycloak.protocol.oidc.grants.ciba.clientpolicy.context.BackchannelAuthenticationRequestContext;
@@ -40,6 +41,7 @@
4041
import org.keycloak.services.clientpolicy.context.AuthorizationRequestContext;
4142
import org.keycloak.services.clientpolicy.context.ServiceAccountTokenRequestContext;
4243
import org.keycloak.services.clientpolicy.context.ServiceAccountTokenResponseContext;
44+
import org.keycloak.services.clientpolicy.context.TokenExchangeRequestContext;
4345
import org.keycloak.services.clientpolicy.context.TokenRequestContext;
4446
import org.keycloak.services.clientpolicy.context.TokenResponseContext;
4547

@@ -113,6 +115,9 @@ public ClientPolicyVote applyPolicy(ClientPolicyContext context) throws ClientPo
113115
case BACKCHANNEL_TOKEN_RESPONSE:
114116
if (isScopeMatched(((BackchannelTokenResponseContext)context).getParsedRequest())) return ClientPolicyVote.YES;
115117
return ClientPolicyVote.NO;
118+
case TOKEN_EXCHANGE_REQUEST:
119+
if (isScopeMatched(((TokenExchangeRequestContext) context).getTokenExchangeContext())) return ClientPolicyVote.YES;
120+
return ClientPolicyVote.NO;
116121
default:
117122
return ClientPolicyVote.ABSTAIN;
118123
}
@@ -133,6 +138,11 @@ private boolean isScopeMatched(CIBAAuthenticationRequest request) {
133138
return isScopeMatched(request.getScope(), session.getContext().getRealm().getClientByClientId(request.getClient().getClientId()));
134139
}
135140

141+
private boolean isScopeMatched(TokenExchangeContext context) {
142+
if (context == null) return false;
143+
return isScopeMatched(context.getParams().getScope(), context.getClient());
144+
}
145+
136146
private boolean isScopeMatched(String explicitScopes, ClientModel client) {
137147
if (explicitScopes == null) explicitScopes = "";
138148
Collection<String> explicitSpecifiedScopes = new HashSet<>(Arrays.asList(explicitScopes.split(" ")));
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright 2025 Red Hat, Inc. and/or its affiliates
3+
* and other contributors as indicated by the @author tags.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.keycloak.services.clientpolicy.context;
19+
20+
import org.keycloak.protocol.oidc.TokenExchangeContext;
21+
import org.keycloak.services.clientpolicy.ClientPolicyContext;
22+
import org.keycloak.services.clientpolicy.ClientPolicyEvent;
23+
24+
/**
25+
* @author <a href="mailto:[email protected]">Giuseppe Graziano</a>
26+
*/
27+
public class TokenExchangeRequestContext implements ClientPolicyContext {
28+
29+
private final TokenExchangeContext tokenExchangeContext;
30+
31+
public TokenExchangeRequestContext(TokenExchangeContext tokenExchangeContext) {
32+
this.tokenExchangeContext = tokenExchangeContext;
33+
}
34+
35+
@Override
36+
public ClientPolicyEvent getEvent() {
37+
return ClientPolicyEvent.TOKEN_EXCHANGE_REQUEST;
38+
}
39+
40+
41+
public TokenExchangeContext getTokenExchangeContext() {
42+
return tokenExchangeContext;
43+
}
44+
}

testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,21 +27,23 @@
2727
import org.keycloak.OAuth2Constants;
2828
import org.keycloak.OAuthErrorException;
2929
import org.keycloak.TokenVerifier;
30-
import org.keycloak.admin.client.resource.ClientResource;
3130
import org.keycloak.admin.client.resource.UserResource;
3231
import org.keycloak.common.Profile;
3332
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
3433
import org.keycloak.representations.AccessToken;
3534
import org.keycloak.representations.IDToken;
36-
import org.keycloak.representations.idm.ClientRepresentation;
3735
import org.keycloak.representations.idm.RealmRepresentation;
38-
import org.keycloak.testsuite.AbstractKeycloakTest;
36+
import org.keycloak.services.clientpolicy.ClientPolicyEvent;
37+
import org.keycloak.services.clientpolicy.condition.ClientScopesConditionFactory;
3938
import org.keycloak.testsuite.AssertEvents;
4039
import org.keycloak.testsuite.admin.ApiUtil;
4140
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
4241
import org.keycloak.testsuite.arquillian.annotation.UncaughtServerErrorExpected;
42+
import org.keycloak.testsuite.client.policies.AbstractClientPoliciesTest;
4343
import org.keycloak.testsuite.pages.ConsentPage;
44+
import org.keycloak.testsuite.services.clientpolicy.executor.TestRaiseExceptionExecutorFactory;
4445
import org.keycloak.testsuite.updaters.ClientAttributeUpdater;
46+
import org.keycloak.testsuite.util.ClientPoliciesUtil;
4547
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
4648
import org.keycloak.testsuite.util.oauth.TokenExchangeRequest;
4749
import org.keycloak.util.TokenUtil;
@@ -56,12 +58,14 @@
5658
import static org.junit.Assert.assertNull;
5759
import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson;
5860
import static org.keycloak.testsuite.auth.page.AuthRealm.TEST;
61+
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createClientScopesConditionConfig;
62+
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createTestRaiseExeptionExecutorConfig;
5963

6064
/**
6165
* @author <a href="mailto:[email protected]">Marek Posolda</a>
6266
*/
6367
@EnableFeature(value = Profile.Feature.TOKEN_EXCHANGE_STANDARD_V2, skipRestart = true)
64-
public class StandardTokenExchangeV2Test extends AbstractKeycloakTest {
68+
public class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest {
6569

6670
@Rule
6771
public AssertEvents events = new AssertEvents(this);
@@ -435,6 +439,10 @@ public void testScopeFilter() throws Exception {
435439
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
436440
assertEquals("Requested audience not available: target-client2", response.getErrorDescription());
437441

442+
oauth.scope("optional-scope2");
443+
response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1"), null);
444+
assertAudiencesAndScopes(response, List.of("target-client1"), List.of("default-scope1"));
445+
438446
oauth.scope("optional-scope2");
439447
response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client2"), null);
440448
assertAudiencesAndScopes(response, List.of("target-client2"), List.of("optional-scope2"));
@@ -549,6 +557,40 @@ public void testOfflineAccessNotAllowed() throws Exception {
549557
}
550558
}
551559

560+
@Test
561+
public void testClientPolicies() throws Exception {
562+
563+
String json = (new ClientPoliciesUtil.ClientProfilesBuilder()).addProfile(
564+
(new ClientPoliciesUtil.ClientProfileBuilder()).createProfile(PROFILE_NAME, "Profilo")
565+
.addExecutor(TestRaiseExceptionExecutorFactory.PROVIDER_ID,
566+
createTestRaiseExeptionExecutorConfig(List.of(ClientPolicyEvent.TOKEN_EXCHANGE_REQUEST)))
567+
.toRepresentation()
568+
).toString();
569+
updateProfiles(json);
570+
571+
// register policy with condition on client scope optional-scope2
572+
json = (new ClientPoliciesUtil.ClientPoliciesBuilder()).addPolicy(
573+
(new ClientPoliciesUtil.ClientPolicyBuilder()).createPolicy(POLICY_NAME, "Client Scope Policy", Boolean.TRUE)
574+
.addCondition(ClientScopesConditionFactory.PROVIDER_ID,
575+
createClientScopesConditionConfig(ClientScopesConditionFactory.ANY, List.of("optional-scope2")))
576+
.addProfile(PROFILE_NAME)
577+
.toRepresentation()
578+
).toString();
579+
updatePolicies(json);
580+
581+
String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret");
582+
583+
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1"), null);
584+
assertAudiencesAndScopes(response, List.of("target-client1"), List.of("default-scope1"));
585+
586+
//block token exchange request if optional-scope2 is requested
587+
oauth.scope("optional-scope2");
588+
response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client2"), null);
589+
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
590+
assertEquals(ClientPolicyEvent.TOKEN_EXCHANGE_REQUEST.toString(), response.getError());
591+
assertEquals("Exception thrown intentionally", response.getErrorDescription());
592+
}
593+
552594
private void assertAudiences(AccessToken token, List<String> expectedAudiences) {
553595
MatcherAssert.assertThat("Incompatible audiences", token.getAudience() == null ? List.of() : List.of(token.getAudience()), containsInAnyOrder(expectedAudiences.toArray()));
554596
MatcherAssert.assertThat("Incompatible resource access", token.getResourceAccess().keySet(), containsInAnyOrder(expectedAudiences.toArray()));

0 commit comments

Comments
 (0)