Migrate Standard token exchange tests (#47516)

Closes #47491
closes #48982 


Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
Giuseppe Graziano 2026-05-27 16:33:56 +02:00 committed by GitHub
parent 4f06846693
commit 2e99d2e965
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 2022 additions and 1 deletions

View file

@ -2,6 +2,7 @@ package org.keycloak.testframework.realm;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
@ -17,6 +18,7 @@ import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.idm.RolesRepresentation;
import org.keycloak.representations.idm.ScopeMappingRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
public class RealmBuilder extends Builder<RealmRepresentation> {
@ -588,6 +590,29 @@ public class RealmBuilder extends Builder<RealmRepresentation> {
return this;
}
public RealmBuilder addClientScopeRealmRoleMapping(String clientScopeName, String... roleNames) {
ScopeMappingRepresentation mapping = rep.clientScopeScopeMapping(clientScopeName);
for (String roleName : roleNames) {
mapping.role(roleName);
}
return this;
}
public RealmBuilder addClientScopeClientRoleMapping(String clientName, String clientScopeName, String... roleNames) {
ScopeMappingRepresentation mapping = new ScopeMappingRepresentation();
mapping.setClientScope(clientScopeName);
for (String roleName : roleNames) {
mapping.role(roleName);
}
Map<String, List<ScopeMappingRepresentation>> mappings = rep.getClientScopeMappings();
if (mappings == null) {
mappings = new HashMap<>();
rep.setClientScopeMappings(mappings);
}
mappings.computeIfAbsent(clientName, k -> new LinkedList<>()).add(mapping);
return this;
}
/**
* Best practice is to use other convenience methods when configuring a realm, but while the framework is under
* active development there may not be a way to perform all updates required. In these cases this method allows

View file

@ -0,0 +1,540 @@
package org.keycloak.tests.oauth.tokenexchange;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException;
import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.common.util.CollectionUtil;
import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.http.simple.SimpleHttp;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.encode.AccessTokenContext;
import org.keycloak.protocol.oidc.encode.TokenContextEncoderProvider;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.RealmEventsConfigRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.oidc.TokenMetadataRepresentation;
import org.keycloak.testframework.admin.AdminClientFactory;
import org.keycloak.testframework.annotations.InjectAdminClientFactory;
import org.keycloak.testframework.annotations.InjectEvents;
import org.keycloak.testframework.annotations.InjectKeycloakUrls;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.InjectSimpleHttp;
import org.keycloak.testframework.annotations.TestSetup;
import org.keycloak.testframework.events.EventAssertion;
import org.keycloak.testframework.events.Events;
import org.keycloak.testframework.oauth.OAuthClient;
import org.keycloak.testframework.oauth.annotations.InjectOAuthClient;
import org.keycloak.testframework.realm.ClientBuilder;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.RealmBuilder;
import org.keycloak.testframework.realm.RealmConfig;
import org.keycloak.testframework.realm.RoleBuilder;
import org.keycloak.testframework.realm.UserBuilder;
import org.keycloak.testframework.remote.runonserver.InjectRunOnServer;
import org.keycloak.testframework.remote.runonserver.RunOnServerClient;
import org.keycloak.testframework.remote.timeoffset.InjectTimeOffSet;
import org.keycloak.testframework.remote.timeoffset.TimeOffSet;
import org.keycloak.testframework.server.KeycloakUrls;
import org.keycloak.testframework.ui.annotations.InjectPage;
import org.keycloak.testframework.ui.page.ConsentPage;
import org.keycloak.tests.utils.admin.AdminApiUtil;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.UserInfoResponse;
import org.hamcrest.MatcherAssert;
import static org.keycloak.models.Constants.CREATE_DEFAULT_CLIENT_SCOPES;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
public class AbstractBaseTokenExchangeTest {
@InjectRealm(config = TokenExchangeRealm.class)
ManagedRealm realm;
@InjectEvents
Events events;
@InjectOAuthClient
OAuthClient oauth;
@InjectPage
ConsentPage consentPage;
@InjectTimeOffSet
TimeOffSet timeOffSet;
@InjectRunOnServer(permittedPackages = "org.keycloak.tests")
RunOnServerClient runOnServer;
@InjectAdminClientFactory
AdminClientFactory adminClientFactory;
@InjectKeycloakUrls
KeycloakUrls keycloakUrls;
@InjectSimpleHttp
SimpleHttp simpleHttp;
protected static UserRepresentation john;
protected static UserRepresentation mike;
protected static UserRepresentation alice;
protected static ClientRepresentation subjectClient;
protected static ClientRepresentation requesterClient;
protected static ClientRepresentation requesterClient2;
@TestSetup
public void setup() {
enableEvents(realm);
john = AdminApiUtil.findUserByUsername(realm.admin(), "john");
mike = AdminApiUtil.findUserByUsername(realm.admin(), "mike");
alice = AdminApiUtil.findUserByUsername(realm.admin(), "alice");
subjectClient = AdminApiUtil.findClientByClientId(realm.admin(), "subject-client").toRepresentation();
requesterClient = AdminApiUtil.findClientByClientId(realm.admin(), "requester-client").toRepresentation();
requesterClient2 = AdminApiUtil.findClientByClientId(realm.admin(), "requester-client-2").toRepresentation();
}
private void enableEvents(ManagedRealm realm) {
RealmEventsConfigRepresentation realmEventsConfig = realm.admin().getRealmEventsConfig();
List<String> enabledEventTypes = realmEventsConfig.getEnabledEventTypes();
if (!enabledEventTypes.contains(EventType.REFRESH_TOKEN.name())) {
enabledEventTypes.add(EventType.REFRESH_TOKEN.name());
enabledEventTypes.add(EventType.INTROSPECT_TOKEN.name());
realm.admin().updateRealmEventsConfig(realmEventsConfig);
}
}
public static class TokenExchangeRealm implements RealmConfig {
@Override
public RealmBuilder configure(RealmBuilder realm) {
realm.eventsEnabled(true);
realm.attribute(CREATE_DEFAULT_CLIENT_SCOPES, String.valueOf(true));
// Client Scopes
realm.clientScopes(createClientScope("default-scope1"));
realm.clientScopes(createClientScope("optional-scope2"));
realm.clientScopes(createClientScope("optional-scope3"));
realm.clientScopes(createClientScope("optional-requester-scope"));
// Clients
realm.clients(ClientBuilder.create().clientId("subject-client")
.secret("secret")
.redirectUris("*")
.webOrigins("*")
.directAccessGrantsEnabled(true)
.serviceAccountsEnabled(true)
.fullScopeEnabled(false)
.attribute("standard.token.exchange.enabled", "true")
.defaultClientScopes("service_account", "acr", "roles", "profile", "basic", "email")
.optionalClientScopes("optional-scope2", "optional-requester-scope", OAuth2Constants.OFFLINE_ACCESS)
.protocolMappers(createAudienceMapper("requester-client", "requester-client"), createAudienceMapper("subject-client", "subject-client")));
realm.users(UserBuilder.create().username(ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "subject-client")
.serviceAccountId("subject-client")
.clientRoles("target-client1", "target-client1-role"));
realm.clients(ClientBuilder.create().clientId("requester-client")
.secret("secret")
.redirectUris("*")
.webOrigins("*")
.directAccessGrantsEnabled(true)
.serviceAccountsEnabled(true)
.fullScopeEnabled(false)
.attribute("standard.token.exchange.enabled", "true")
.defaultClientScopes("service_account", "acr", "default-scope1", "roles", "basic")
.optionalClientScopes("optional-scope2", "optional-requester-scope", "offline_access", "profile", "email")
.protocolMappers(createAudienceMapper("audience-requester-client", "requester-client")));
realm.clients(ClientBuilder.create().clientId("requester-client-2")
.secret("secret")
.redirectUris("*")
.webOrigins("*")
.directAccessGrantsEnabled(true)
.serviceAccountsEnabled(true)
.fullScopeEnabled(false)
.attribute("standard.token.exchange.enabled", "true")
.defaultClientScopes("service_account", "acr", "default-scope1", "roles", "basic")
.optionalClientScopes("optional-scope2", "optional-requester-scope", "offline_access", "profile", "email")
.protocolMappers(createAudienceMapper("audience-requester-client-2", "requester-client-2")));
realm.clients(ClientBuilder.create().clientId("requester-client-public")
.publicClient(true)
.redirectUris("*")
.webOrigins("*")
.directAccessGrantsEnabled(true)
.fullScopeEnabled(true)
.attribute("standard.token.exchange.enabled", "true")
.defaultClientScopes("acr", "roles", "basic")
.optionalClientScopes("optional-scope2", "optional-requester-scope"));
realm.clients(ClientBuilder.create().clientId("target-client1")
.publicClient(true)
.redirectUris("*")
.webOrigins("*")
.fullScopeEnabled(true)
.defaultClientScopes("acr", "roles", "basic"));
realm.clients(ClientBuilder.create().clientId("target-client2")
.publicClient(true)
.redirectUris("*")
.webOrigins("*")
.fullScopeEnabled(true)
.defaultClientScopes("acr", "roles", "basic"));
realm.clients(ClientBuilder.create().clientId("target-client3")
.publicClient(true)
.redirectUris("*")
.webOrigins("*")
.fullScopeEnabled(true)
.defaultClientScopes("acr", "roles", "basic"));
realm.clients(ClientBuilder.create().clientId("invalid-requester-client")
.secret("secret")
.redirectUris("*")
.webOrigins("*")
.directAccessGrantsEnabled(true)
.serviceAccountsEnabled(true)
.fullScopeEnabled(true)
.attribute("standard.token.exchange.enabled", "true")
.defaultClientScopes("service_account", "acr", "roles", "basic"));
realm.clients(ClientBuilder.create().clientId("disabled-requester-client")
.secret("secret")
.redirectUris("*")
.webOrigins("*")
.directAccessGrantsEnabled(true)
.serviceAccountsEnabled(true)
.fullScopeEnabled(true)
.attribute("standard.token.exchange.enabled", "false")
.defaultClientScopes("service_account", "acr", "roles", "basic"));
// Realm roles
realm.roles("all-target", "offline_access", "uma_authorization");
realm.realmRoles(RoleBuilder.create().name("default-roles-test")
.description("${role_default-roles}")
.composite(true)
.realmComposite("offline_access")
.realmComposite("uma_authorization")
.clientComposite("account", "view-profile")
.clientComposite("account", "manage-account"));
// Client roles (must be after clients are created)
realm.clientRoles("requester-client", "requester-client-role");
realm.clientRoles("requester-client-2", "requester-client-2-role");
realm.clientRoles("target-client1", "target-client1-role");
realm.clientRoles("target-client2", "target-client2-role");
realm.clientRoles("target-client3", "target-client3-role");
// Scope mappings: map realm roles to client scopes
realm.addClientScopeRealmRoleMapping("optional-scope2", "all-target");
realm.addClientScopeRealmRoleMapping("offline_access", "offline_access");
// Client scope mappings: map client roles to client scopes
realm.addClientScopeClientRoleMapping("target-client1", "default-scope1", "target-client1-role");
realm.addClientScopeClientRoleMapping("target-client2", "optional-scope2", "target-client2-role");
realm.addClientScopeClientRoleMapping("requester-client", "optional-requester-scope", "requester-client-role");
realm.addClientScopeClientRoleMapping("requester-client-2", "optional-requester-scope", "requester-client-2-role");
// Users
realm.users(UserBuilder.create().username("alice")
.name("Alice", "Doe")
.email("alice@email.cz")
.password("password")
.realmRoles("default-roles-test")
.clientRoles("requester-client", "requester-client-role")
.clientRoles("requester-client-2", "requester-client-2-role"));
realm.users(UserBuilder.create().username("john")
.name("John", "Bar")
.email("john@email.cz")
.password("password")
.realmRoles("default-roles-test")
.clientRoles("target-client1", "target-client1-role")
.clientRoles("target-client2", "target-client2-role"));
realm.users(UserBuilder.create().username("mike")
.name("Mike", "Bar")
.email("mike@email.cz")
.password("password")
.realmRoles("default-roles-test", "all-target")
.clientRoles("target-client1", "target-client1-role"));
return realm;
}
private ClientScopeRepresentation createClientScope(String name) {
ClientScopeRepresentation cs = new ClientScopeRepresentation();
cs.setName(name);
cs.setProtocol("openid-connect");
Map<String, String> attrs = new HashMap<>();
attrs.put("include.in.token.scope", "true");
attrs.put("display.on.consent.screen", "true");
cs.setAttributes(attrs);
return cs;
}
private ProtocolMapperRepresentation createAudienceMapper(String name, String audience) {
ProtocolMapperRepresentation mapper = new ProtocolMapperRepresentation();
mapper.setName(name);
mapper.setProtocol("openid-connect");
mapper.setProtocolMapper("oidc-audience-mapper");
Map<String, String> config = new HashMap<>();
config.put("included.client.audience", audience);
config.put("id.token.claim", "false");
config.put("lightweight.claim", "false");
config.put("access.token.claim", "true");
config.put("introspection.token.claim", "true");
mapper.setConfig(config);
return mapper;
}
}
protected String getSessionIdFromToken(String accessToken) {
return verifyAccessToken(accessToken).getSessionId();
}
protected AccessTokenResponse resourceOwnerLogin(String username, String password, String clientId, String secret) {
return resourceOwnerLogin(username, password, clientId, secret, null);
}
protected AccessTokenResponse resourceOwnerLogin(String username, String password, String clientId, String secret, String scope) {
events.clear();
oauth.client(clientId, secret);
oauth.scope(scope);
oauth.openid(false);
AccessTokenResponse response = oauth.doPasswordGrantRequest(username, password);
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
AccessToken token = verifyAccessToken(response.getAccessToken());
EventAssertion.assertSuccess(events.poll())
.type(EventType.LOGIN)
.clientId(clientId)
.userId(token.getSubject())
.sessionId(token.getSessionId())
.details(Details.USERNAME, username);
return response;
}
protected String loginWithConsents(UserRepresentation user, String password, String clientId, String secret) {
oauth.client(clientId, secret).openLoginForm();
oauth.fillLoginForm(user.getUsername(), password);
consentPage.assertCurrent();
consentPage.confirm();
assertNotNull(oauth.parseLoginResponse().getCode());
AccessTokenResponse response = oauth.doAccessTokenRequest(oauth.parseLoginResponse().getCode());
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
AccessToken token = verifyAccessToken(response.getAccessToken());
final EventRepresentation loginEvent = events.poll();
EventAssertion.assertSuccess(loginEvent)
.type(EventType.LOGIN)
.clientId(clientId)
.userId(user.getId())
.sessionId(token.getSessionId())
.details(Details.USERNAME, user.getUsername())
.details(Details.CONSENT, Details.CONSENT_VALUE_CONSENT_GRANTED)
.hasCodeId();
final String codeId = loginEvent.getDetails().get(Details.CODE_ID);
EventAssertion.assertSuccess(events.poll())
.type(EventType.CODE_TO_TOKEN)
.clientId(clientId)
.userId(user.getId())
.sessionId(token.getSessionId())
.details(Details.CODE_ID, codeId);
return response.getAccessToken();
}
protected AccessTokenResponse tokenExchange(String subjectToken, String clientId, String secret, List<String> audience, String requestedTokenType) {
return oauth.tokenExchangeRequest(subjectToken).client(clientId, secret).audience(audience).requestedTokenType(requestedTokenType).send();
}
protected void isAccessTokenEnabled(String accessToken, String clientId, String secret) {
oauth.client(clientId, secret);
TokenMetadataRepresentation rep = introspectToken(accessToken);
assertTrue(rep.isActive());
EventRepresentation event = events.poll();
EventAssertion.assertSuccess(event)
.type(EventType.INTROSPECT_TOKEN)
.clientId(clientId);
assertNotNull(event.getUserId());
assertNotNull(event.getSessionId());
}
protected void isAccessTokenDisabled(String accessTokenString, String clientId, String secret) {
// Test introspection endpoint not possible
oauth.client(clientId, secret);
TokenMetadataRepresentation rep = introspectToken(accessTokenString);
assertFalse(rep.isActive());
}
protected void isTokenEnabled(AccessTokenResponse tokenResponse, String clientId, String secret) {
isAccessTokenEnabled(tokenResponse.getAccessToken(), clientId, secret);
AccessTokenResponse tokenRefreshResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken());
assertEquals(Response.Status.OK.getStatusCode(), tokenRefreshResponse.getStatusCode());
}
protected void isTokenDisabled(AccessTokenResponse tokenResponse, String clientId, String secret) {
isAccessTokenDisabled(tokenResponse.getAccessToken(), clientId, secret);
oauth.client(clientId, secret);
AccessTokenResponse tokenRefreshResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken());
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), tokenRefreshResponse.getStatusCode());
}
protected void assertAudiences(AccessToken token, List<String> expectedAudiences) {
MatcherAssert.assertThat("Incompatible audiences", token.getAudience() == null ? List.of() : List.of(token.getAudience()), containsInAnyOrder(expectedAudiences.toArray()));
java.util.List<String> audsWithoutAzp = new java.util.ArrayList<>(expectedAudiences);
audsWithoutAzp.remove(token.getIssuedFor());
MatcherAssert.assertThat("Incompatible resource access", token.getResourceAccess().keySet(), containsInAnyOrder(audsWithoutAzp.toArray()));
}
protected void assertScopes(AccessToken token, List<String> expectedScopes) {
MatcherAssert.assertThat("Incompatible scopes", token.getScope().isEmpty() ? List.of() : List.of(token.getScope().split(" ")), containsInAnyOrder(expectedScopes.toArray()));
}
protected AccessToken assertAudiencesAndScopes(AccessTokenResponse tokenExchangeResponse, List<String> expectedAudiences, List<String> expectedScopes) {
assertEquals(Response.Status.OK.getStatusCode(), tokenExchangeResponse.getStatusCode());
AccessToken token = verifyAccessToken(tokenExchangeResponse.getAccessToken());
if (expectedAudiences == null) {
assertNull(token.getAudience(), "Expected token to not contain audience");
} else {
assertAudiences(token, expectedAudiences);
}
assertScopes(token, expectedScopes);
return token;
}
protected AccessToken assertAudiencesAndScopes(AccessTokenResponse tokenExchangeResponse, UserRepresentation user, List<String> expectedAudiences, List<String> expectedScopes) {
return assertAudiencesAndScopes(tokenExchangeResponse, user, expectedAudiences, expectedScopes, OAuth2Constants.ACCESS_TOKEN_TYPE, "subject-client");
}
protected AccessToken assertAudiencesAndScopes(AccessTokenResponse tokenExchangeResponse, UserRepresentation user,
List<String> expectedAudiences, List<String> expectedScopes, String expectedTokenType, String expectedSubjectTokenClientId) {
AccessToken token = assertAudiencesAndScopes(tokenExchangeResponse, expectedAudiences, expectedScopes);
EventRepresentation event = events.poll();
EventAssertion.assertSuccess(event)
.type(EventType.TOKEN_EXCHANGE)
.clientId(token.getIssuedFor())
.userId(user.getId())
.sessionId(token.getSessionId())
.details(Details.AUDIENCE, CollectionUtil.join(expectedAudiences, " "))
.details(Details.USERNAME, user.getUsername())
.details(Details.REQUESTED_TOKEN_TYPE, expectedTokenType)
.details(Details.SUBJECT_TOKEN_CLIENT_ID, expectedSubjectTokenClientId);
// Verify scopes
Set<String> expectedScopeSet = new HashSet<>(expectedScopes);
Set<String> actualScopeSet = new HashSet<>(Arrays.asList(event.getDetails().get(Details.SCOPE).split(" ")));
assertEquals(expectedScopeSet, actualScopeSet);
return token;
}
protected void assertIntrospectSuccess(String token, String clientId, String clientSecret, String userId) {
oauth.client(clientId, clientSecret);
TokenMetadataRepresentation rep = introspectToken(token);
assertTrue(rep.isActive());
assertEquals(userId, rep.getSubject());
}
protected void assertIntrospectError(String token) {
TokenMetadataRepresentation rep = introspectToken(token);
assertFalse(rep.isActive());
}
protected void assertUserInfoSuccess(String token, String clientId, String clientSecret, String userId) {
UserInfoResponse userInfoResp = oauth.client(clientId, clientSecret).userInfoRequest(token).send();
assertEquals(Response.Status.OK.getStatusCode(), userInfoResp.getStatusCode());
assertEquals(userId, userInfoResp.getUserInfo().getSub());
}
protected void assertUserInfoError(String token, String clientId, String clientSecret, String error, String errorDesciption) {
UserInfoResponse userInfoResp = oauth.client(clientId, clientSecret).userInfoRequest(token).send();
assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), userInfoResp.getStatusCode());
assertEquals(String.format("Bearer realm=\"%s\", error=\"%s\", error_description=\"%s\"", realm.getName(), error, errorDesciption),
userInfoResp.getHeader(HttpHeaders.WWW_AUTHENTICATE));
}
protected void assertAccessTokenContext(String jti, AccessTokenContext.SessionType sessionType, AccessTokenContext.TokenType tokenType, String grantType) {
AccessTokenContext ctx = getAccessTokenContext(jti);
assertEquals(sessionType, ctx.getSessionType());
assertEquals(tokenType, ctx.getTokenType());
assertEquals(grantType, ctx.getGrantType());
}
protected AccessTokenContext getAccessTokenContext(String jti) {
return runOnServer.fetch(session -> session.getProvider(TokenContextEncoderProvider.class).getTokenContextFromTokenId(jti), AccessTokenContext.class);
}
protected Integer getClientSessionsCountInUserSession(String sessionId) {
return runOnServer.fetch(session -> {
UserSessionModel sessionModel = session.sessions().getUserSession(session.getContext().getRealm(), sessionId);
if (sessionModel == null) {
throw new NotFoundException("Session not found");
}
return sessionModel.getAuthenticatedClientSessions().size();
}, Integer.class);
}
public IDToken verifyIdToken(String idToken) {
return verifyToken(idToken, IDToken.class);
}
public AccessToken verifyAccessToken(String accessToken) {
return verifyToken(accessToken, AccessToken.class);
}
public <T extends JsonWebToken> T verifyToken(String token, Class<T> clazz) {
TokenVerifier<T> tokenVerifier = TokenVerifier.create(token, clazz);
try {
return tokenVerifier.parse().getToken();
} catch (VerificationException e) {
fail("Error verifying token", e);
}
return null;
}
public TokenMetadataRepresentation introspectToken(String token) {
try {
return oauth.doIntrospectionAccessTokenRequest(token).asTokenMetadata();
} catch (IOException e) {
fail("Error during token introspection", e);
}
return null;
}
}

View file

@ -0,0 +1,231 @@
/*
* 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.tests.oauth.tokenexchange;
import java.util.List;
import jakarta.ws.rs.core.Response;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.protocol.oidc.mappers.HardcodedClaim;
import org.keycloak.representations.AccessToken;
import org.keycloak.services.clientpolicy.condition.ClientScopesCondition;
import org.keycloak.services.clientpolicy.condition.ClientScopesConditionFactory;
import org.keycloak.services.clientpolicy.condition.GrantTypeCondition;
import org.keycloak.services.clientpolicy.condition.GrantTypeConditionFactory;
import org.keycloak.services.clientpolicy.executor.DownscopeAssertionGrantEnforcerExecutorFactory;
import org.keycloak.services.clientpolicy.executor.JWTClaimEnforcerExecutor;
import org.keycloak.services.clientpolicy.executor.JWTClaimEnforcerExecutorFactory;
import org.keycloak.services.clientpolicy.executor.RejectRequestExecutorFactory;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.realm.ClientPolicyBuilder;
import org.keycloak.testframework.realm.ClientProfileBuilder;
import org.keycloak.testframework.util.ApiUtil;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
@KeycloakIntegrationTest
public class StandardBaseTokenExchangeClientPoliciesV2Test extends AbstractBaseTokenExchangeTest {
private static final String PROFILE_NAME = "MyProfile";
private static final String POLICY_NAME = "MyPolicy";
@Test
public void testClientPolicies() {
realm.updateClientProfile(List.of(
ClientProfileBuilder.create()
.name(PROFILE_NAME)
.description("Profilo")
.executor(RejectRequestExecutorFactory.PROVIDER_ID, null)
.build()
));
// Create client scopes condition configuration
ClientScopesCondition.Configuration scopesConfig = new ClientScopesCondition.Configuration();
scopesConfig.setType(ClientScopesConditionFactory.ANY);
scopesConfig.setScopes(List.of("optional-scope2"));
// Create grant type condition configuration
GrantTypeCondition.Configuration grantTypeConfig = ClientPolicyBuilder.grantTypeConditionConfiguration(
false, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE
);
// Create and update client policy
realm.updateClientPolicy(List.of(
ClientPolicyBuilder.create()
.name(POLICY_NAME)
.description("Client Scope Policy")
.condition(ClientScopesConditionFactory.PROVIDER_ID, scopesConfig)
.condition(GrantTypeConditionFactory.PROVIDER_ID, grantTypeConfig)
.profile(PROFILE_NAME)
.build()
));
String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken();
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1"), null);
assertAudiencesAndScopes(response, john, 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);
Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
Assertions.assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
Assertions.assertEquals("Request not allowed", response.getErrorDescription());
}
@Test
public void testDownscopeClientPolicies() {
// Create and update client profile
realm.updateClientProfile(List.of(
ClientProfileBuilder.create()
.name(PROFILE_NAME)
.description("Profile")
.executor(DownscopeAssertionGrantEnforcerExecutorFactory.PROVIDER_ID, null)
.build()
));
// Create grant type condition configuration
GrantTypeCondition.Configuration grantTypeConfig = ClientPolicyBuilder.grantTypeConditionConfiguration(
false, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE
);
// Create and update client policy
realm.updateClientPolicy(List.of(
ClientPolicyBuilder.create()
.name(POLICY_NAME)
.description("Client Scope Policy")
.condition(GrantTypeConditionFactory.PROVIDER_ID, grantTypeConfig)
.profile(PROFILE_NAME)
.build()
));
// request initial token with optional scope optional-scope2
String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret", "optional-scope2").getAccessToken();
AccessToken token = verifyAccessToken(accessToken);
assertScopes(token, List.of("email", "profile", "optional-scope2"));
// request with the all the scopes allowed in the initial token, all are optional in requester-client
// only those should be there, even default-scope1 is supressed
oauth.scope("email profile optional-scope2");
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertAudiencesAndScopes(response, john, List.of("target-client2"), List.of("email", "profile", "optional-scope2"));
// exchange with downscope to only optional-scope2
oauth.scope("optional-scope2");
response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertAudiencesAndScopes(response, john, List.of("target-client2"), List.of("optional-scope2"));
// exchange for a invisible scope returns error although it is added by default
oauth.scope("basic optional-scope2");
response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError());
assertEquals("Scopes [basic] not present in the initial access token [optional-scope2, profile, email]",
response.getErrorDescription());
// exchange for another optional that is not in the token
oauth.scope("optional-requester-scope");
response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError());
assertEquals("Scopes [optional-requester-scope] not present in the initial access token [optional-scope2, profile, email]",
response.getErrorDescription());
// exchange for a optional that is not in initial token
oauth.scope("default-scope1");
response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError());
assertEquals("Scopes [default-scope1] not present in the initial access token [optional-scope2, profile, email]",
response.getErrorDescription());
}
@Test
public void testJWTClaimClientPolicies() {
testJWTClaimClientPolicies("username", "testuser", "testuser", true, null);
testJWTClaimClientPolicies("username", "baduser", "testuser", false, "Value for claim 'username' not allowed");
testJWTClaimClientPolicies("username", "admin", "^(admin|service|test-[0-9]+)$", true, null);
testJWTClaimClientPolicies("username", "test-12345", "^(admin|service|test-[0-9]+)$", true, null);
testJWTClaimClientPolicies("username", "unknown-username", "^(admin|service|test-[0-9]+)$", false, "Value for claim 'username' not allowed");
testJWTClaimClientPolicies("username", "testuser", null, true, "Value for claim 'username' not allowed");
testJWTClaimClientPolicies("username", null, null, false, "Required claim 'username' is missing from the token");
}
private void testJWTClaimClientPolicies(String claimName, String claimValue, String executorRegex, boolean success, String errorMessage) {
// Add protocol mapper to subject-client
Response createMapperResponse = realm.admin().clients().get(subjectClient.getId()).getProtocolMappers().createMapper(
ModelToRepresentation.toRepresentation(
HardcodedClaim.create(claimName, claimName, claimValue, "String", true, true, true)
)
);
String mapperId = ApiUtil.getCreatedId(createMapperResponse);
createMapperResponse.close();
// Create executor configuration
JWTClaimEnforcerExecutor.Configuration claimsConfig = new JWTClaimEnforcerExecutor.Configuration();
claimsConfig.setClaimName(claimName);
claimsConfig.setAllowedValue(executorRegex);
// Create and update client profile
realm.updateClientProfile(List.of(
ClientProfileBuilder.create()
.name(PROFILE_NAME)
.description("Profile")
.executor(JWTClaimEnforcerExecutorFactory.PROVIDER_ID, claimsConfig)
.build()
));
// Create grant type condition configuration
GrantTypeCondition.Configuration grantTypeConfig = ClientPolicyBuilder.grantTypeConditionConfiguration(
false, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE
);
// Create and update client policy
realm.updateClientPolicy(List.of(
ClientPolicyBuilder.create()
.name(POLICY_NAME)
.description("Client Scope Policy")
.condition(GrantTypeConditionFactory.PROVIDER_ID, grantTypeConfig)
.profile(PROFILE_NAME)
.build()
));
String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken();
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, null);
if (success) {
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
} else {
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
assertEquals(errorMessage, response.getErrorDescription());
}
realm.admin().clients().get(subjectClient.getId()).getProtocolMappers().delete(mapperId);
}
}

View file

@ -0,0 +1,968 @@
/*
* 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.tests.oauth.tokenexchange;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.core.Response;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.models.AccountRoles;
import org.keycloak.models.AdminRoles;
import org.keycloak.models.Constants;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.encode.AccessTokenContext;
import org.keycloak.protocol.oidc.mappers.AudienceProtocolMapper;
import org.keycloak.protocol.oidc.mappers.HardcodedClaim;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.events.EventAssertion;
import org.keycloak.testframework.util.ApiUtil;
import org.keycloak.tests.utils.admin.AdminApiUtil;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.TokenExchangeRequest;
import org.keycloak.testsuite.util.oauth.TokenRevocationResponse;
import org.keycloak.util.TokenUtil;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
@KeycloakIntegrationTest
public class StandardBaseTokenExchangeV2Test extends AbstractBaseTokenExchangeTest {
@Test
public void testSubjectTokenType() {
String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken();
TokenExchangeRequest request = oauth.tokenExchangeRequest(accessToken, OAuth2Constants.ACCESS_TOKEN_TYPE);
AccessTokenResponse response = request.send();
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
request = oauth.tokenExchangeRequest(accessToken, OAuth2Constants.REFRESH_TOKEN_TYPE);
response = request.send();
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
request = oauth.tokenExchangeRequest(accessToken, OAuth2Constants.ID_TOKEN_TYPE);
response = request.send();
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
request = oauth.tokenExchangeRequest(accessToken, OAuth2Constants.SAML2_TOKEN_TYPE);
response = request.send();
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
request = oauth.tokenExchangeRequest(accessToken, OAuth2Constants.JWT_TOKEN_TYPE);
response = request.send();
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
request = oauth.tokenExchangeRequest(accessToken, "WRONG_TOKEN_TYPE");
response = request.send();
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
}
@Test
public void testRequestedTokenType() {
realm.cleanup().add(realm1 -> {
requesterClient.getAttributes().put(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, OIDCAdvancedConfigWrapper.TokenExchangeRefreshTokenEnabled.SAME_SESSION.name());
realm1.clients().get(requesterClient.getId()).update(requesterClient);
});
String accessToken = resourceOwnerLogin(john.getUsername(), "password", "subject-client", "secret").getAccessToken();
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, OAuth2Constants.ACCESS_TOKEN_TYPE);
assertAudiencesAndScopes(response, john, List.of("requester-client", "target-client1"), List.of("default-scope1"));
assertNotNull(response.getAccessToken());
assertEquals(TokenUtil.TOKEN_TYPE_BEARER, response.getTokenType());
assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType());
requesterClient.getAttributes().put(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, OIDCAdvancedConfigWrapper.TokenExchangeRefreshTokenEnabled.NO.name());
realm.admin().clients().get(requesterClient.getId()).update(requesterClient);
response = tokenExchange(accessToken, "requester-client", "secret", null, OAuth2Constants.REFRESH_TOKEN_TYPE);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
assertEquals("requested_token_type unsupported", response.getErrorDescription());
EventRepresentation event = events.poll();
EventAssertion.assertError(event)
.type(EventType.TOKEN_EXCHANGE_ERROR)
.clientId("requester-client")
.error(Errors.INVALID_REQUEST)
.userId(john.getId())
.details(Details.REASON, "requested_token_type unsupported")
.details(Details.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE)
.details(Details.SUBJECT_TOKEN_CLIENT_ID, "subject-client");
assertNotNull(event.getSessionId());
requesterClient.getAttributes().put(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, OIDCAdvancedConfigWrapper.TokenExchangeRefreshTokenEnabled.SAME_SESSION.name());
realm.admin().clients().get(requesterClient.getId()).update(requesterClient);
response = tokenExchange(accessToken, "requester-client", "secret", null, OAuth2Constants.REFRESH_TOKEN_TYPE);
assertAudiencesAndScopes(response, john, List.of("requester-client", "target-client1"), List.of("default-scope1"), OAuth2Constants.REFRESH_TOKEN_TYPE, "subject-client");
assertNotNull(response.getAccessToken());
assertEquals(TokenUtil.TOKEN_TYPE_BEARER, response.getTokenType());
assertEquals(OAuth2Constants.REFRESH_TOKEN_TYPE, response.getIssuedTokenType());
response = tokenExchange(accessToken, "requester-client", "secret", null, OAuth2Constants.ID_TOKEN_TYPE);
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
assertNotNull(response.getAccessToken());
assertEquals(TokenUtil.TOKEN_TYPE_NA, response.getTokenType());
assertEquals(OAuth2Constants.ID_TOKEN_TYPE, response.getIssuedTokenType());
event = events.poll();
EventAssertion.assertSuccess(event)
.type(EventType.TOKEN_EXCHANGE)
.clientId("requester-client")
.userId(john.getId())
.details(Details.REQUESTED_TOKEN_TYPE, OAuth2Constants.ID_TOKEN_TYPE)
.details(Details.SUBJECT_TOKEN_CLIENT_ID, "subject-client");
assertNotNull(event.getSessionId());
response = tokenExchange(accessToken, "requester-client", "secret", null, OAuth2Constants.JWT_TOKEN_TYPE);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
assertEquals("requested_token_type unsupported", response.getErrorDescription());
event = events.poll();
EventAssertion.assertError(event)
.type(EventType.TOKEN_EXCHANGE_ERROR)
.clientId("requester-client")
.error(Errors.INVALID_REQUEST)
.userId(john.getId())
.details(Details.REASON, "requested_token_type unsupported")
.details(Details.REQUESTED_TOKEN_TYPE, OAuth2Constants.JWT_TOKEN_TYPE)
.details(Details.SUBJECT_TOKEN_CLIENT_ID, "subject-client");
assertNotNull(event.getSessionId());
response = tokenExchange(accessToken, "requester-client", "secret", null, OAuth2Constants.SAML2_TOKEN_TYPE);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
assertEquals("requested_token_type unsupported", response.getErrorDescription());
event = events.poll();
EventAssertion.assertError(event)
.type(EventType.TOKEN_EXCHANGE_ERROR)
.clientId("requester-client")
.error(Errors.INVALID_REQUEST)
.userId(john.getId())
.details(Details.REASON, "requested_token_type unsupported")
.details(Details.REQUESTED_TOKEN_TYPE, OAuth2Constants.SAML2_TOKEN_TYPE)
.details(Details.SUBJECT_TOKEN_CLIENT_ID, "subject-client");
assertNotNull(event.getSessionId());
response = tokenExchange(accessToken, "requester-client", "secret", null, "WRONG_TOKEN_TYPE");
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
assertEquals("requested_token_type unsupported", response.getErrorDescription());
event = events.poll();
EventAssertion.assertError(event)
.type(EventType.TOKEN_EXCHANGE_ERROR)
.clientId("requester-client")
.error(Errors.INVALID_REQUEST)
.userId(john.getId())
.details(Details.REASON, "requested_token_type unsupported")
.details(Details.REQUESTED_TOKEN_TYPE, "WRONG_TOKEN_TYPE")
.details(Details.SUBJECT_TOKEN_CLIENT_ID, "subject-client");
assertNotNull(event.getSessionId());
}
@Test
public void testExchange() {
String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken();
// Test successful exchange
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType());
String exchangedTokenString = response.getAccessToken();
AccessToken exchangedToken = verifyAccessToken(exchangedTokenString);
assertEquals(getSessionIdFromToken(accessToken), exchangedToken.getSessionId());
assertEquals("requester-client", exchangedToken.getIssuedFor());
EventRepresentation event = events.poll();
EventAssertion.assertSuccess(event)
.type(EventType.TOKEN_EXCHANGE)
.clientId(exchangedToken.getIssuedFor())
.userId(john.getId())
.sessionId(exchangedToken.getSessionId())
.details(Details.USERNAME, john.getUsername());
// Test exchange not allowed - invalid client is not in the subject-client audience
response = tokenExchange(accessToken, "invalid-requester-client", "secret", null, null);
assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatusCode());
event = events.poll();
EventAssertion.assertError(event)
.type(EventType.TOKEN_EXCHANGE_ERROR)
.clientId("invalid-requester-client")
.error(Errors.NOT_ALLOWED)
.userId(john.getId())
.details(Details.REASON, "client is not within the token audience");
assertNotNull(event.getSessionId());
}
@Test
public void testTransientSessionForRequester() {
String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken();
oauth.scope(OAuth2Constants.SCOPE_OPENID); // add openid scope for the user-info request
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType());
String exchangedTokenString = response.getAccessToken();
AccessToken exchangedToken = verifyAccessToken(exchangedTokenString);
assertEquals(getSessionIdFromToken(accessToken), exchangedToken.getSessionId());
assertEquals("requester-client", exchangedToken.getIssuedFor());
assertAccessTokenContext(exchangedToken.getId(), AccessTokenContext.SessionType.ONLINE_TRANSIENT_CLIENT,
AccessTokenContext.TokenType.REGULAR, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE);
// assert introspection and user-info works
assertIntrospectSuccess(exchangedTokenString, "requester-client", "secret", john.getId());
assertUserInfoSuccess(exchangedTokenString, "requester-client", "secret", john.getId());
// assert introspection and user-info works in 10s
timeOffSet.set(10);
assertIntrospectSuccess(exchangedTokenString, "requester-client", "secret", john.getId());
assertUserInfoSuccess(exchangedTokenString, "requester-client", "secret", john.getId());
// assert introspection and user-info fails with session deleted
realm.admin().deleteSession(exchangedToken.getSessionId(), false);
assertIntrospectError(exchangedTokenString);
assertUserInfoError(exchangedTokenString, "requester-client", "secret", "invalid_token", Errors.USER_SESSION_NOT_FOUND);
}
@Test
public void testExchangeRequestAccessTokenType() {
String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken();
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, OAuth2Constants.ACCESS_TOKEN_TYPE);
assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType());
String exchangedTokenString = response.getAccessToken();
AccessToken exchangedToken = verifyAccessToken(exchangedTokenString);
assertEquals(getSessionIdFromToken(accessToken), exchangedToken.getSessionId());
assertEquals("requester-client", exchangedToken.getIssuedFor());
}
@Test
public void testExchangeForIdToken() {
String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret").getAccessToken();
// Exchange request with "scope=oidc" . ID Token should be issued in addition to access-token
oauth.openid(true);
oauth.scope(OAuth2Constants.SCOPE_OPENID);
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, OAuth2Constants.ACCESS_TOKEN_TYPE);
assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType());
AccessToken exchangedToken = verifyAccessToken(response.getAccessToken());
assertEquals(TokenUtil.TOKEN_TYPE_BEARER, exchangedToken.getType());
IDToken exchangedIdToken = verifyIdToken(response.getIdToken());
assertEquals(TokenUtil.TOKEN_TYPE_ID, exchangedIdToken.getType());
assertEquals(getSessionIdFromToken(accessToken), exchangedIdToken.getSessionId());
assertEquals("requester-client", exchangedIdToken.getIssuedFor());
// Exchange request without "scope=oidc" . Only access-token should be issued, but not ID Token
oauth.openid(false);
oauth.scope(null);
response = tokenExchange(accessToken, "requester-client", "secret", null, OAuth2Constants.ACCESS_TOKEN_TYPE);
assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType());
assertNotNull(response.getAccessToken());
assertNull(response.getIdToken(), "ID Token was present, but should not be present");
// Exchange request requesting id-token. ID Token should be issued inside "access_token" parameter (as per token-exchange specification https://datatracker.ietf.org/doc/html/rfc8693#name-successful-response - parameter "access_token")
response = tokenExchange(accessToken, "requester-client", "secret", null, OAuth2Constants.ID_TOKEN_TYPE);
assertEquals(OAuth2Constants.ID_TOKEN_TYPE, response.getIssuedTokenType());
assertEquals(TokenUtil.TOKEN_TYPE_NA, response.getTokenType());
assertNotNull(response.getAccessToken());
assertNull(response.getIdToken(), "ID Token was present, but should not be present");
exchangedIdToken = verifyAccessToken(response.getAccessToken());
assertEquals(TokenUtil.TOKEN_TYPE_ID, exchangedIdToken.getType());
assertEquals(getSessionIdFromToken(accessToken), exchangedIdToken.getSessionId());
assertEquals("requester-client", exchangedIdToken.getIssuedFor());
}
@Test
public void testExchangeUsingServiceAccount() {
final UserRepresentation user = realm.admin().clients().get(subjectClient.getId()).getServiceAccountUser();
oauth.scope(null);
oauth.client("subject-client", "secret");
AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest();
String accessToken = response.getAccessToken();
AccessToken token = verifyAccessToken(accessToken);
assertNull(token.getSessionId());
EventRepresentation event = events.poll();
EventAssertion.assertSuccess(event)
.type(EventType.CLIENT_LOGIN)
.clientId("subject-client")
.userId(user.getId())
.sessionId(token.getSessionId())
.details(Details.USERNAME, user.getUsername())
.details(Details.GRANT_TYPE, OAuth2Constants.CLIENT_CREDENTIALS);
response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertAudiencesAndScopes(response, user, List.of("requester-client", "target-client1"), List.of("default-scope1"));
assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType());
String exchangedTokenString = response.getAccessToken();
AccessToken exchangedToken = verifyAccessToken(exchangedTokenString);
assertNull(exchangedToken.getSessionId());
assertEquals("requester-client", exchangedToken.getIssuedFor());
requesterClient.getAttributes().put(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, OIDCAdvancedConfigWrapper.TokenExchangeRefreshTokenEnabled.SAME_SESSION.name());
realm.admin().clients().get(requesterClient.getId()).update(requesterClient);
response = tokenExchange(accessToken, "requester-client", "secret", null,
OAuth2Constants.REFRESH_TOKEN_TYPE);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(Errors.INVALID_REQUEST, response.getError());
assertEquals("Refresh token not valid as requested_token_type because creating a new session is needed", response.getErrorDescription());
event = events.poll();
EventAssertion.assertError(event)
.type(EventType.TOKEN_EXCHANGE_ERROR)
.clientId("requester-client")
.error(Errors.INVALID_REQUEST)
.userId(user.getId())
.details(Details.REASON, "Refresh token not valid as requested_token_type because creating a new session is needed");
requesterClient.getAttributes().put(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, OIDCAdvancedConfigWrapper.TokenExchangeRefreshTokenEnabled.NO.name());
realm.admin().clients().get(requesterClient.getId()).update(requesterClient);
}
@Test
public void testClientExchangeToItself() {
String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret").getAccessToken();
AccessTokenResponse response = tokenExchange(accessToken, "subject-client", "secret", null, null);
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
response = tokenExchange(accessToken, "subject-client", "secret", List.of("subject-client"), null);
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
}
@Test
public void testClientExchangeToItselfWithConsents() {
String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret").getAccessToken();
subjectClient.setConsentRequired(true);
realm.admin().clients().get(subjectClient.getId()).update(subjectClient);
AccessTokenResponse response = tokenExchange(accessToken, "subject-client", "secret", null, null);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError());
assertEquals("Missing consents for Token Exchange in client subject-client", response.getErrorDescription());
response = tokenExchange(accessToken, "subject-client", "secret", List.of("subject-client"), null);
assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError());
assertEquals("Missing consents for Token Exchange in client subject-client", response.getErrorDescription());
subjectClient.setConsentRequired(false);
realm.admin().clients().get(subjectClient.getId()).update(subjectClient);
}
@Test
public void testExchangeWithPublicClient() {
String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret").getAccessToken();
AccessTokenResponse response = tokenExchange(accessToken, "requester-client-public", null, null, null);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_CLIENT, response.getError());
assertEquals("Public client is not allowed to exchange token", response.getErrorDescription());
}
@Test
public void testOptionalScopeParamRequestedWithoutAudience() {
String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret").getAccessToken();
oauth.scope("optional-scope2");
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertAudiencesAndScopes(response, john, List.of( "requester-client", "target-client1", "target-client2"), List.of("default-scope1", "optional-scope2"));
}
@Test
public void testAudienceRequested() {
String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret").getAccessToken();
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1"), null);
assertAudiencesAndScopes(response, john, List.of("target-client1"), List.of("default-scope1"));
}
@Test
public void testUnavailableAudienceRequested() {
String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret").getAccessToken();
// request invalid client audience
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1", "invalid-client"), null);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_CLIENT, response.getError());
assertEquals("Audience not found", response.getErrorDescription());
// The "target-client3" is valid client, but audience unavailable to the user. Request not allowed
response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1", "target-client3"), null);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
assertEquals("Requested audience not available: target-client3", response.getErrorDescription());
}
@Test
public void testScopeNotAllowed() {
String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken();
//scope not allowed
oauth.scope("optional-scope3");
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1", "target-client3"), null);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError());
assertEquals("Invalid scopes: optional-scope3", response.getErrorDescription());
//scope that doesn't exist
oauth.scope("bad-scope");
response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1", "target-client3"), null);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError());
assertEquals("Invalid scopes: bad-scope", response.getErrorDescription());
}
@Test
public void testScopeFilter() {
String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken();
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client2"), null);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
assertEquals("Requested audience not available: target-client2", response.getErrorDescription());
EventRepresentation event = events.poll();
EventAssertion.assertError(event)
.type(EventType.TOKEN_EXCHANGE_ERROR)
.clientId("requester-client")
.error(Errors.INVALID_REQUEST)
.userId(john.getId())
.details(Details.REASON, "Requested audience not available: target-client2");
assertNotNull(event.getSessionId());
oauth.scope("optional-scope2");
response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1"), null);
assertAudiencesAndScopes(response, john, 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, john, List.of("target-client2"), List.of("optional-scope2"));
oauth.scope("optional-scope2");
response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1", "target-client2"), null);
assertAudiencesAndScopes(response, john, List.of("target-client1", "target-client2"), List.of("default-scope1", "optional-scope2"));
//just check that the exchanged token contains the optional-scope2 mapped by the realm role
accessToken = resourceOwnerLogin("mike", "password","subject-client", "secret").getAccessToken();
oauth.scope("optional-scope2");
response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertAudiencesAndScopes(response, mike, List.of("requester-client", "target-client1"), List.of("default-scope1", "optional-scope2"));
accessToken = resourceOwnerLogin("mike", "password","subject-client", "secret").getAccessToken();
oauth.scope("optional-scope2");
response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1"), null);
assertAudiencesAndScopes(response, mike, List.of("target-client1"), List.of("default-scope1", "optional-scope2"));
}
@Test
public void testExchangeDisabledOnClient() {
String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken();
AccessTokenResponse response = tokenExchange(accessToken, "disabled-requester-client", "secret", null, null);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
assertEquals("Standard token exchange is not enabled for the requested client", response.getErrorDescription());
}
@Test
public void testConsents() {
requesterClient.setConsentRequired(true);
realm.admin().clients().get(requesterClient.getId()).update(requesterClient);
// initial TE without any consent should fail
String accessToken = resourceOwnerLogin("mike", "password", "subject-client", "secret").getAccessToken();
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError());
assertEquals("Missing consents for Token Exchange in client requester-client", response.getErrorDescription());
EventAssertion.assertError(events.poll())
.type(EventType.TOKEN_EXCHANGE_ERROR)
.clientId("requester-client")
.error(Errors.CONSENT_DENIED)
.userId(mike.getId())
.details(Details.REASON, "Missing consents for Token Exchange in client requester-client");
// logout
realm.admin().users().get(mike.getId()).logout();
// perform a login and allow consent for default scopes, TE should work now
accessToken = loginWithConsents(mike, "password", "requester-client", "secret");
response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertAudiencesAndScopes(response, mike, List.of("requester-client", "target-client1"), List.of("default-scope1"), OAuth2Constants.ACCESS_TOKEN_TYPE, "requester-client");
// request TE with optional-scope2 whose consent is missing, should fail
oauth.scope("optional-scope2");
response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError());
assertEquals("Missing consents for Token Exchange in client requester-client", response.getErrorDescription());
EventAssertion.assertError(events.poll())
.type(EventType.TOKEN_EXCHANGE_ERROR)
.clientId("requester-client")
.error(Errors.CONSENT_DENIED)
.userId(mike.getId())
.details(Details.REASON, "Missing consents for Token Exchange in client requester-client");
// logout
realm.admin().users().get(mike.getId()).logout();
// consent the additional scope, TE should work now
accessToken = loginWithConsents(mike, "password", "requester-client", "secret");
response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertAudiencesAndScopes(response, mike, List.of("requester-client", "target-client1"), List.of("default-scope1", "optional-scope2"),
OAuth2Constants.ACCESS_TOKEN_TYPE, "requester-client");
requesterClient.setConsentRequired(false);
realm.admin().clients().get(requesterClient.getId()).update(requesterClient);
}
@Test
public void testIntrospectionWithExchangedTokenAfterSSOLoginOfRequesterClient() {
// Login with "subject-client" and create SSO session
subjectClient.setConsentRequired(true);
realm.admin().clients().get(subjectClient.getId()).update(subjectClient);
String accessToken = loginWithConsents(mike, "password", "subject-client", "secret");
// Token exchange access-token for "Requester-client" . No client session yet for "requester-client" at this stage
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, null);
String exchangedToken = response.getAccessToken();
assertNotNull(exchangedToken);
// Set time offset
timeOffSet.set(10);
// SSO login to "requester-client". Will create client session for "requester-client"
oauth.client("requester-client", "secret").openLoginForm();
assertNotNull(oauth.parseLoginResponse().getCode());
response = oauth.doAccessTokenRequest(oauth.parseLoginResponse().getCode());
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
String requesterClientToken = response.getAccessToken();
// Token introspection with the previously exchanged token should success. Also with the new token should success
assertIntrospectSuccess(exchangedToken, "requester-client", "secret", mike.getId());
assertIntrospectSuccess(requesterClientToken, "requester-client", "secret", mike.getId());
subjectClient.setConsentRequired(false);
realm.admin().clients().get(subjectClient.getId()).update(subjectClient);
}
@Test
public void testTokenRevocation() {
requesterClient.getAttributes().put(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, OIDCAdvancedConfigWrapper.TokenExchangeRefreshTokenEnabled.SAME_SESSION.name());
realm.admin().clients().get(requesterClient.getId()).update(requesterClient);
AccessTokenResponse accessTokenResponse = resourceOwnerLogin("john", "password", "subject-client", "secret");
//revoke the exchanged access token
AccessTokenResponse tokenExchangeResponse = tokenExchange(accessTokenResponse.getAccessToken(), "requester-client", "secret", null, OAuth2Constants.REFRESH_TOKEN_TYPE);
oauth.client("requester-client", "secret");
events.clear();
oauth.doTokenRevoke(tokenExchangeResponse.getAccessToken());
EventAssertion.assertSuccess(events.poll())
.type(EventType.REVOKE_GRANT)
.clientId("requester-client")
.userId(john.getId());
isAccessTokenEnabled(accessTokenResponse.getAccessToken(), "subject-client", "secret");
isAccessTokenDisabled(tokenExchangeResponse.getAccessToken(), "requester-client", "secret");
//revoke the exchanged refresh token
tokenExchangeResponse = tokenExchange(accessTokenResponse.getAccessToken(), "requester-client", "secret", null, OAuth2Constants.REFRESH_TOKEN_TYPE);
events.clear();
oauth.doTokenRevoke(tokenExchangeResponse.getRefreshToken());
EventAssertion.assertSuccess(events.poll())
.type(EventType.REVOKE_GRANT)
.clientId("requester-client")
.userId(john.getId())
.sessionId(tokenExchangeResponse.getSessionState());
isTokenDisabled(tokenExchangeResponse, "requester-client", "secret");
//revoke the subject access token
tokenExchangeResponse = tokenExchange(accessTokenResponse.getAccessToken(), "requester-client", "secret", null, OAuth2Constants.REFRESH_TOKEN_TYPE);
oauth.client("subject-client", "secret");
events.clear();
oauth.doTokenRevoke(accessTokenResponse.getAccessToken());
EventAssertion.assertSuccess(events.poll())
.type(EventType.REVOKE_GRANT)
.clientId("subject-client")
.userId(john.getId())
.details(Details.TOKEN_EXCHANGE_REVOKED_CLIENTS, "requester-client");
isAccessTokenDisabled(accessTokenResponse.getAccessToken(), "subject-client", "secret");
isTokenDisabled(tokenExchangeResponse, "requester-client", "secret");
//revoke the subject refresh token
accessTokenResponse = resourceOwnerLogin("john", "password", "subject-client", "secret");
tokenExchangeResponse = tokenExchange(accessTokenResponse.getAccessToken(), "requester-client", "secret", null, OAuth2Constants.REFRESH_TOKEN_TYPE);
assertEquals(Response.Status.OK.getStatusCode(), tokenExchangeResponse.getStatusCode());
oauth.client("subject-client", "secret");
events.clear();
oauth.doTokenRevoke(accessTokenResponse.getRefreshToken());
EventAssertion.assertSuccess(events.poll())
.type(EventType.REVOKE_GRANT)
.clientId("subject-client")
.userId(john.getId())
.sessionId(tokenExchangeResponse.getSessionState())
.details(Details.TOKEN_EXCHANGE_REVOKED_CLIENTS, "requester-client");
isTokenDisabled(accessTokenResponse, "subject-client", "secret");
isTokenDisabled(tokenExchangeResponse, "requester-client", "secret");
//revoke multiple access token
AccessTokenResponse accessTokenResponse1 = resourceOwnerLogin("john", "password", "subject-client", "secret");
AccessTokenResponse accessTokenResponse2 = oauth.doRefreshTokenRequest(accessTokenResponse1.getRefreshToken());
AccessTokenResponse accessTokenResponse3 = oauth.doRefreshTokenRequest(accessTokenResponse1.getRefreshToken());
AccessTokenResponse tokenExchangeResponse1 = tokenExchange(accessTokenResponse1.getAccessToken(), "requester-client", "secret", null, OAuth2Constants.REFRESH_TOKEN_TYPE);
assertEquals(Response.Status.OK.getStatusCode(), tokenExchangeResponse1.getStatusCode());
AccessTokenResponse tokenExchangeResponse2 = tokenExchange(accessTokenResponse2.getAccessToken(), "requester-client", "secret", null, OAuth2Constants.REFRESH_TOKEN_TYPE);
assertEquals(Response.Status.OK.getStatusCode(), tokenExchangeResponse2.getStatusCode());
oauth.client("subject-client", "secret");
events.clear();
oauth.doTokenRevoke(accessTokenResponse3.getAccessToken());
EventAssertion.assertSuccess(events.poll())
.type(EventType.REVOKE_GRANT)
.clientId("subject-client")
.userId(john.getId())
.details(Details.TOKEN_EXCHANGE_REVOKED_CLIENTS, "requester-client");
isAccessTokenEnabled(accessTokenResponse1.getAccessToken(), "subject-client", "secret");
isAccessTokenEnabled(accessTokenResponse2.getAccessToken(), "subject-client", "secret");
isAccessTokenDisabled(accessTokenResponse3.getAccessToken(), "subject-client", "secret");
isTokenDisabled(tokenExchangeResponse1, "requester-client", "secret");
isTokenDisabled(tokenExchangeResponse2, "requester-client", "secret");
//revoke exchange chain if an already exchanged token is used for token exchange
// Create protocol mapper and save its ID for cleanup
ProtocolMapperRepresentation mapper = ModelToRepresentation.toRepresentation(
AudienceProtocolMapper.createClaimMapper("requester-client-2", "requester-client-2", null, true, false, true)
);
Response createMapperResponse = realm.admin().clients().get(requesterClient.getId()).getProtocolMappers().createMapper(mapper);
final String mapperId = ApiUtil.getCreatedId(createMapperResponse);
createMapperResponse.close();
requesterClient2.getAttributes().put(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, OIDCAdvancedConfigWrapper.TokenExchangeRefreshTokenEnabled.SAME_SESSION.name());
realm.admin().clients().get(requesterClient2.getId()).update(requesterClient2);
accessTokenResponse = resourceOwnerLogin("john", "password", "subject-client", "secret");
tokenExchangeResponse1 = tokenExchange(accessTokenResponse.getAccessToken(), "requester-client", "secret", null, OAuth2Constants.REFRESH_TOKEN_TYPE);
assertEquals(Response.Status.OK.getStatusCode(), tokenExchangeResponse1.getStatusCode());
tokenExchangeResponse2 = tokenExchange(tokenExchangeResponse1.getAccessToken(), "requester-client-2", "secret", null, OAuth2Constants.REFRESH_TOKEN_TYPE);
assertEquals(Response.Status.OK.getStatusCode(), tokenExchangeResponse2.getStatusCode());
oauth.client("subject-client", "secret");
events.clear();
oauth.doTokenRevoke(accessTokenResponse.getAccessToken());
EventRepresentation event = events.poll();
EventAssertion.assertSuccess(event)
.type(EventType.REVOKE_GRANT)
.clientId("subject-client")
.userId(john.getId());
// Verify revoked clients
Set<String> expectedClients = new HashSet<>(Arrays.asList("requester-client-2", "requester-client"));
Set<String> actualClients = new HashSet<>(Arrays.asList(event.getDetails().get(Details.TOKEN_EXCHANGE_REVOKED_CLIENTS).split(",")));
assertEquals(expectedClients, actualClients);
isTokenDisabled(tokenExchangeResponse1, "requester-client", "secret");
isTokenDisabled(tokenExchangeResponse2, "requester-client-2", "secret");
// Delete the protocol mapper and restore requester-client-2 attribute
realm.admin().clients().get(requesterClient.getId()).getProtocolMappers().delete(mapperId);
requesterClient.getAttributes().put(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, OIDCAdvancedConfigWrapper.TokenExchangeRefreshTokenEnabled.NO.name());
realm.admin().clients().get(requesterClient.getId()).update(requesterClient);
requesterClient2.getAttributes().put(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, OIDCAdvancedConfigWrapper.TokenExchangeRefreshTokenEnabled.NO.name());
realm.admin().clients().get(requesterClient2.getId()).update(requesterClient2);
}
@Test
public void testExchangeChainRequesters() {
String accessToken = resourceOwnerLogin("alice", "password", "subject-client", "secret", "optional-requester-scope").getAccessToken();
// exchange with requester-client
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("requester-client-2"), null);
assertAudiencesAndScopes(response, alice, List.of("requester-client-2"), List.of("optional-requester-scope"));
assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType());
String exchangedTokenString = response.getAccessToken();
AccessToken exchangedToken = verifyAccessToken(exchangedTokenString);
assertEquals(getSessionIdFromToken(accessToken), exchangedToken.getSessionId());
assertEquals("requester-client", exchangedToken.getIssuedFor());
// exchange now with requester-client-2
response = tokenExchange(exchangedTokenString, "requester-client-2", "secret", List.of("requester-client"), null);
assertAudiencesAndScopes(response, alice, List.of("requester-client"), List.of("optional-requester-scope"),
OAuth2Constants.ACCESS_TOKEN_TYPE, "requester-client");
assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType());
exchangedTokenString = response.getAccessToken();
exchangedToken = verifyAccessToken(exchangedTokenString);
assertEquals(getSessionIdFromToken(accessToken), exchangedToken.getSessionId());
assertEquals("requester-client-2", exchangedToken.getIssuedFor());
// exchange again with requester-client
response = tokenExchange(exchangedTokenString, "requester-client", "secret", List.of("requester-client-2"), null);
assertAudiencesAndScopes(response, alice, List.of("requester-client-2"), List.of("optional-requester-scope"),
OAuth2Constants.ACCESS_TOKEN_TYPE, "requester-client-2");
assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType());
exchangedTokenString = response.getAccessToken();
exchangedToken = verifyAccessToken(exchangedTokenString);
assertEquals(getSessionIdFromToken(accessToken), exchangedToken.getSessionId());
assertEquals("requester-client", exchangedToken.getIssuedFor());
// test revocation endpoint
isAccessTokenEnabled(response.getAccessToken(), "requester-client-2", "secret");
TokenRevocationResponse revocationResponse = oauth.client("requester-client", "secret").doTokenRevoke(response.getAccessToken());
assertNull(revocationResponse.getError());
EventAssertion.assertSuccess(events.poll())
.type(EventType.REVOKE_GRANT)
.clientId("requester-client")
.userId(alice.getId());
isAccessTokenDisabled(response.getAccessToken(), "requester-client", "secret");
}
@Test
public void testTransientOfflineSessionForRequester() {
RealmRepresentation realmRepresentation = realm.admin().toRepresentation();
realmRepresentation.setRememberMe(false);
realmRepresentation.setSsoSessionMaxLifespan(600);
realm.admin().update(realmRepresentation);
String offlineAccessScopeId = realm.admin().clientScopes().findAll().stream()
.filter(scope -> OAuth2Constants.OFFLINE_ACCESS.equals(scope.getName()))
.findFirst()
.orElseThrow()
.getId();
realm.admin().clients().get(subjectClient.getId()).addOptionalClientScope(offlineAccessScopeId);
// Login, which creates offline-session
AccessTokenResponse initialResponse = resourceOwnerLogin("john", "password", "subject-client", "secret", OAuth2Constants.OFFLINE_ACCESS);
String accessToken = initialResponse.getAccessToken();
// Regular token-exchange with the access token as requested_token_type
oauth.scope(OAuth2Constants.SCOPE_OPENID); // add openid scope for the user-info request
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType());
String exchangedTokenString = response.getAccessToken();
AccessToken exchangedToken = verifyAccessToken(exchangedTokenString);
assertNotNull(exchangedToken.isActive(), "Exchanged token is not active");
assertEquals(getSessionIdFromToken(accessToken), exchangedToken.getSessionId());
assertEquals("requester-client", exchangedToken.getIssuedFor());
assertAccessTokenContext(exchangedToken.getId(), AccessTokenContext.SessionType.OFFLINE_TRANSIENT_CLIENT,
AccessTokenContext.TokenType.REGULAR, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE);
// assert introspection and user-info works
assertIntrospectSuccess(exchangedTokenString, "requester-client", "secret", john.getId());
assertUserInfoSuccess(exchangedTokenString, "requester-client", "secret", john.getId());
// assert introspection and user-info works in 10s
timeOffSet.set(10);
assertIntrospectSuccess(exchangedTokenString, "requester-client", "secret", john.getId());
assertUserInfoSuccess(exchangedTokenString, "requester-client", "secret", john.getId());
// move time to be more than the normal expired session value, refresh and request another exchange
timeOffSet.set(610);
oauth.client("subject-client", "secret").scope(null);
AccessTokenResponse refreshResponse = oauth.doRefreshTokenRequest(initialResponse.getRefreshToken());
assertNull(refreshResponse.getError(), "Error refreshing the initial token: " + refreshResponse.getErrorDescription());
accessToken = refreshResponse.getAccessToken();
oauth.scope(OAuth2Constants.SCOPE_OPENID);
response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertNull(response.getError(), "Error exchanging the token: " + response.getErrorDescription());
exchangedTokenString = response.getAccessToken();
exchangedToken = verifyAccessToken(exchangedTokenString);
assertNotNull(exchangedToken.isActive(), "Exchanged token is not active");
assertEquals(getSessionIdFromToken(accessToken), exchangedToken.getSessionId());
assertEquals("requester-client", exchangedToken.getIssuedFor());
assertAccessTokenContext(exchangedToken.getId(), AccessTokenContext.SessionType.OFFLINE_TRANSIENT_CLIENT,
AccessTokenContext.TokenType.REGULAR, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE);
// assert introspection and user-info works
assertIntrospectSuccess(exchangedTokenString, "requester-client", "secret", john.getId());
assertUserInfoSuccess(exchangedTokenString, "requester-client", "secret", john.getId());
// assert introspection and user-info fails with offline session deleted
realm.admin().deleteSession(getSessionIdFromToken(accessToken), true);
assertIntrospectError(exchangedTokenString);
assertUserInfoError(exchangedTokenString, "requester-client", "secret", "invalid_token", Errors.USER_SESSION_NOT_FOUND);
realm.admin().clients().get(subjectClient.getId()).removeOptionalClientScope(offlineAccessScopeId);
}
@Test
public void testTransientSessionWithAdminApi() {
// Create client scope for realm-management view-realm role
ClientResource realmManagementClient = AdminApiUtil.findClientByClientId(realm.admin(), Constants.REALM_MANAGEMENT_CLIENT_ID);
RoleRepresentation viewRealmRole = realmManagementClient.roles().get(AdminRoles.VIEW_REALM).toRepresentation();
ClientScopeRepresentation clientScope = new ClientScopeRepresentation();
clientScope.setName("realm-management-view-scope");
clientScope.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
Response createScopeResponse = realm.admin().clientScopes().create(clientScope);
String clientScopeId = ApiUtil.getCreatedId(createScopeResponse);
createScopeResponse.close();
// Add role to client scope
realm.admin().clientScopes().get(clientScopeId).getScopeMappings()
.clientLevel(realmManagementClient.toRepresentation().getId())
.add(List.of(viewRealmRole));
// Assign role to john and add optional scope to requester-client
realm.admin().users().get(john.getId()).roles().clientLevel(realmManagementClient.toRepresentation().getId())
.add(List.of(viewRealmRole));
realm.admin().clients().get(requesterClient.getId()).addOptionalClientScope(clientScopeId);
String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken();
// Token exchange with the realm-management-view optional scope
oauth.scope("realm-management-view-scope");
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of(Constants.REALM_MANAGEMENT_CLIENT_ID), null);
assertAudiencesAndScopes(response, john, List.of(Constants.REALM_MANAGEMENT_CLIENT_ID), List.of("realm-management-view-scope"));
AccessToken exchangedToken = verifyAccessToken(response.getAccessToken());
assertAccessTokenContext(exchangedToken.getId(), AccessTokenContext.SessionType.ONLINE_TRANSIENT_CLIENT,
AccessTokenContext.TokenType.REGULAR, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE);
// Use the exchanged token with admin client
try (Keycloak keycloak = adminClientFactory.create()
.realm(realm.getName())
.clientId(Constants.ADMIN_CLI_CLIENT_ID)
.authorization(response.getAccessToken())
.build()) {
assertEquals(realm.getName(), keycloak.realm(realm.getName()).toRepresentation().getRealm());
timeOffSet.set(10);
assertEquals(realm.getName(), keycloak.realm(realm.getName()).toRepresentation().getRealm());
realm.admin().deleteSession(exchangedToken.getSessionId(), false);
assertThrows(NotAuthorizedException.class, () -> keycloak.realm(realm.getName()).toRepresentation().getRealm());
}
// Cleanup: remove client scope
realm.admin().clientScopes().get(clientScopeId).remove();
}
@Test
public void testTransientSessionWithAccountApi() {
// Create client scope for account view-profile role
ClientResource accountClient = AdminApiUtil.findClientByClientId(realm.admin(), Constants.ACCOUNT_MANAGEMENT_CLIENT_ID);
RoleRepresentation viewProfileRole = accountClient.roles().get(AccountRoles.VIEW_PROFILE).toRepresentation();
ClientScopeRepresentation clientScope = new ClientScopeRepresentation();
clientScope.setName("account-view-profile-scope");
clientScope.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
Response createScopeResponse = realm.admin().clientScopes().create(clientScope);
String clientScopeId = ApiUtil.getCreatedId(createScopeResponse);
createScopeResponse.close();
// Add role to client scope
realm.admin().clientScopes().get(clientScopeId).getScopeMappings()
.clientLevel(accountClient.toRepresentation().getId())
.add(List.of(viewProfileRole));
// Add optional scope to requester-client
realm.admin().clients().get(requesterClient.getId()).addOptionalClientScope(clientScopeId);
try {
String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken();
// Token exchange with the view-profile optional scope
oauth.scope("account-view-profile-scope");
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID), null);
assertAudiencesAndScopes(response, john, List.of(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID), List.of("account-view-profile-scope"));
AccessToken exchangedToken = verifyAccessToken(response.getAccessToken());
assertAccessTokenContext(exchangedToken.getId(), AccessTokenContext.SessionType.ONLINE_TRANSIENT_CLIENT,
AccessTokenContext.TokenType.REGULAR, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE);
// Use the exchanged token to call account API
String accountUrl = keycloakUrls.getBase() + "/realms/" + realm.getName() + "/account";
UserRepresentation accountUser = simpleHttp.doGet(accountUrl)
.header("Authorization", "Bearer " + response.getAccessToken())
.asJson(UserRepresentation.class);
assertEquals("john", accountUser.getUsername());
timeOffSet.set(10);
accountUser = simpleHttp.doGet(accountUrl)
.header("Authorization", "Bearer " + response.getAccessToken())
.asJson(UserRepresentation.class);
assertEquals("john", accountUser.getUsername());
realm.admin().deleteSession(exchangedToken.getSessionId(), false);
int statusCode = simpleHttp.doGet(accountUrl)
.header("Accept", "application/json")
.header("Authorization", "Bearer " + response.getAccessToken())
.asResponse().getStatus();
assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), statusCode);
}
catch (IOException e) {
fail("Error parsing response: ", e);
}
finally {
// Cleanup: remove client scope
realm.admin().clientScopes().get(clientScopeId).remove();
}
}
@Test
public void testSenderConstrainedTokenRejection() {
// Create a protocol mapper that adds the cnf claim
ProtocolMapperModel mapper = HardcodedClaim.create("test-cnf-mapper", "cnf", "{\"jkt\":\"test-thumbprint-12345\"}", "JSON", true, false, false);
Response mapperResponse = realm.admin().clients().get(subjectClient.getId()).getProtocolMappers().createMapper(ModelToRepresentation.toRepresentation(mapper));
String mapperId = ApiUtil.getCreatedId(mapperResponse);
// Get a new token with the cnf claim
String senderConstrainedToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken();
AccessToken token = verifyAccessToken(senderConstrainedToken);
assertNotNull(token.getConfirmation());
AccessTokenResponse response = tokenExchange(senderConstrainedToken, "requester-client", "secret", null, null);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
assertTrue(response.getErrorDescription().contains("Sender-constrained"), "Error should mention sender-constrained tokens");
realm.admin().clients().get(subjectClient.getId()).getProtocolMappers().delete(mapperId);
}
}

View file

@ -0,0 +1,39 @@
/*
* 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.tests.oauth.tokenexchange;
import org.keycloak.common.Profile;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.server.KeycloakServerConfig;
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
@KeycloakIntegrationTest(config = StandardBaseTokenExchangeWithDynamicScopesTest.ServerConfig.class)
public class StandardBaseTokenExchangeWithDynamicScopesTest extends StandardBaseTokenExchangeV2Test {
public static class ServerConfig implements KeycloakServerConfig {
@Override
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
return config.features(Profile.Feature.DYNAMIC_SCOPES);
}
}
}

View file

@ -0,0 +1,212 @@
/*
* 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.tests.oauth.tokenexchange;
import java.util.List;
import jakarta.ws.rs.core.Response;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.encode.AccessTokenContext;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.events.EventAssertion;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.util.TokenUtil;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
@KeycloakIntegrationTest
public class StandardTokenExchangeRefreshBaseTokenV2Test extends AbstractBaseTokenExchangeTest {
@Test
public void testScopeParamIncludedAudienceIncludedRefreshToken() {
requesterClient.getAttributes().put(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, OIDCAdvancedConfigWrapper.TokenExchangeRefreshTokenEnabled.SAME_SESSION.name());
realm.admin().clients().get(requesterClient.getId()).update(requesterClient);
String accessToken = resourceOwnerLogin("mike", "password", "subject-client", "secret").getAccessToken();
oauth.scope("optional-scope2");
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1"), OAuth2Constants.REFRESH_TOKEN_TYPE);
assertAudiencesAndScopes(response, mike, List.of("target-client1"), List.of("default-scope1", "optional-scope2"), OAuth2Constants.REFRESH_TOKEN_TYPE, "subject-client");
assertNotNull(response.getRefreshToken());
oauth.client("requester-client", "secret");
response = oauth.doRefreshTokenRequest(response.getRefreshToken());
AccessToken exchangedToken = assertAudiencesAndScopes(response, List.of("requester-client", "target-client1"), List.of("default-scope1", "optional-scope2"));
EventRepresentation event = events.poll();
EventAssertion.assertSuccess(event)
.type(EventType.REFRESH_TOKEN)
.details(Details.TOKEN_ID, exchangedToken.getId())
.details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH)
.sessionId(exchangedToken.getSessionId());
oauth.client("requester-client", "secret");
response = oauth.doRefreshTokenRequest(response.getRefreshToken());
exchangedToken = assertAudiencesAndScopes(response, List.of("requester-client", "target-client1"), List.of("default-scope1", "optional-scope2"));
event = events.poll();
EventAssertion.assertSuccess(event)
.type(EventType.REFRESH_TOKEN)
.details(Details.TOKEN_ID, exchangedToken.getId())
.details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH)
.sessionId(exchangedToken.getSessionId());
requesterClient.getAttributes().put(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, OIDCAdvancedConfigWrapper.TokenExchangeRefreshTokenEnabled.NO.name());
realm.admin().clients().get(requesterClient.getId()).update(requesterClient);
}
@Test
public void testExchangeNoRefreshToken() {
String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret").getAccessToken();
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, null);
assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType());
String exchangedTokenString = response.getAccessToken();
String refreshTokenString = response.getRefreshToken();
assertNotNull(exchangedTokenString);
assertNull(refreshTokenString);
requesterClient.getAttributes().put(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, OIDCAdvancedConfigWrapper.TokenExchangeRefreshTokenEnabled.SAME_SESSION.name());
requesterClient.getAttributes().put(OIDCConfigAttributes.USE_REFRESH_TOKEN, Boolean.FALSE.toString());
realm.admin().clients().get(requesterClient.getId()).update(requesterClient);
realm.admin().clients().get(requesterClient.getId()).update(requesterClient);
response = tokenExchange(accessToken, "requester-client", "secret", null,
OAuth2Constants.REFRESH_TOKEN_TYPE);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
assertEquals("requested_token_type unsupported", response.getErrorDescription());
requesterClient.getAttributes().put(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, OIDCAdvancedConfigWrapper.TokenExchangeRefreshTokenEnabled.NO.name());
requesterClient.getAttributes().put(OIDCConfigAttributes.USE_REFRESH_TOKEN, Boolean.TRUE.toString());
realm.admin().clients().get(requesterClient.getId()).update(requesterClient);
}
@Test
public void testOfflineAccessNotAllowed() {
requesterClient.getAttributes().put(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, OIDCAdvancedConfigWrapper.TokenExchangeRefreshTokenEnabled.SAME_SESSION.name());
realm.admin().clients().get(requesterClient.getId()).update(requesterClient);
String accessToken = resourceOwnerLogin("mike", "password", "subject-client", "secret").getAccessToken();
String sessionId = getSessionIdFromToken(accessToken);
assertEquals(getClientSessionsCountInUserSession(sessionId), Integer.valueOf(1));
oauth.scope("offline_access");
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1"), OAuth2Constants.REFRESH_TOKEN_TYPE);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
assertEquals("Scope offline_access not allowed for token exchange", response.getErrorDescription());
// Check that client session was not created
assertEquals(getClientSessionsCountInUserSession(sessionId), Integer.valueOf(1));
requesterClient.getAttributes().put(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, OIDCAdvancedConfigWrapper.TokenExchangeRefreshTokenEnabled.NO.name());
realm.admin().clients().get(requesterClient.getId()).update(requesterClient);
}
@Test
public void testOfflineAccessLoginWithRegularTokenExchange() {
requesterClient.getAttributes().put(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, OIDCAdvancedConfigWrapper.TokenExchangeRefreshTokenEnabled.SAME_SESSION.name());
realm.admin().clients().get(requesterClient.getId()).update(requesterClient);
String offlineAccessScopeId = realm.admin().clientScopes().findAll().stream()
.filter(scope -> OAuth2Constants.OFFLINE_ACCESS.equals(scope.getName()))
.findFirst()
.orElseThrow()
.getId();
realm.admin().clients().get(subjectClient.getId()).addOptionalClientScope(offlineAccessScopeId);
// Login with "scope=offline_access" . Will create offline user-session
String accessToken = resourceOwnerLogin("mike", "password", "subject-client", "secret", OAuth2Constants.OFFLINE_ACCESS).getAccessToken();
AccessToken originalToken = verifyAccessToken(accessToken);
AccessTokenContext ctx = getAccessTokenContext(originalToken.getId());
assertEquals(AccessTokenContext.SessionType.OFFLINE, ctx.getSessionType());
// normal access token exchange is allowed for the offline session
oauth.scope(null);
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1"), null);
AccessToken exchangedToken = assertAudiencesAndScopes(response, mike, List.of("target-client1"), List.of("default-scope1"));
assertEquals(originalToken.getSessionId(), exchangedToken.getSessionId());
// Refresh token-exchange without "scope=offline_access". Not allowed cos a new new "online" user session is needed (as previous one was offline)
oauth.scope(null);
response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1"), OAuth2Constants.REFRESH_TOKEN_TYPE);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(Errors.INVALID_REQUEST, response.getError());
assertEquals("Refresh token not valid as requested_token_type because creating a new session is needed", response.getErrorDescription());
EventAssertion.assertError(events.poll())
.type(EventType.TOKEN_EXCHANGE_ERROR)
.clientId("requester-client")
.error(Errors.INVALID_REQUEST)
.userId(mike.getId())
.sessionId(originalToken.getSessionId())
.details(Details.REASON, "Refresh token not valid as requested_token_type because creating a new session is needed");
requesterClient.getAttributes().put(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, OIDCAdvancedConfigWrapper.TokenExchangeRefreshTokenEnabled.NO.name());
realm.admin().clients().get(requesterClient.getId()).update(requesterClient);
realm.admin().clients().get(subjectClient.getId()).removeOptionalClientScope(offlineAccessScopeId);
}
@Test
public void testOfflineAccessNotAllowedAfterOfflineAccessLogin() {
requesterClient.getAttributes().put(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, OIDCAdvancedConfigWrapper.TokenExchangeRefreshTokenEnabled.SAME_SESSION.name());
realm.admin().clients().get(requesterClient.getId()).update(requesterClient);
// Login with "scope=offline_access" . Will create offline user-session
String accessToken = resourceOwnerLogin("mike", "password", "subject-client", "secret", OAuth2Constants.OFFLINE_ACCESS).getAccessToken();
// Doublecheck count of sessions
String subjectClientUuid = subjectClient.getId();
String requesterClientUuid = requesterClient.getId();
UserResource user = realm.admin().users().get(mike.getId());
assertEquals(0, user.getUserSessions().size());
assertEquals(1, user.getOfflineSessions(subjectClientUuid).size());
assertEquals(0, user.getOfflineSessions(requesterClientUuid).size());
// Token exchange with scope=offline-access should not be allowed
oauth.scope("offline_access");
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1"), OAuth2Constants.REFRESH_TOKEN_TYPE);
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
// Make sure not new user sessions persisted
assertEquals(0, user.getUserSessions().size());
assertEquals(1, user.getOfflineSessions(subjectClientUuid).size());
assertEquals(0, user.getOfflineSessions(requesterClientUuid).size());
oauth.scope("openid");
requesterClient.getAttributes().put(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, OIDCAdvancedConfigWrapper.TokenExchangeRefreshTokenEnabled.NO.name());
realm.admin().clients().get(requesterClient.getId()).update(requesterClient);
}
}

View file

@ -108,9 +108,15 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* This class is abstract to prevent execution in the old Arquillian test suite.
* Tests have been migrated to the new test framework in {@code org.keycloak.tests.oauth.tokenexchange}.
* Do not add new tests here.
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
* @deprecated Use the new test framework classes instead
*/
public class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest {
@Deprecated
public abstract class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest {
@Page
protected ConsentPage consentPage;