Skip to content

Commit 510f8f7

Browse files
rmartincmposolda
authored andcommitted
Allow TE for clients with consents approved by the user
Close #37112 Signed-off-by: rmartinc <[email protected]>
1 parent d5de190 commit 510f8f7

File tree

3 files changed

+114
-41
lines changed

3 files changed

+114
-41
lines changed

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/AbstractTokenExchangeProvider.java

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,11 @@ protected List<ClientModel> getTargetAudienceClients() {
240240
if (targetAudienceClients.isEmpty()) {
241241
targetAudienceClients.add(client);
242242
}
243+
return targetAudienceClients;
244+
}
245+
246+
protected void validateAudience(AccessToken token, boolean disallowOnHolderOfTokenMismatch, List<ClientModel> targetAudienceClients) {
247+
ClientModel tokenHolder = token == null ? null : realm.getClientByClientId(token.getIssuedFor());
243248
for (ClientModel targetClient : targetAudienceClients) {
244249
if (targetClient.isConsentRequired()) {
245250
event.detail(Details.REASON, "audience requires consent");
@@ -253,13 +258,6 @@ protected List<ClientModel> getTargetAudienceClients() {
253258
event.error(Errors.CLIENT_DISABLED);
254259
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, "Client disabled", Response.Status.BAD_REQUEST);
255260
}
256-
}
257-
return targetAudienceClients;
258-
}
259-
260-
protected void validateAudience(AccessToken token, boolean disallowOnHolderOfTokenMismatch, List<ClientModel> targetAudienceClients) {
261-
ClientModel tokenHolder = token == null ? null : realm.getClientByClientId(token.getIssuedFor());
262-
for (ClientModel targetClient : targetAudienceClients) {
263261
boolean isClientTheAudience = targetClient.equals(client);
264262
if (isClientTheAudience) {
265263
if (client.isPublicClient()) {

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,12 +139,30 @@ protected void validateAudience(AccessToken token, boolean disallowOnHolderOfTok
139139
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, errorMessage, Response.Status.BAD_REQUEST);
140140
}
141141

142+
for (ClientModel targetClient : targetAudienceClients) {
143+
if (!targetClient.isEnabled()) {
144+
event.detail(Details.REASON, "audience client disabled");
145+
event.detail(Details.AUDIENCE, targetClient.getClientId());
146+
event.error(Errors.CLIENT_DISABLED);
147+
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, "Client disabled", Response.Status.BAD_REQUEST);
148+
}
149+
}
150+
142151
//reject if the requester-client is not in the audience of the subject token
143152
if (!client.equals(tokenHolder)) {
144153
forbiddenIfClientIsNotWithinTokenAudience(token);
145154
}
146155
}
147156

157+
protected void validateConsents(UserModel targetUser, ClientSessionContext clientSessionCtx) {
158+
if (!TokenManager.verifyConsentStillAvailable(session, targetUser, client, clientSessionCtx.getClientScopesStream())) {
159+
event.detail(Details.REASON, "Missing consents for Token Exchange in client " + client.getClientId());
160+
event.error(Errors.CONSENT_DENIED);
161+
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_SCOPE,
162+
"Missing consents for Token Exchange in client " + client.getClientId(), Response.Status.BAD_REQUEST);
163+
}
164+
}
165+
148166
// For now, include "scope" parameter as is
149167
@Override
150168
protected String getRequestedScope(AccessToken token, List<ClientModel> targetAudienceClients) {
@@ -196,6 +214,8 @@ protected Response exchangeClientToOIDCClient(UserModel targetUser, UserSessionM
196214
clientSessionCtx.setAttribute(Constants.REQUESTED_AUDIENCE_CLIENTS, targetAudienceClients.toArray(ClientModel[]::new));
197215
}
198216

217+
validateConsents(targetUser, clientSessionCtx);
218+
199219
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(realm, client, event, this.session, targetUserSession, clientSessionCtx)
200220
.generateAccessToken();
201221

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

Lines changed: 89 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@
2121

2222
import jakarta.ws.rs.core.Response;
2323
import org.hamcrest.MatcherAssert;
24+
import org.jboss.arquillian.graphene.page.Page;
2425
import org.junit.Rule;
2526
import org.junit.Test;
2627
import org.keycloak.OAuth2Constants;
2728
import org.keycloak.OAuthErrorException;
2829
import org.keycloak.TokenVerifier;
2930
import org.keycloak.admin.client.resource.ClientResource;
31+
import org.keycloak.admin.client.resource.UserResource;
3032
import org.keycloak.common.Profile;
3133
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
3234
import org.keycloak.representations.AccessToken;
@@ -39,6 +41,8 @@
3941
import org.keycloak.testsuite.admin.ApiUtil;
4042
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
4143
import org.keycloak.testsuite.arquillian.annotation.UncaughtServerErrorExpected;
44+
import org.keycloak.testsuite.pages.ConsentPage;
45+
import org.keycloak.testsuite.updaters.ClientAttributeUpdater;
4246
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
4347
import org.keycloak.util.TokenUtil;
4448

@@ -62,6 +66,9 @@ public class StandardTokenExchangeV2Test extends AbstractKeycloakTest {
6266
@Rule
6367
public AssertEvents events = new AssertEvents(this);
6468

69+
@Page
70+
protected ConsentPage consentPage;
71+
6572
@Override
6673
public void addTestRealms(List<RealmRepresentation> testRealms) {
6774
RealmRepresentation testRealm = loadJson(getClass().getResourceAsStream("/token-exchange/testrealm-token-exchange-v2.json"), RealmRepresentation.class);
@@ -81,8 +88,21 @@ private String resourceOwnerLogin(String username, String password, String clien
8188
oauth.scope(null);
8289
oauth.openid(false);
8390
AccessTokenResponse response = oauth.doGrantAccessTokenRequest(username, password);
91+
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
8492
TokenVerifier<AccessToken> accessTokenVerifier = TokenVerifier.create(response.getAccessToken(), AccessToken.class);
85-
AccessToken token = accessTokenVerifier.parse().getToken();
93+
accessTokenVerifier.parse();
94+
return response.getAccessToken();
95+
}
96+
97+
private String loginWithConsents(String username, String password, String clientId, String secret) throws Exception {
98+
oauth.client(clientId, secret).doLogin(username, password);
99+
consentPage.assertCurrent();
100+
consentPage.confirm();
101+
assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE));
102+
AccessTokenResponse response = oauth.doAccessTokenRequest(oauth.getCurrentQuery().get(OAuth2Constants.CODE));
103+
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
104+
TokenVerifier<AccessToken> accessTokenVerifier = TokenVerifier.create(response.getAccessToken(), AccessToken.class);
105+
accessTokenVerifier.parse();
86106
return response.getAccessToken();
87107
}
88108

@@ -234,22 +254,18 @@ public void testClientExchangeToItselfWithConsents() throws Exception {
234254
oauth.realm(TEST);
235255
String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret");
236256

237-
ClientResource client = ApiUtil.findClientByClientId(adminClient.realm(TEST), "subject-client");
238-
ClientRepresentation clientRepresentation = client.toRepresentation();
239-
clientRepresentation.setConsentRequired(Boolean.TRUE);
240-
client.update(clientRepresentation);
241-
242-
AccessTokenResponse response = tokenExchange(accessToken, "subject-client", "secret", null, null);
243-
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
244-
assertEquals(OAuthErrorException.INVALID_CLIENT, response.getError());
245-
assertEquals("Client requires user consent", response.getErrorDescription());
246-
247-
response = tokenExchange(accessToken, "subject-client", "secret", List.of("subject-client"), null);
248-
assertEquals(OAuthErrorException.INVALID_CLIENT, response.getError());
249-
assertEquals("Client requires user consent", response.getErrorDescription());
250-
251-
clientRepresentation.setConsentRequired(Boolean.FALSE);
252-
client.update(clientRepresentation);
257+
try (ClientAttributeUpdater clientUpdater = ClientAttributeUpdater.forClient(adminClient, TEST, "subject-client")
258+
.setConsentRequired(Boolean.TRUE)
259+
.update()) {
260+
AccessTokenResponse response = tokenExchange(accessToken, "subject-client", "secret", null, null);
261+
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
262+
assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError());
263+
assertEquals("Missing consents for Token Exchange in client subject-client", response.getErrorDescription());
264+
265+
response = tokenExchange(accessToken, "subject-client", "secret", List.of("subject-client"), null);
266+
assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError());
267+
assertEquals("Missing consents for Token Exchange in client subject-client", response.getErrorDescription());
268+
}
253269
}
254270

255271
@Test
@@ -281,14 +297,14 @@ public void testUnavailableAudienceRequested() throws Exception {
281297
String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret");
282298
// request invalid client audience
283299
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1", "invalid-client"), null);
284-
org.junit.Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
285-
org.junit.Assert.assertEquals(OAuthErrorException.INVALID_CLIENT, response.getError());
286-
org.junit.Assert.assertEquals("Audience not found", response.getErrorDescription());
300+
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
301+
assertEquals(OAuthErrorException.INVALID_CLIENT, response.getError());
302+
assertEquals("Audience not found", response.getErrorDescription());
287303
// The "target-client3" is valid client, but audience unavailable to the user. Request not allowed
288304
response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1", "target-client3"), null);
289-
org.junit.Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
290-
org.junit.Assert.assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
291-
org.junit.Assert.assertEquals("Requested audience not available: target-client3", response.getErrorDescription());
305+
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
306+
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
307+
assertEquals("Requested audience not available: target-client3", response.getErrorDescription());
292308
}
293309

294310
@Test
@@ -299,24 +315,24 @@ public void testScopeNotAllowed() throws Exception {
299315
oauth.scope("optional-scope3");
300316
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1", "target-client3"), null);
301317
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
302-
org.junit.Assert.assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError());
303-
org.junit.Assert.assertEquals("Invalid scopes: optional-scope3", response.getErrorDescription());
318+
assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError());
319+
assertEquals("Invalid scopes: optional-scope3", response.getErrorDescription());
304320

305321
//scope that doesn't exist
306322
oauth.scope("bad-scope");
307323
response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1", "target-client3"), null);
308324
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
309-
org.junit.Assert.assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError());
310-
org.junit.Assert.assertEquals("Invalid scopes: bad-scope", response.getErrorDescription());
325+
assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError());
326+
assertEquals("Invalid scopes: bad-scope", response.getErrorDescription());
311327
}
312328

313329
@Test
314330
public void testScopeFilter() throws Exception {
315-
String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret");
331+
String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret");
316332
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client2"), null);
317-
org.junit.Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
318-
org.junit.Assert.assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
319-
org.junit.Assert.assertEquals("Requested audience not available: target-client2", response.getErrorDescription());
333+
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
334+
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
335+
assertEquals("Requested audience not available: target-client2", response.getErrorDescription());
320336

321337
oauth.scope("optional-scope2");
322338
response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client2"), null);
@@ -340,11 +356,11 @@ public void testScopeFilter() throws Exception {
340356

341357
@Test
342358
public void testScopeParamIncludedAudienceIncludedRefreshToken() throws Exception {
343-
String accessToken = resourceOwnerLogin("mike", "password","subject-client", "secret");
359+
String accessToken = resourceOwnerLogin("mike", "password", "subject-client", "secret");
344360
oauth.scope("optional-scope2");
345361
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1"), Collections.singletonMap(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE));
346362
assertAudiencesAndScopes(response, List.of("target-client1"), List.of("default-scope1", "optional-scope2"));
347-
org.junit.Assert.assertNotNull(response.getRefreshToken());
363+
assertNotNull(response.getRefreshToken());
348364

349365
oauth.client("requester-client", "secret");
350366
response = oauth.doRefreshTokenRequest(response.getRefreshToken());
@@ -363,6 +379,44 @@ public void testExchangeWithDynamicScopesEnabled() throws Exception {
363379
testingClient.disableFeature(Profile.Feature.DYNAMIC_SCOPES);
364380
}
365381

382+
@Test
383+
public void testConsents() throws Exception {
384+
try (ClientAttributeUpdater clientUpdater = ClientAttributeUpdater.forClient(adminClient, TEST, "requester-client")
385+
.setConsentRequired(Boolean.TRUE)
386+
.update()) {
387+
// initial TE without any consent should fail
388+
String accessToken = resourceOwnerLogin("mike", "password", "subject-client", "secret");
389+
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, null);
390+
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
391+
assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError());
392+
assertEquals("Missing consents for Token Exchange in client requester-client", response.getErrorDescription());
393+
394+
// logout
395+
UserResource mike = ApiUtil.findUserByUsernameId(adminClient.realm(TEST), "mike");
396+
mike.logout();
397+
398+
// perform a login and allow consent for default scopes, TE should work now
399+
accessToken = loginWithConsents("mike", "password", "requester-client", "secret");
400+
response = tokenExchange(accessToken, "requester-client", "secret", null, null);
401+
assertAudiencesAndScopes(response, List.of("target-client1"), List.of("default-scope1"));
402+
403+
// request TE with optional-scope2 whose consent is missing, should fail
404+
oauth.scope("optional-scope2");
405+
response = tokenExchange(accessToken, "requester-client", "secret", null, null);
406+
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
407+
assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError());
408+
assertEquals("Missing consents for Token Exchange in client requester-client", response.getErrorDescription());
409+
410+
// logout
411+
mike.logout();
412+
413+
// consent the additional scope, TE should work now
414+
accessToken = loginWithConsents("mike", "password", "requester-client", "secret");
415+
response = tokenExchange(accessToken, "requester-client", "secret", null, null);
416+
assertAudiencesAndScopes(response, List.of("target-client1"), List.of("default-scope1", "optional-scope2"));
417+
}
418+
}
419+
366420
private void assertAudiences(AccessToken token, List<String> expectedAudiences) {
367421
MatcherAssert.assertThat("Incompatible audiences", token.getAudience() == null ? List.of() : List.of(token.getAudience()), containsInAnyOrder(expectedAudiences.toArray()));
368422
MatcherAssert.assertThat("Incompatible resource access", token.getResourceAccess().keySet(), containsInAnyOrder(expectedAudiences.toArray()));
@@ -373,10 +427,11 @@ private void assertScopes(AccessToken token, List<String> expectedScopes) {
373427
}
374428

375429
private void assertAudiencesAndScopes(AccessTokenResponse tokenExchangeResponse, List<String> expectedAudiences, List<String> expectedScopes) throws Exception {
430+
assertEquals(Response.Status.OK.getStatusCode(), tokenExchangeResponse.getStatusCode());
376431
TokenVerifier<AccessToken> accessTokenVerifier = TokenVerifier.create(tokenExchangeResponse.getAccessToken(), AccessToken.class);
377432
AccessToken token = accessTokenVerifier.parse().getToken();
378433
if (expectedAudiences == null) {
379-
org.junit.Assert.assertNull("Expected token to not contain audience", token.getAudience());
434+
assertNull("Expected token to not contain audience", token.getAudience());
380435
} else {
381436
assertAudiences(token, expectedAudiences);
382437
}

0 commit comments

Comments
 (0)