mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-28 04:13:22 -04:00
Migrate Standard token exchange tests (#47516)
Closes #47491 closes #48982 Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
parent
4f06846693
commit
2e99d2e965
7 changed files with 2022 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue