diff --git a/test-framework/builders/src/main/java/org/keycloak/testframework/realm/RealmBuilder.java b/test-framework/builders/src/main/java/org/keycloak/testframework/realm/RealmBuilder.java index fbd2d25200c..04a17ede40c 100644 --- a/test-framework/builders/src/main/java/org/keycloak/testframework/realm/RealmBuilder.java +++ b/test-framework/builders/src/main/java/org/keycloak/testframework/realm/RealmBuilder.java @@ -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 { @@ -588,6 +590,29 @@ public class RealmBuilder extends Builder { 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> 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 diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/tokenexchange/AbstractBaseTokenExchangeTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/tokenexchange/AbstractBaseTokenExchangeTest.java new file mode 100644 index 00000000000..dc210bb6721 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/tokenexchange/AbstractBaseTokenExchangeTest.java @@ -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 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 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 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 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 expectedAudiences) { + MatcherAssert.assertThat("Incompatible audiences", token.getAudience() == null ? List.of() : List.of(token.getAudience()), containsInAnyOrder(expectedAudiences.toArray())); + java.util.List 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 expectedScopes) { + MatcherAssert.assertThat("Incompatible scopes", token.getScope().isEmpty() ? List.of() : List.of(token.getScope().split(" ")), containsInAnyOrder(expectedScopes.toArray())); + } + + protected AccessToken assertAudiencesAndScopes(AccessTokenResponse tokenExchangeResponse, List expectedAudiences, List 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 expectedAudiences, List expectedScopes) { + return assertAudiencesAndScopes(tokenExchangeResponse, user, expectedAudiences, expectedScopes, OAuth2Constants.ACCESS_TOKEN_TYPE, "subject-client"); + } + + protected AccessToken assertAudiencesAndScopes(AccessTokenResponse tokenExchangeResponse, UserRepresentation user, + List expectedAudiences, List 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 expectedScopeSet = new HashSet<>(expectedScopes); + Set 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 verifyToken(String token, Class clazz) { + TokenVerifier 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; + } + +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/tokenexchange/StandardBaseTokenExchangeClientPoliciesV2Test.java b/tests/base/src/test/java/org/keycloak/tests/oauth/tokenexchange/StandardBaseTokenExchangeClientPoliciesV2Test.java new file mode 100644 index 00000000000..fb344d91b9c --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/tokenexchange/StandardBaseTokenExchangeClientPoliciesV2Test.java @@ -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); + + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/tokenexchange/StandardBaseTokenExchangeV2Test.java b/tests/base/src/test/java/org/keycloak/tests/oauth/tokenexchange/StandardBaseTokenExchangeV2Test.java new file mode 100644 index 00000000000..50913ba2dfb --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/tokenexchange/StandardBaseTokenExchangeV2Test.java @@ -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 expectedClients = new HashSet<>(Arrays.asList("requester-client-2", "requester-client")); + Set 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); + + } + +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/tokenexchange/StandardBaseTokenExchangeWithDynamicScopesTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/tokenexchange/StandardBaseTokenExchangeWithDynamicScopesTest.java new file mode 100644 index 00000000000..0104273955d --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/tokenexchange/StandardBaseTokenExchangeWithDynamicScopesTest.java @@ -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); + } + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/tokenexchange/StandardTokenExchangeRefreshBaseTokenV2Test.java b/tests/base/src/test/java/org/keycloak/tests/oauth/tokenexchange/StandardTokenExchangeRefreshBaseTokenV2Test.java new file mode 100644 index 00000000000..6d322079432 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/tokenexchange/StandardTokenExchangeRefreshBaseTokenV2Test.java @@ -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); + } + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java index 8bdd30eed74..18925694ac2 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java @@ -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 Marek Posolda + * @deprecated Use the new test framework classes instead */ -public class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest { +@Deprecated +public abstract class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest { @Page protected ConsentPage consentPage;