Token exchange - added experimental token exchange V2 divided into mulitple features (#36407)

closes #35504

Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
Marek Posolda 2025-01-17 09:12:38 +01:00 committed by GitHub
parent 537ce7eb38
commit ec5a8d161a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 2403 additions and 1358 deletions

View File

@ -73,7 +73,10 @@ public class Profile {
SCRIPTS("Write custom authenticators using JavaScript", Type.PREVIEW),
TOKEN_EXCHANGE("Token Exchange Service", Type.PREVIEW),
TOKEN_EXCHANGE("Token Exchange Service", Type.PREVIEW, 1),
TOKEN_EXCHANGE_STANDARD_V2("Standard Token Exchange version 2", Type.EXPERIMENTAL, 2, Feature.ADMIN_FINE_GRAINED_AUTHZ), // TODO: Switch v2 token exchanges to depend admin-fine-grained-authz-v2
TOKEN_EXCHANGE_FEDERATED_V2("Federated Token Exchange for external-internal and internal-external token exchange", Type.EXPERIMENTAL, 2, Feature.ADMIN_FINE_GRAINED_AUTHZ),
TOKEN_EXCHANGE_SUBJECT_IMPERSONATION_V2("Subject impersonation Token Exchange", Type.EXPERIMENTAL, 2, Feature.ADMIN_FINE_GRAINED_AUTHZ),
WEB_AUTHN("W3C Web Authentication (WebAuthn)", Type.DEFAULT),

View File

@ -44,7 +44,10 @@ public class TokenExchangeGrantTypeFactory implements OAuth2GrantTypeFactory, En
@Override
public boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.TOKEN_EXCHANGE);
return Profile.isFeatureEnabled(Profile.Feature.TOKEN_EXCHANGE)
|| Profile.isFeatureEnabled(Profile.Feature.TOKEN_EXCHANGE_STANDARD_V2)
|| Profile.isFeatureEnabled(Profile.Feature.TOKEN_EXCHANGE_FEDERATED_V2)
|| Profile.isFeatureEnabled(Profile.Feature.TOKEN_EXCHANGE_SUBJECT_IMPERSONATION_V2);
}
@Override

View File

@ -1,5 +1,5 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* Copyright 2024 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");
@ -11,11 +11,12 @@
* 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.protocol.oidc;
package org.keycloak.protocol.oidc.tokenexchange;
import org.jboss.logging.Logger;
import org.keycloak.OAuth2Constants;
@ -50,6 +51,11 @@ import org.keycloak.models.UserSessionModel;
import org.keycloak.models.light.LightweightUserAdapter;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.LoginProtocolFactory;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenExchangeContext;
import org.keycloak.protocol.oidc.TokenExchangeProvider;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.protocol.oidc.endpoints.TokenEndpoint.TokenExchangeSamlProtocol;
import org.keycloak.protocol.saml.SamlClient;
import org.keycloak.protocol.saml.SamlProtocol;
@ -93,13 +99,13 @@ import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
/**
* Default token exchange implementation
* Base token exchange implementation. For now for both V1 and V2 token exchange (may change in the follow-up commits)
*
* @author <a href="mailto:dmitryt@backbase.com">Dmitry Telegin</a>
*/
public class DefaultTokenExchangeProvider implements TokenExchangeProvider {
public abstract class AbstractTokenExchangeProvider implements TokenExchangeProvider {
private static final Logger logger = Logger.getLogger(DefaultTokenExchangeProvider.class);
private static final Logger logger = Logger.getLogger(AbstractTokenExchangeProvider.class);
private TokenExchangeContext.Params params;
private MultivaluedMap<String, String> formParams;
@ -112,11 +118,7 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider {
private HttpHeaders headers;
private TokenManager tokenManager;
private Map<String, String> clientAuthAttributes;
@Override
public boolean supports(TokenExchangeContext context) {
return true;
}
protected TokenExchangeContext context;
@Override
public Response exchange(TokenExchangeContext context) {
@ -131,6 +133,7 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider {
this.headers = context.getHeaders();
this.tokenManager = (TokenManager)context.getTokenManager();
this.clientAuthAttributes = context.getClientAuthAttributes();
this.context = context;
return tokenExchange();
}
@ -138,127 +141,47 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider {
public void close() {
}
protected Response tokenExchange() {
protected abstract Response tokenExchange();
UserModel tokenUser = null;
UserSessionModel tokenSession = null;
AccessToken token = null;
/**
* Is it the request for external-internal token exchange?
*/
protected boolean isExternalInternalTokenExchangeRequest(TokenExchangeContext context) {
String subjectToken = context.getParams().getSubjectToken();
KeycloakSession session = context.getSession();
RealmModel realm = context.getRealm();
EventBuilder event = context.getEvent();
String subjectToken = formParams.getFirst(OAuth2Constants.SUBJECT_TOKEN);
if (subjectToken != null) {
String subjectTokenType = formParams.getFirst(OAuth2Constants.SUBJECT_TOKEN_TYPE);
String subjectTokenType = context.getParams().getSubjectTokenType();
String realmIssuerUrl = Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName());
String subjectIssuer = formParams.getFirst(OAuth2Constants.SUBJECT_ISSUER);
if (subjectIssuer == null && OAuth2Constants.JWT_TOKEN_TYPE.equals(subjectTokenType)) {
try {
JWSInput jws = new JWSInput(subjectToken);
JsonWebToken jwt = jws.readJsonContent(JsonWebToken.class);
subjectIssuer = jwt.getIssuer();
} catch (JWSInputException e) {
event.detail(Details.REASON, "unable to parse jwt subject_token");
event.error(Errors.INVALID_TOKEN);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Invalid token type, must be access token", Response.Status.BAD_REQUEST);
}
}
String subjectIssuer = getSubjectIssuer(context, subjectToken, subjectTokenType);
if (subjectIssuer != null && !realmIssuerUrl.equals(subjectIssuer)) {
event.detail(OAuth2Constants.SUBJECT_ISSUER, subjectIssuer);
return exchangeExternalToken(subjectIssuer, subjectToken);
return true;
}
if (subjectTokenType != null && !subjectTokenType.equals(OAuth2Constants.ACCESS_TOKEN_TYPE)) {
event.detail(Details.REASON, "subject_token supports access tokens only");
event.error(Errors.INVALID_TOKEN);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Invalid token type, must be access token", Response.Status.BAD_REQUEST);
}
AuthenticationManager.AuthResult authResult = AuthenticationManager.verifyIdentityToken(session, realm, session.getContext().getUri(), clientConnection, true, true, null, false, subjectToken, headers);
if (authResult == null) {
event.detail(Details.REASON, "subject_token validation failure");
event.error(Errors.INVALID_TOKEN);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Invalid token", Response.Status.BAD_REQUEST);
}
tokenUser = authResult.getUser();
tokenSession = authResult.getSession();
token = authResult.getToken();
}
return false;
}
String requestedSubject = formParams.getFirst(OAuth2Constants.REQUESTED_SUBJECT);
boolean disallowOnHolderOfTokenMismatch = true;
protected String getSubjectIssuer(TokenExchangeContext context, String subjectToken, String subjectTokenType) {
String subjectIssuer = context.getFormParams().getFirst(OAuth2Constants.SUBJECT_ISSUER);
if (subjectIssuer != null) return subjectIssuer;
if (requestedSubject != null) {
event.detail(Details.REQUESTED_SUBJECT, requestedSubject);
UserModel requestedUser = session.users().getUserByUsername(realm, requestedSubject);
if (requestedUser == null) {
requestedUser = session.users().getUserById(realm, requestedSubject);
}
if (requestedUser == null) {
// We always returned access denied to avoid username fishing
event.detail(Details.REASON, "requested_subject not found");
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN);
}
if (token != null) {
event.detail(Details.IMPERSONATOR, tokenUser.getUsername());
// for this case, the user represented by the token, must have permission to impersonate.
AdminAuth auth = new AdminAuth(realm, token, tokenUser, client);
if (!AdminPermissions.evaluator(session, realm, auth).users().canImpersonate(requestedUser, client)) {
event.detail(Details.REASON, "subject not allowed to impersonate");
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN);
}
} else {
// no token is being exchanged, this is a direct exchange. Client must be authenticated, not public, and must be allowed
// to impersonate
if (client.isPublicClient()) {
event.detail(Details.REASON, "public clients not allowed");
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN);
}
if (!AdminPermissions.management(session, realm).users().canClientImpersonate(client, requestedUser)) {
event.detail(Details.REASON, "client not allowed to impersonate");
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN);
}
// see https://issues.redhat.com/browse/KEYCLOAK-5492
disallowOnHolderOfTokenMismatch = false;
}
tokenSession = new UserSessionManager(session).createUserSession(realm, requestedUser, requestedUser.getUsername(), clientConnection.getRemoteAddr(), "impersonate", false, null, null);
if (tokenUser != null) {
tokenSession.setNote(IMPERSONATOR_ID.toString(), tokenUser.getId());
tokenSession.setNote(IMPERSONATOR_USERNAME.toString(), tokenUser.getUsername());
}
tokenUser = requestedUser;
}
String requestedIssuer = formParams.getFirst(OAuth2Constants.REQUESTED_ISSUER);
if (requestedIssuer == null) {
return exchangeClientToClient(tokenUser, tokenSession, token, disallowOnHolderOfTokenMismatch);
} else {
if (OAuth2Constants.JWT_TOKEN_TYPE.equals(subjectTokenType)) {
try {
return exchangeToIdentityProvider(tokenUser, tokenSession, requestedIssuer);
} finally {
if (subjectToken == null) { // we are naked! So need to clean up user session
try {
session.sessions().removeUserSession(realm, tokenSession);
} catch (Exception ignore) {
}
}
JWSInput jws = new JWSInput(subjectToken);
JsonWebToken jwt = jws.readJsonContent(JsonWebToken.class);
return jwt.getIssuer();
} catch (JWSInputException e) {
context.getEvent().detail(Details.REASON, "unable to parse jwt subject_token");
context.getEvent().error(Errors.INVALID_TOKEN);
throw new CorsErrorResponseException(context.getCors(), OAuthErrorException.INVALID_REQUEST, "Invalid token type, must be access token", Response.Status.BAD_REQUEST);
}
}
} else {
return null;
}
}
protected Response exchangeToIdentityProvider(UserModel targetUser, UserSessionModel targetUserSession, String requestedIssuer) {

View File

@ -0,0 +1,93 @@
/*
* 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.protocol.oidc.tokenexchange;
import jakarta.ws.rs.core.Response;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.TokenExchangeContext;
import org.keycloak.representations.AccessToken;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.managers.AuthenticationManager;
/**
* Provider for external-internal or internal-external token exchange, where identity providers are involved
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class FederatedTokenExchangeProvider extends AbstractTokenExchangeProvider {
@Override
public boolean supports(TokenExchangeContext context) {
String requestedIssuer = context.getFormParams().getFirst(OAuth2Constants.REQUESTED_ISSUER);
if (requestedIssuer != null) {
// Internal-external token exchange
return true;
}
return isExternalInternalTokenExchangeRequest(context);
}
@Override
protected Response tokenExchange() {
KeycloakSession session = context.getSession();
EventBuilder event = context.getEvent();
UserModel tokenUser = null;
UserSessionModel tokenSession = null;
AccessToken token = null;
String subjectToken = context.getParams().getSubjectToken();
if (subjectToken != null) {
String subjectTokenType = context.getParams().getSubjectTokenType();
if (isExternalInternalTokenExchangeRequest(context)) {
String subjectIssuer = getSubjectIssuer(context, subjectToken, subjectTokenType);
return exchangeExternalToken(subjectIssuer, subjectToken);
}
if (subjectTokenType != null && !subjectTokenType.equals(OAuth2Constants.ACCESS_TOKEN_TYPE)) {
event.detail(Details.REASON, "subject_token supports access tokens only");
event.error(Errors.INVALID_TOKEN);
throw new CorsErrorResponseException(context.getCors(), OAuthErrorException.INVALID_REQUEST, "Invalid token type, must be access token", Response.Status.BAD_REQUEST);
}
AuthenticationManager.AuthResult authResult = AuthenticationManager.verifyIdentityToken(session, context.getRealm(), session.getContext().getUri(), context.getClientConnection(), true, true, null, false, subjectToken, context.getHeaders());
if (authResult == null) {
event.detail(Details.REASON, "subject_token validation failure");
event.error(Errors.INVALID_TOKEN);
throw new CorsErrorResponseException(context.getCors(), OAuthErrorException.INVALID_REQUEST, "Invalid token", Response.Status.BAD_REQUEST);
}
tokenUser = authResult.getUser();
tokenSession = authResult.getSession();
}
String requestedIssuer = context.getFormParams().getFirst(OAuth2Constants.REQUESTED_ISSUER);
return exchangeToIdentityProvider(tokenUser, tokenSession, requestedIssuer);
}
}

View File

@ -0,0 +1,71 @@
/*
* 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.protocol.oidc.tokenexchange;
import org.keycloak.Config;
import org.keycloak.common.Profile;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.protocol.oidc.TokenExchangeProvider;
import org.keycloak.protocol.oidc.TokenExchangeProviderFactory;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
/**
* Provider factory for external-internal or internal-external token exchange, where identity providers are involved
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class FederatedTokenExchangeProviderFactory implements TokenExchangeProviderFactory, EnvironmentDependentProviderFactory {
@Override
public TokenExchangeProvider create(KeycloakSession session) {
return new FederatedTokenExchangeProvider();
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return "federated";
}
@Override
public boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.TOKEN_EXCHANGE_FEDERATED_V2);
}
@Override
public int order() {
return 2;
}
}

View File

@ -0,0 +1,107 @@
/*
* 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.protocol.oidc.tokenexchange;
import jakarta.ws.rs.core.Response;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.common.ClientConnection;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.TokenExchangeContext;
import org.keycloak.representations.AccessToken;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.cors.Cors;
import org.keycloak.services.managers.AuthenticationManager;
/**
* Provider for internal-internal token exchange, which is compliant with the token exchange specification https://datatracker.ietf.org/doc/html/rfc8693
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider {
@Override
public boolean supports(TokenExchangeContext context) {
return true;
}
@Override
protected Response tokenExchange() {
KeycloakSession session = context.getSession();
RealmModel realm = context.getRealm();
ClientConnection clientConnection = context.getClientConnection();
Cors cors = context.getCors();
EventBuilder event = context.getEvent();
String subjectToken = context.getParams().getSubjectToken();
if (subjectToken == null) {
event.detail(Details.REASON, "subject_token parameter not provided");
event.error(Errors.INVALID_REQUEST);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "subject_token parameter not provided", Response.Status.BAD_REQUEST);
}
String subjectTokenType = context.getParams().getSubjectTokenType();
if (subjectTokenType == null) {
event.detail(Details.REASON, "subject_token_type parameter not provided");
event.error(Errors.INVALID_REQUEST);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "subject_token_type parameter not provided", Response.Status.BAD_REQUEST);
}
if (!subjectTokenType.equals(OAuth2Constants.ACCESS_TOKEN_TYPE)) {
event.detail(Details.REASON, "subject_token supports access tokens only");
event.error(Errors.INVALID_TOKEN);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Invalid token type, must be access token", Response.Status.BAD_REQUEST);
}
AuthenticationManager.AuthResult authResult = AuthenticationManager.verifyIdentityToken(session, realm, session.getContext().getUri(), clientConnection, true, true, null, false, subjectToken, context.getHeaders());
if (authResult == null) {
event.detail(Details.REASON, "subject_token validation failure");
event.error(Errors.INVALID_TOKEN);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Invalid token", Response.Status.BAD_REQUEST);
}
UserModel tokenUser = authResult.getUser();
UserSessionModel tokenSession = authResult.getSession();
AccessToken token = authResult.getToken();
String requestedSubject = context.getFormParams().getFirst(OAuth2Constants.REQUESTED_SUBJECT);
if (requestedSubject != null) {
event.detail(Details.REASON, "Parameter '" + OAuth2Constants.REQUESTED_SUBJECT + "' not supported for standard token exchange");
event.error(Errors.INVALID_REQUEST);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Parameter '" + OAuth2Constants.REQUESTED_SUBJECT + "' not supported for standard token exchange", Response.Status.BAD_REQUEST);
}
String requestedIssuer = context.getFormParams().getFirst(OAuth2Constants.REQUESTED_ISSUER);
if (requestedIssuer != null) {
event.detail(Details.REASON, "Parameter '" + OAuth2Constants.REQUESTED_ISSUER + "' not supported for standard token exchange");
event.error(Errors.INVALID_REQUEST);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Parameter '" + OAuth2Constants.REQUESTED_ISSUER + "' not supported for standard token exchange", Response.Status.BAD_REQUEST);
}
return exchangeClientToClient(tokenUser, tokenSession, token, true);
}
}

View File

@ -0,0 +1,72 @@
/*
* 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.protocol.oidc.tokenexchange;
import org.keycloak.Config;
import org.keycloak.common.Profile;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.protocol.oidc.TokenExchangeProvider;
import org.keycloak.protocol.oidc.TokenExchangeProviderFactory;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
/**
* Provider factory for internal-internal token exchange, which is compliant with the token exchange specification https://datatracker.ietf.org/doc/html/rfc8693
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class StandardTokenExchangeProviderFactory implements TokenExchangeProviderFactory, EnvironmentDependentProviderFactory {
@Override
public TokenExchangeProvider create(KeycloakSession session) {
return new StandardTokenExchangeProvider();
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return "standard";
}
@Override
public boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.TOKEN_EXCHANGE_STANDARD_V2);
}
@Override
public int order() {
// Smaller priority than other V2 providers (due other providers can be detected based on parameters), but bigger than V1, so it has preference if both V1 and V2 enabled
return 1;
}
}

View File

@ -0,0 +1,167 @@
/*
* 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.protocol.oidc.tokenexchange;
import jakarta.ws.rs.core.Response;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.common.ClientConnection;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.TokenExchangeContext;
import org.keycloak.representations.AccessToken;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.cors.Cors;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.UserSessionManager;
import org.keycloak.services.resources.admin.AdminAuth;
import org.keycloak.services.resources.admin.permissions.AdminPermissions;
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID;
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME;
/**
* Provider for token-exchange subject impersonation where subject of the token is changed.
*
* This is Keycloak proprietary and it is not related to standard token-exchange impersonation described in
* the specification https://datatracker.ietf.org/doc/html/rfc8693 where the subject in the tokens are not changed. That one is covered by {@link StandardTokenExchangeProvider}
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class SubjectImpersonationTokenExchangeProvider extends AbstractTokenExchangeProvider {
@Override
public boolean supports(TokenExchangeContext context) {
String requestedSubject = context.getFormParams().getFirst(OAuth2Constants.REQUESTED_SUBJECT);
return requestedSubject != null;
}
@Override
protected Response tokenExchange() {
KeycloakSession session = context.getSession();
RealmModel realm = context.getRealm();
ClientConnection clientConnection = context.getClientConnection();
Cors cors = context.getCors();
ClientModel client = context.getClient();
EventBuilder event = context.getEvent();
UserModel tokenUser = null;
AccessToken token = null;
String subjectToken = context.getParams().getSubjectToken();
if (subjectToken != null) {
String subjectTokenType = context.getParams().getSubjectTokenType();
if (subjectTokenType != null && !subjectTokenType.equals(OAuth2Constants.ACCESS_TOKEN_TYPE)) {
event.detail(Details.REASON, "subject_token supports access tokens only");
event.error(Errors.INVALID_TOKEN);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Invalid token type, must be access token", Response.Status.BAD_REQUEST);
}
AuthenticationManager.AuthResult authResult = AuthenticationManager.verifyIdentityToken(session, realm, session.getContext().getUri(), clientConnection, true, true, null, false, subjectToken, context.getHeaders());
if (authResult == null) {
event.detail(Details.REASON, "subject_token validation failure");
event.error(Errors.INVALID_TOKEN);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Invalid token", Response.Status.BAD_REQUEST);
}
tokenUser = authResult.getUser();
token = authResult.getToken();
}
String requestedSubject = context.getFormParams().getFirst(OAuth2Constants.REQUESTED_SUBJECT);
boolean disallowOnHolderOfTokenMismatch = true;
event.detail(Details.REQUESTED_SUBJECT, requestedSubject);
UserModel requestedUser = session.users().getUserByUsername(realm, requestedSubject);
if (requestedUser == null) {
requestedUser = session.users().getUserById(realm, requestedSubject);
}
if (requestedUser == null) {
// We always returned access denied to avoid username fishing
event.detail(Details.REASON, "requested_subject not found");
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN);
}
if (token != null) {
event.detail(Details.IMPERSONATOR, tokenUser.getUsername());
// for this case, the user represented by the token, must have permission to impersonate.
AdminAuth auth = new AdminAuth(realm, token, tokenUser, client);
if (!AdminPermissions.evaluator(session, realm, auth).users().canImpersonate(requestedUser, client)) {
event.detail(Details.REASON, "subject not allowed to impersonate");
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN);
}
} else {
// no token is being exchanged, this is a direct exchange. Client must be authenticated, not public, and must be allowed
// to impersonate
if (client.isPublicClient()) {
event.detail(Details.REASON, "public clients not allowed");
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN);
}
if (!AdminPermissions.management(session, realm).users().canClientImpersonate(client, requestedUser)) {
event.detail(Details.REASON, "client not allowed to impersonate");
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN);
}
// see https://issues.redhat.com/browse/KEYCLOAK-5492
disallowOnHolderOfTokenMismatch = false;
}
UserSessionModel userSession = new UserSessionManager(session).createUserSession(realm, requestedUser, requestedUser.getUsername(), clientConnection.getRemoteAddr(), "impersonate", false, null, null);
if (tokenUser != null) {
userSession.setNote(IMPERSONATOR_ID.toString(), tokenUser.getId());
userSession.setNote(IMPERSONATOR_USERNAME.toString(), tokenUser.getUsername());
}
tokenUser = requestedUser;
String requestedIssuer = context.getFormParams().getFirst(OAuth2Constants.REQUESTED_ISSUER);
if (requestedIssuer == null) {
return exchangeClientToClient(tokenUser, userSession, token, disallowOnHolderOfTokenMismatch);
} else {
try {
// Subject impersonation supports "internal to external" exchange as well for now
return exchangeToIdentityProvider(tokenUser, userSession, requestedIssuer);
} finally {
if (subjectToken == null) { // we are naked! So need to clean up user session
try {
session.sessions().removeUserSession(realm, userSession);
} catch (Exception ignore) {
}
}
}
}
}
}

View File

@ -0,0 +1,75 @@
/*
* 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.protocol.oidc.tokenexchange;
import org.keycloak.Config;
import org.keycloak.common.Profile;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.protocol.oidc.TokenExchangeProvider;
import org.keycloak.protocol.oidc.TokenExchangeProviderFactory;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
/**
* Provider factory for token-exchange subject impersonation where subject of the token is changed.
*
* This is Keycloak proprietary and it is not related to standard token-exchange impersonation described in
* the specification https://datatracker.ietf.org/doc/html/rfc8693 where the subject in the tokens are not changed. That one is covered by {@link StandardTokenExchangeProviderFactory}
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class SubjectImpersonationTokenExchangeProviderFactory implements TokenExchangeProviderFactory, EnvironmentDependentProviderFactory {
@Override
public TokenExchangeProvider create(KeycloakSession session) {
return new SubjectImpersonationTokenExchangeProvider();
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return "subject-impersonation";
}
@Override
public boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.TOKEN_EXCHANGE_SUBJECT_IMPERSONATION_V2);
}
@Override
public int order() {
// Bigger priority than other V2 providers. If parameter "requested_subject" present, we know that it must be subject-impersonation
return 3;
}
}

View File

@ -0,0 +1,169 @@
/*
* 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.protocol.oidc.tokenexchange;
import jakarta.ws.rs.core.Response;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.common.ClientConnection;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.TokenExchangeContext;
import org.keycloak.representations.AccessToken;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.cors.Cors;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.UserSessionManager;
import org.keycloak.services.resources.admin.AdminAuth;
import org.keycloak.services.resources.admin.permissions.AdminPermissions;
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID;
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME;
/**
* V1 token exchange provider. Supports all token exchange types (standard, federated, subject impersonation)
*
* @author <a href="mailto:dmitryt@backbase.com">Dmitry Telegin</a>
*/
public class V1TokenExchangeProvider extends AbstractTokenExchangeProvider {
@Override
public boolean supports(TokenExchangeContext context) {
return true;
}
protected Response tokenExchange() {
KeycloakSession session = context.getSession();
RealmModel realm = context.getRealm();
ClientConnection clientConnection = context.getClientConnection();
Cors cors = context.getCors();
ClientModel client = context.getClient();
EventBuilder event = context.getEvent();
UserModel tokenUser = null;
UserSessionModel tokenSession = null;
AccessToken token = null;
String subjectToken = context.getParams().getSubjectToken();
if (subjectToken != null) {
String subjectTokenType = context.getParams().getSubjectTokenType();
if (isExternalInternalTokenExchangeRequest(this.context)) {
String subjectIssuer = getSubjectIssuer(this.context, subjectToken, subjectTokenType);
return exchangeExternalToken(subjectIssuer, subjectToken);
}
if (subjectTokenType != null && !subjectTokenType.equals(OAuth2Constants.ACCESS_TOKEN_TYPE)) {
event.detail(Details.REASON, "subject_token supports access tokens only");
event.error(Errors.INVALID_TOKEN);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Invalid token type, must be access token", Response.Status.BAD_REQUEST);
}
AuthenticationManager.AuthResult authResult = AuthenticationManager.verifyIdentityToken(session, realm, session.getContext().getUri(), clientConnection, true, true, null, false, subjectToken, context.getHeaders());
if (authResult == null) {
event.detail(Details.REASON, "subject_token validation failure");
event.error(Errors.INVALID_TOKEN);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Invalid token", Response.Status.BAD_REQUEST);
}
tokenUser = authResult.getUser();
tokenSession = authResult.getSession();
token = authResult.getToken();
}
String requestedSubject = context.getFormParams().getFirst(OAuth2Constants.REQUESTED_SUBJECT);
boolean disallowOnHolderOfTokenMismatch = true;
if (requestedSubject != null) {
event.detail(Details.REQUESTED_SUBJECT, requestedSubject);
UserModel requestedUser = session.users().getUserByUsername(realm, requestedSubject);
if (requestedUser == null) {
requestedUser = session.users().getUserById(realm, requestedSubject);
}
if (requestedUser == null) {
// We always returned access denied to avoid username fishing
event.detail(Details.REASON, "requested_subject not found");
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN);
}
if (token != null) {
event.detail(Details.IMPERSONATOR, tokenUser.getUsername());
// for this case, the user represented by the token, must have permission to impersonate.
AdminAuth auth = new AdminAuth(realm, token, tokenUser, client);
if (!AdminPermissions.evaluator(session, realm, auth).users().canImpersonate(requestedUser, client)) {
event.detail(Details.REASON, "subject not allowed to impersonate");
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN);
}
} else {
// no token is being exchanged, this is a direct exchange. Client must be authenticated, not public, and must be allowed
// to impersonate
if (client.isPublicClient()) {
event.detail(Details.REASON, "public clients not allowed");
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN);
}
if (!AdminPermissions.management(session, realm).users().canClientImpersonate(client, requestedUser)) {
event.detail(Details.REASON, "client not allowed to impersonate");
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN);
}
// see https://issues.redhat.com/browse/KEYCLOAK-5492
disallowOnHolderOfTokenMismatch = false;
}
tokenSession = new UserSessionManager(session).createUserSession(realm, requestedUser, requestedUser.getUsername(), clientConnection.getRemoteAddr(), "impersonate", false, null, null);
if (tokenUser != null) {
tokenSession.setNote(IMPERSONATOR_ID.toString(), tokenUser.getId());
tokenSession.setNote(IMPERSONATOR_USERNAME.toString(), tokenUser.getUsername());
}
tokenUser = requestedUser;
}
String requestedIssuer = context.getFormParams().getFirst(OAuth2Constants.REQUESTED_ISSUER);
if (requestedIssuer == null) {
return exchangeClientToClient(tokenUser, tokenSession, token, disallowOnHolderOfTokenMismatch);
} else {
try {
return exchangeToIdentityProvider(tokenUser, tokenSession, requestedIssuer);
} finally {
if (subjectToken == null) { // we are naked! So need to clean up user session
try {
session.sessions().removeUserSession(realm, tokenSession);
} catch (Exception ignore) {
}
}
}
}
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* Copyright 2024 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");
@ -11,26 +11,31 @@
* 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.protocol.oidc;
package org.keycloak.protocol.oidc.tokenexchange;
import org.keycloak.Config;
import org.keycloak.common.Profile;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.protocol.oidc.TokenExchangeProvider;
import org.keycloak.protocol.oidc.TokenExchangeProviderFactory;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
/**
* Default token exchange provider factory
* V1 token exchange provider factory. Supports all token exchange types (standard, federated, subject impersonation)
*
* @author <a href="mailto:dmitryt@backbase.com">Dmitry Telegin</a>
*/
public class DefaultTokenExchangeProviderFactory implements TokenExchangeProviderFactory {
public class V1TokenExchangeProviderFactory implements TokenExchangeProviderFactory, EnvironmentDependentProviderFactory {
@Override
public TokenExchangeProvider create(KeycloakSession session) {
return new DefaultTokenExchangeProvider();
return new V1TokenExchangeProvider();
}
@Override
@ -50,4 +55,8 @@ public class DefaultTokenExchangeProviderFactory implements TokenExchangeProvide
return "default";
}
@Override
public boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.TOKEN_EXCHANGE);
}
}

View File

@ -1,2 +1,4 @@
org.keycloak.protocol.oidc.DefaultTokenExchangeProviderFactory
org.keycloak.protocol.oidc.tokenexchange.V1TokenExchangeProviderFactory
org.keycloak.protocol.oidc.tokenexchange.StandardTokenExchangeProviderFactory
org.keycloak.protocol.oidc.tokenexchange.FederatedTokenExchangeProviderFactory
org.keycloak.protocol.oidc.tokenexchange.SubjectImpersonationTokenExchangeProviderFactory

View File

@ -0,0 +1,31 @@
/*
* 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.testsuite.broker;
import org.keycloak.common.Profile;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.arquillian.annotation.EnableFeatures;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@EnableFeatures({@EnableFeature(Profile.Feature.TOKEN_EXCHANGE_FEDERATED_V2), @EnableFeature(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ)})
public class KcOidcBrokerTokenExchangeFederatedTest extends KcOidcBrokerTokenExchangeTest {
}

View File

@ -45,7 +45,6 @@ import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.authorization.model.Policy;
import org.keycloak.authorization.model.ResourceServer;
import org.keycloak.broker.oidc.mappers.UserAttributeMapper;
import org.keycloak.common.Profile;
import org.keycloak.models.ClientModel;
import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.IdentityProviderMapperSyncMode;
@ -64,15 +63,15 @@ import org.keycloak.services.resources.admin.permissions.AdminPermissionManageme
import org.keycloak.services.resources.admin.permissions.AdminPermissions;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.arquillian.annotation.EnableFeatures;
import org.keycloak.testsuite.updaters.IdentityProviderAttributeUpdater;
import org.keycloak.testsuite.util.AdminClientUtil;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.util.BasicAuthHelper;
@EnableFeatures({@EnableFeature(Profile.Feature.TOKEN_EXCHANGE), @EnableFeature(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ)})
public final class KcOidcBrokerTokenExchangeTest extends AbstractInitializedBaseBrokerTest {
/**
* Test for identity-provider token exchange scenarios. Base for tests of token-exchange V1 as well as token-exchange-federated V2
*/
public abstract class KcOidcBrokerTokenExchangeTest extends AbstractInitializedBaseBrokerTest {
@Override
protected BrokerConfiguration getBrokerConfiguration() {

View File

@ -0,0 +1,31 @@
/*
* 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.testsuite.broker;
import org.keycloak.common.Profile;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.arquillian.annotation.EnableFeatures;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@EnableFeatures({@EnableFeature(Profile.Feature.TOKEN_EXCHANGE), @EnableFeature(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ)})
public class KcOidcBrokerTokenExchangeV1Test extends KcOidcBrokerTokenExchangeTest {
}

View File

@ -0,0 +1,541 @@
/*
* 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.testsuite.oauth.tokenexchange;
import jakarta.ws.rs.core.Response.Status;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.ClientsResource;
import org.keycloak.authorization.model.Policy;
import org.keycloak.authorization.model.ResourceServer;
import org.keycloak.common.Profile;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ImpersonationConstants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserCredentialModel;
import org.keycloak.models.UserModel;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.mappers.AudienceProtocolMapper;
import org.keycloak.protocol.oidc.mappers.UserSessionNoteMapper;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.OAuth2ErrorRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.authorization.ClientPolicyRepresentation;
import org.keycloak.representations.idm.authorization.DecisionStrategy;
import org.keycloak.services.managers.ClientManager;
import org.keycloak.services.managers.RealmManager;
import org.keycloak.services.resources.admin.permissions.AdminPermissionManagement;
import org.keycloak.services.resources.admin.permissions.AdminPermissions;
import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.DisableFeature;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.arquillian.annotation.UncaughtServerErrorExpected;
import org.keycloak.testsuite.util.AdminClientUtil;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.util.BasicAuthHelper;
import org.keycloak.util.JsonSerialization;
import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.client.WebTarget;
import jakarta.ws.rs.core.Form;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static org.hamcrest.Matchers.instanceOf;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID;
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME;
import static org.keycloak.testsuite.auth.page.AuthRealm.TEST;
import com.fasterxml.jackson.databind.JsonNode;
/**
* Tests for standard token exchange. For now, this class provides set of same tests for token-exchange-v1 as well as for token-exchange-v2.
*
* The class may be removed/refactored once V2 implementation will start to differ from V1 (based on new capabilities, adjustments to the specification etc)
*
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
public abstract class AbstractStandardTokenExchangeTest extends AbstractKeycloakTest {
@Rule
public AssertEvents events = new AssertEvents(this);
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
RealmRepresentation testRealmRep = new RealmRepresentation();
testRealmRep.setId(TEST);
testRealmRep.setRealm(TEST);
testRealmRep.setEnabled(true);
testRealms.add(testRealmRep);
}
@Override
protected boolean isImportAfterEachMethod() {
return true;
}
@Test
@UncaughtServerErrorExpected
public void testExchange() throws Exception {
testingClient.server().run(TokenExchangeTestUtils::setupRealm);
oauth.realm(TEST);
oauth.clientId("client-exchanger");
OAuthClient.AccessTokenResponse response = oauth.doGrantAccessTokenRequest("secret", "user", "password");
String accessToken = response.getAccessToken();
TokenVerifier<AccessToken> accessTokenVerifier = TokenVerifier.create(accessToken, AccessToken.class);
AccessToken token = accessTokenVerifier.parse().getToken();
Assert.assertNotNull(token.getSessionId());
Assert.assertEquals(token.getPreferredUsername(), "user");
assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example"));
{
response = oauth.doTokenExchange(TEST, accessToken, "target", "client-exchanger", "secret");
Assert.assertEquals(OAuth2Constants.REFRESH_TOKEN_TYPE, response.getIssuedTokenType());
String exchangedTokenString = response.getAccessToken();
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
Assert.assertEquals(token.getSessionId(), exchangedToken.getSessionId());
Assert.assertEquals("client-exchanger", exchangedToken.getIssuedFor());
Assert.assertEquals("target", exchangedToken.getAudience()[0]);
Assert.assertEquals(exchangedToken.getPreferredUsername(), "user");
assertTrue(exchangedToken.getRealmAccess().isUserInRole("example"));
}
{
response = oauth.doTokenExchange(TEST, accessToken, "target", "legal", "secret");
Assert.assertEquals(OAuth2Constants.REFRESH_TOKEN_TYPE, response.getIssuedTokenType());
String exchangedTokenString = response.getAccessToken();
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
Assert.assertEquals(token.getSessionId(), exchangedToken.getSessionId());
Assert.assertEquals("legal", exchangedToken.getIssuedFor());
Assert.assertEquals("target", exchangedToken.getAudience()[0]);
Assert.assertEquals(exchangedToken.getPreferredUsername(), "user");
assertTrue(exchangedToken.getRealmAccess().isUserInRole("example"));
}
{
response = oauth.doTokenExchange(TEST, accessToken, "target", "illegal", "secret");
Assert.assertEquals(403, response.getStatusCode());
}
}
@Test
public void testExchangeRequestAccessTokenType() throws Exception {
testingClient.server().run(TokenExchangeTestUtils::setupRealm);
oauth.realm(TEST);
oauth.clientId("client-exchanger");
OAuthClient.AccessTokenResponse response = oauth.doGrantAccessTokenRequest("secret", "user", "password");
String accessToken = response.getAccessToken();
TokenVerifier<AccessToken> accessTokenVerifier = TokenVerifier.create(accessToken, AccessToken.class);
AccessToken token = accessTokenVerifier.parse().getToken();
Assert.assertNotNull(token.getSessionId());
Assert.assertEquals(token.getPreferredUsername(), "user");
assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example"));
{
response = oauth.doTokenExchange(TEST, accessToken, "target", "client-exchanger", "secret", Map.of(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE));
Assert.assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType());
String exchangedTokenString = response.getAccessToken();
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
Assert.assertEquals(token.getSessionId(), exchangedToken.getSessionId());
Assert.assertEquals("client-exchanger", exchangedToken.getIssuedFor());
Assert.assertEquals("target", exchangedToken.getAudience()[0]);
Assert.assertEquals(exchangedToken.getPreferredUsername(), "user");
assertTrue(exchangedToken.getRealmAccess().isUserInRole("example"));
}
}
@Test
@UncaughtServerErrorExpected
public void testExchangeUsingServiceAccount() throws Exception {
testingClient.server().run(TokenExchangeTestUtils::setupRealm);
oauth.realm(TEST);
oauth.clientId("my-service-account");
OAuthClient.AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest("secret");
String accessToken = response.getAccessToken();
TokenVerifier<AccessToken> accessTokenVerifier = TokenVerifier.create(accessToken, AccessToken.class);
AccessToken token = accessTokenVerifier.parse().getToken();
Assert.assertNull(token.getSessionId());
{
response = oauth.doTokenExchange(TEST, accessToken, "target", "my-service-account", "secret");
Assert.assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType());
String exchangedTokenString = response.getAccessToken();
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
Assert.assertNull(exchangedToken.getSessionId());
Assert.assertEquals("my-service-account", exchangedToken.getIssuedFor());
Assert.assertEquals("target", exchangedToken.getAudience()[0]);
Assert.assertEquals(exchangedToken.getPreferredUsername(), "service-account-my-service-account");
}
}
@Test
@UncaughtServerErrorExpected
public void testExchangeDifferentScopes() throws Exception {
testingClient.server().run(TokenExchangeTestUtils::setupRealm);
oauth.realm(TEST);
oauth.clientId("client-exchanger");
OAuthClient.AccessTokenResponse response = oauth.doGrantAccessTokenRequest("secret", "user", "password");
String accessToken = response.getAccessToken();
TokenVerifier<AccessToken> accessTokenVerifier = TokenVerifier.create(accessToken, AccessToken.class);
AccessToken token = accessTokenVerifier.parse().getToken();
Assert.assertEquals(token.getPreferredUsername(), "user");
Assert.assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example"));
{
response = oauth.doTokenExchange(TEST, accessToken, null, "different-scope-client", "secret");
String exchangedTokenString = response.getAccessToken();
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
Assert.assertEquals("different-scope-client", exchangedToken.getIssuedFor());
Assert.assertNull(exchangedToken.getAudience());
Assert.assertEquals(exchangedToken.getPreferredUsername(), "user");
Assert.assertNames(Arrays.asList(exchangedToken.getScope().split(" ")),"profile","openid");
Assert.assertNull(exchangedToken.getEmailVerified());
}
{
response = oauth.doTokenExchange(TEST, accessToken, "target", "different-scope-client", "secret");
String exchangedTokenString = response.getAccessToken();
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
Assert.assertEquals("different-scope-client", exchangedToken.getIssuedFor());
Assert.assertEquals("target", exchangedToken.getAudience()[0]);
Assert.assertEquals(exchangedToken.getPreferredUsername(), "user");
Assert.assertTrue(exchangedToken.getRealmAccess().isUserInRole("example"));
Assert.assertNames(Arrays.asList(exchangedToken.getScope().split(" ")),"profile","email","openid");
Assert.assertFalse(exchangedToken.getEmailVerified());
}
}
@Test
@UncaughtServerErrorExpected
public void testExchangeDifferentScopesWithScopeParameter() throws Exception {
testingClient.server().run(TokenExchangeTestUtils::setupRealm);
oauth.realm(TEST);
oauth.clientId("client-exchanger");
oauth.scope("openid profile email phone");
OAuthClient.AccessTokenResponse response = oauth.doGrantAccessTokenRequest("secret", "user", "password");
String accessToken = response.getAccessToken();
TokenVerifier<AccessToken> accessTokenVerifier = TokenVerifier.create(accessToken, AccessToken.class);
AccessToken token = accessTokenVerifier.parse().getToken();
Assert.assertEquals(token.getPreferredUsername(), "user");
Assert.assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example"));
Assert.assertNames(Arrays.asList(token.getScope().split(" ")),"profile", "email", "openid", "phone");
//change scopes for token exchange - profile,phone must be removed
oauth.scope("openid profile email");
{
response = oauth.doTokenExchange(TEST, accessToken, null, "different-scope-client", "secret");
String exchangedTokenString = response.getAccessToken();
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
Assert.assertEquals("different-scope-client", exchangedToken.getIssuedFor());
Assert.assertNull(exchangedToken.getAudience());
Assert.assertEquals(exchangedToken.getPreferredUsername(), "user");
Assert.assertNames(Arrays.asList(exchangedToken.getScope().split(" ")),"profile", "openid");
Assert.assertNull(exchangedToken.getEmailVerified());
}
{
response = oauth.doTokenExchange(TEST, accessToken, "target", "different-scope-client", "secret");
String exchangedTokenString = response.getAccessToken();
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
Assert.assertEquals("different-scope-client", exchangedToken.getIssuedFor());
Assert.assertEquals("target", exchangedToken.getAudience()[0]);
Assert.assertEquals(exchangedToken.getPreferredUsername(), "user");
Assert.assertTrue(exchangedToken.getRealmAccess().isUserInRole("example"));
Assert.assertNames(Arrays.asList(exchangedToken.getScope().split(" ")),"profile", "email","openid");
Assert.assertFalse(exchangedToken.getEmailVerified());
}
oauth.scope(null);
}
@Test
@UncaughtServerErrorExpected
public void testExchangeFromPublicClient() throws Exception {
testingClient.server().run(TokenExchangeTestUtils::setupRealm);
oauth.realm(TEST);
oauth.clientId("direct-public");
OAuthClient.AuthorizationEndpointResponse authzResponse = oauth.doLogin("user", "password");
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(authzResponse.getCode(), "secret");
String accessToken = response.getAccessToken();
TokenVerifier<AccessToken> accessTokenVerifier = TokenVerifier.create(accessToken, AccessToken.class);
AccessToken token = accessTokenVerifier.parse().getToken();
Assert.assertEquals(token.getPreferredUsername(), "user");
assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example"));
response = oauth.doTokenExchange(TEST, accessToken, "target", "client-exchanger", "secret");
String exchangedTokenString = response.getAccessToken();
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
Assert.assertEquals("client-exchanger", exchangedToken.getIssuedFor());
Assert.assertEquals("target", exchangedToken.getAudience()[0]);
Assert.assertEquals(exchangedToken.getPreferredUsername(), "user");
assertTrue(exchangedToken.getRealmAccess().isUserInRole("example"));
// can exchange to itself because the client is within the audience of the token issued to the public client
response = oauth.doTokenExchange(TEST, accessToken, null, "client-exchanger", "secret");
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
// can not exchange to itself because the client is not within the audience of the token issued to the public client
response = oauth.doTokenExchange(TEST, accessToken, null, "direct-legal", "secret");
assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatusCode());
response = oauth.doTokenExchange(TEST, accessToken, null, "direct-public", null);
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
}
@Test
@UncaughtServerErrorExpected
public void testExchangeNoRefreshToken() throws Exception {
testingClient.server().run(TokenExchangeTestUtils::setupRealm);
oauth.realm(TEST);
oauth.clientId("client-exchanger");
ClientResource client = ApiUtil.findClientByClientId(adminClient.realm(TEST), "no-refresh-token");
ClientRepresentation clientRepresentation = client.toRepresentation();
clientRepresentation.getAttributes().put(OIDCConfigAttributes.USE_REFRESH_TOKEN, "false");
client.update(clientRepresentation);
OAuthClient.AccessTokenResponse response = oauth.doGrantAccessTokenRequest("secret", "user", "password");
String accessToken = response.getAccessToken();
{
response = oauth.doTokenExchange(TEST, accessToken, "target", "client-exchanger", "secret");
String exchangedTokenString = response.getAccessToken();
String refreshTokenString = response.getRefreshToken();
assertNotNull(exchangedTokenString);
assertNotNull(refreshTokenString);
}
{
response = oauth.doTokenExchange(TEST, accessToken, "target", "no-refresh-token", "secret");
String exchangedTokenString = response.getAccessToken();
String refreshTokenString = response.getRefreshToken();
assertNotNull(exchangedTokenString);
assertNull(refreshTokenString);
}
clientRepresentation.getAttributes().put(OIDCConfigAttributes.USE_REFRESH_TOKEN, "true");
client.update(clientRepresentation);
}
@Test
public void testClientExchangeToItself() throws Exception {
testingClient.server().run(TokenExchangeTestUtils::setupRealm);
oauth.realm(TEST);
oauth.clientId("client-exchanger");
OAuthClient.AccessTokenResponse response = oauth.doGrantAccessTokenRequest("secret", "user", "password");
String accessToken = response.getAccessToken();
TokenVerifier<AccessToken> accessTokenVerifier = TokenVerifier.create(accessToken, AccessToken.class);
AccessToken token = accessTokenVerifier.parse().getToken();
Assert.assertEquals(token.getPreferredUsername(), "user");
assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example"));
response = oauth.doTokenExchange(TEST, accessToken, null, "client-exchanger", "secret");
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
response = oauth.doTokenExchange(TEST, accessToken, "client-exchanger", "client-exchanger", "secret");
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
}
@Test
public void testClientExchange() throws Exception {
testingClient.server().run(TokenExchangeTestUtils::setupRealm);
oauth.realm(TEST);
oauth.clientId("direct-legal");
OAuthClient.AccessTokenResponse response = oauth.doGrantAccessTokenRequest("secret", "user", "password");
String accessToken = response.getAccessToken();
TokenVerifier<AccessToken> accessTokenVerifier = TokenVerifier.create(accessToken, AccessToken.class);
AccessToken token = accessTokenVerifier.parse().getToken();
Assert.assertEquals(token.getPreferredUsername(), "user");
assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example"));
response = oauth.doTokenExchange(TEST, accessToken, "target", "direct-legal", "secret");
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
}
@Test
public void testClientExchangeWithMoreAudiencesNotBreak() throws Exception {
testingClient.server().run(TokenExchangeTestUtils::setupRealm);
oauth.realm(TEST);
oauth.clientId("client-exchanger");
OAuthClient.AccessTokenResponse response = oauth.doGrantAccessTokenRequest("secret", "user", "password");
String accessToken = response.getAccessToken();
response = oauth.doTokenExchange(TEST, accessToken, List.of("target", "client-exchanger"), "client-exchanger", "secret", null);
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
}
@Test
public void testPublicClientNotAllowed() throws Exception {
testingClient.server().run(TokenExchangeTestUtils::setupRealm);
oauth.realm(TEST);
oauth.clientId("direct-legal");
OAuthClient.AccessTokenResponse response = oauth.doGrantAccessTokenRequest("secret", "user", "password");
String accessToken = response.getAccessToken();
TokenVerifier<AccessToken> accessTokenVerifier = TokenVerifier.create(accessToken, AccessToken.class);
AccessToken token = accessTokenVerifier.parse().getToken();
Assert.assertEquals(token.getPreferredUsername(), "user");
assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example"));
// public client has no permission to exchange with the client direct-legal to which the token was issued for
// if not set, the audience is calculated based on the client to which the token was issued for
response = oauth.doTokenExchange(TEST, accessToken, null, "direct-public", null);
assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatusCode());
assertEquals("Client is not the holder of the token", response.getErrorDescription());
// public client has no permission to exchange
response = oauth.doTokenExchange(TEST, accessToken, "target", "direct-public", null);
assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatusCode());
assertEquals("Client is not the holder of the token", response.getErrorDescription());
response = oauth.doTokenExchange(TEST, accessToken, "direct-legal", "direct-public", null);
assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatusCode());
assertEquals("Client is not the holder of the token", response.getErrorDescription());
// public client can not exchange a token to itself if the token was issued to another client
response = oauth.doTokenExchange(TEST, accessToken, "direct-public", "direct-public", null);
assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatusCode());
assertEquals("Client is not the holder of the token", response.getErrorDescription());
// client with access to exchange
response = oauth.doTokenExchange(TEST, accessToken, "target", "client-exchanger", "secret");
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
// client must pass the audience because the client has no permission to exchange with the calculated audience (direct-legal)
response = oauth.doTokenExchange(TEST, accessToken, null, "client-exchanger", "secret");
assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatusCode());
assertEquals("Client is not within the token audience", response.getErrorDescription());
}
@Test
@EnableFeature(value = Profile.Feature.DYNAMIC_SCOPES, skipRestart = true)
@UncaughtServerErrorExpected
public void testExchangeWithDynamicScopesEnabled() throws Exception {
testExchange();
}
@Test
public void testSupportedTokenTypesWhenValidatingSubjectToken() throws Exception {
testingClient.server().run(TokenExchangeTestUtils::setupRealm);
oauth.realm(TEST);
oauth.clientId("direct-legal");
oauth.scope(OAuth2Constants.SCOPE_OPENID);
ClientsResource clients = adminClient.realm(oauth.getRealm()).clients();
ClientRepresentation rep = clients.findByClientId(oauth.getClientId()).get(0);
rep.getAttributes().put(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, oauth.APP_ROOT + "/admin/backchannelLogout");
getCleanup().addCleanup(() -> {
rep.getAttributes().put(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, "");
clients.get(rep.getId()).update(rep);
});
clients.get(rep.getId()).update(rep);
String logoutToken;
oauth.clientSessionState("client-session");
oauth.doLogin("user", "password");
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "secret");
String idTokenString = tokenResponse.getIdToken();
String logoutUrl = oauth.getLogoutUrl().idTokenHint(idTokenString)
.postLogoutRedirectUri(oauth.APP_AUTH_ROOT).build();
driver.navigate().to(logoutUrl);
logoutToken = testingClient.testApp().getBackChannelRawLogoutToken();
Assert.assertNotNull(logoutToken);
OAuthClient.AccessTokenResponse response = oauth.doTokenExchange(TEST, logoutToken, "target", "direct-legal", "secret");
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
}
@Test
public void testExchangeForDifferentClient() throws Exception {
testingClient.server().run(TokenExchangeTestUtils::setupRealm);
// generate the first token for a public client
oauth.realm(TEST);
oauth.clientId("direct-public");
OAuthClient.AccessTokenResponse response = oauth.doGrantAccessTokenRequest("secret", "user", "password");
String accessToken = response.getAccessToken();
TokenVerifier<AccessToken> accessTokenVerifier = TokenVerifier.create(accessToken, AccessToken.class);
AccessToken token = accessTokenVerifier.parse().getToken();
Assert.assertEquals(token.getPreferredUsername(), "user");
assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example"));
Assert.assertNotNull(token.getSessionId());
String sid = token.getSessionId();
// perform token exchange with client-exchanger simulating it received the previous token
response = oauth.doTokenExchange(TEST, accessToken, "target", "client-exchanger", "secret");
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
accessToken = response.getAccessToken();
accessTokenVerifier = TokenVerifier.create(accessToken, AccessToken.class);
token = accessTokenVerifier.parse().getToken();
Assert.assertEquals("client-exchanger", token.getIssuedFor());
Assert.assertEquals("target", token.getAudience()[0]);
Assert.assertEquals(token.getPreferredUsername(), "user");
Assert.assertEquals(sid, token.getSessionId());
// perform a second token exchange just to check everything is OK
response = oauth.doTokenExchange(TEST, accessToken, "target", "client-exchanger", "secret");
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
accessToken = response.getAccessToken();
accessTokenVerifier = TokenVerifier.create(accessToken, AccessToken.class);
token = accessTokenVerifier.parse().getToken();
Assert.assertEquals("client-exchanger", token.getIssuedFor());
Assert.assertEquals("target", token.getAudience()[0]);
Assert.assertEquals(token.getPreferredUsername(), "user");
Assert.assertEquals(sid, token.getSessionId());
}
}

View File

@ -0,0 +1,542 @@
/*
* 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.testsuite.oauth.tokenexchange;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.client.WebTarget;
import jakarta.ws.rs.core.Form;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.common.Profile;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.idm.OAuth2ErrorRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.arquillian.annotation.DisableFeature;
import org.keycloak.testsuite.arquillian.annotation.UncaughtServerErrorExpected;
import org.keycloak.testsuite.util.AdminClientUtil;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.util.BasicAuthHelper;
import org.keycloak.util.JsonSerialization;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.instanceOf;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.keycloak.testsuite.auth.page.AuthRealm.TEST;
/**
* Tests for subject impersonation token exchange. For now, this class provides set of same tests for token-exchange-v1 as well as for token-exchange-subject-impersonation-v2.
*
* The class may be removed/refactored once V2 implementation will start to differ from V1 (based on new capabilities or removed some capabilities etc)
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public abstract class AbstractSubjectImpersonationTokenExchangeTest extends AbstractKeycloakTest {
@Rule
public AssertEvents events = new AssertEvents(this);
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
RealmRepresentation testRealmRep = new RealmRepresentation();
testRealmRep.setId(TEST);
testRealmRep.setRealm(TEST);
testRealmRep.setEnabled(true);
testRealms.add(testRealmRep);
}
@Override
protected boolean isImportAfterEachMethod() {
return true;
}
protected void checkFeatureDisabled() {
// Required feature should return Status code 400 - Feature doesn't work
testingClient.server().run(TokenExchangeTestUtils::addDirectExchanger);
Assert.assertEquals(400, checkTokenExchange().getStatus());
testingClient.server().run(TokenExchangeTestUtils::removeDirectExchanger);
}
@Test
public void checkFeatureEnabled() {
// Test if the required feature really works.
testingClient.server().run(TokenExchangeTestUtils::addDirectExchanger);
Assert.assertEquals(200, checkTokenExchange().getStatus());
testingClient.server().run(TokenExchangeTestUtils::removeDirectExchanger);
}
@Test
@UncaughtServerErrorExpected
public void testImpersonation() throws Exception {
testingClient.server().run(TokenExchangeTestUtils::setupRealm);
oauth.realm(TEST);
oauth.clientId("client-exchanger");
Client httpClient = AdminClientUtil.createResteasyClient();
WebTarget exchangeUrl = httpClient.target(OAuthClient.AUTH_SERVER_ROOT)
.path("/realms")
.path(TEST)
.path("protocol/openid-connect/token");
System.out.println("Exchange url: " + exchangeUrl.getUri().toString());
OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest("secret", "user", "password");
String accessToken = tokenResponse.getAccessToken();
TokenVerifier<AccessToken> accessTokenVerifier = TokenVerifier.create(accessToken, AccessToken.class);
AccessToken token = accessTokenVerifier.parse().getToken();
Assert.assertEquals(token.getPreferredUsername(), "user");
assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example"));
// client-exchanger can impersonate from token "user" to user "impersonated-user"
{
Response response = exchangeUrl.request()
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("client-exchanger", "secret"))
.post(Entity.form(
new Form()
.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)
.param(OAuth2Constants.SUBJECT_TOKEN, accessToken)
.param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE)
.param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user")
));
org.junit.Assert.assertEquals(200, response.getStatus());
AccessTokenResponse accessTokenResponse = response.readEntity(AccessTokenResponse.class);
response.close();
String exchangedTokenString = accessTokenResponse.getToken();
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
Assert.assertEquals("client-exchanger", exchangedToken.getIssuedFor());
assertNotNull(exchangedToken.getAudience());
Assert.assertEquals("impersonated-user", exchangedToken.getPreferredUsername());
Assert.assertNull(exchangedToken.getRealmAccess());
Object impersonatorRaw = exchangedToken.getOtherClaims().get("impersonator");
assertThat(impersonatorRaw, instanceOf(Map.class));
Map impersonatorClaim = (Map) impersonatorRaw;
Assert.assertEquals(token.getSubject(), impersonatorClaim.get("id"));
Assert.assertEquals("user", impersonatorClaim.get("username"));
}
// client-exchanger can impersonate from token "user" to user "impersonated-user" and to "target" client
{
Response response = exchangeUrl.request()
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("client-exchanger", "secret"))
.post(Entity.form(
new Form()
.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)
.param(OAuth2Constants.SUBJECT_TOKEN, accessToken)
.param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE)
.param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user")
.param(OAuth2Constants.AUDIENCE, "target")
));
org.junit.Assert.assertEquals(200, response.getStatus());
AccessTokenResponse accessTokenResponse = response.readEntity(AccessTokenResponse.class);
response.close();
String exchangedTokenString = accessTokenResponse.getToken();
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
Assert.assertEquals("client-exchanger", exchangedToken.getIssuedFor());
Assert.assertEquals("target", exchangedToken.getAudience()[0]);
Assert.assertEquals(exchangedToken.getPreferredUsername(), "impersonated-user");
assertTrue(exchangedToken.getRealmAccess().isUserInRole("example"));
}
try (Response response = exchangeUrl.request()
.post(Entity.form(
new Form()
.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)
.param(OAuth2Constants.CLIENT_ID, "direct-public")
.param(OAuth2Constants.SUBJECT_TOKEN, accessToken)
.param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE)
.param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user")
))) {
org.junit.Assert.assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus());
assertEquals("Client is not the holder of the token",
response.readEntity(OAuth2ErrorRepresentation.class).getErrorDescription());
}
try (Response response = exchangeUrl.request()
.post(Entity.form(
new Form()
.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)
.param(OAuth2Constants.CLIENT_ID, "direct-public")
.param(OAuth2Constants.SUBJECT_TOKEN, accessToken)
.param(OAuth2Constants.AUDIENCE, "direct-public")
.param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE)
.param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user")
))) {
org.junit.Assert.assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus());
assertEquals("Client is not the holder of the token",
response.readEntity(OAuth2ErrorRepresentation.class).getErrorDescription());
}
try (Response response = exchangeUrl.request()
.post(Entity.form(
new Form()
.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)
.param(OAuth2Constants.CLIENT_ID, "direct-public")
.param(OAuth2Constants.SUBJECT_TOKEN, accessToken)
.param(OAuth2Constants.AUDIENCE, "client-exchanger")
.param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE)
.param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user")
))) {
org.junit.Assert.assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus());
assertEquals("Client is not the holder of the token",
response.readEntity(OAuth2ErrorRepresentation.class).getErrorDescription());
}
}
@Test
@UncaughtServerErrorExpected
public void testIntrospectTokenAfterImpersonation() throws Exception {
testingClient.server().run(TokenExchangeTestUtils::setupRealm);
oauth.realm(TEST);
oauth.clientId("client-exchanger");
Client httpClient = AdminClientUtil.createResteasyClient();
WebTarget exchangeUrl = httpClient.target(OAuthClient.AUTH_SERVER_ROOT)
.path("/realms")
.path(TEST)
.path("protocol/openid-connect/token");
System.out.println("Exchange url: " + exchangeUrl.getUri().toString());
OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest("secret", "user", "password");
String accessToken = tokenResponse.getAccessToken();
try (Response response = exchangeUrl.request()
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("client-exchanger", "secret"))
.post(Entity.form(
new Form()
.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)
.param(OAuth2Constants.SUBJECT_TOKEN, accessToken)
.param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE)
.param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user")
))) {
org.junit.Assert.assertEquals(200, response.getStatus());
AccessTokenResponse accessTokenResponse = response.readEntity(AccessTokenResponse.class);
String exchangedTokenString = accessTokenResponse.getToken();
JsonNode json = JsonSerialization.readValue(oauth.introspectAccessTokenWithClientCredential("client-exchanger", "secret", exchangedTokenString), com.fasterxml.jackson.databind.JsonNode.class);
assertTrue(json.get("active").asBoolean());
assertEquals("impersonated-user", json.get("preferred_username").asText());
assertEquals("user", json.get("act").get("sub").asText());
}
try (Response response = exchangeUrl.request()
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("client-exchanger", "secret"))
.post(Entity.form(
new Form()
.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)
.param(OAuth2Constants.SUBJECT_TOKEN, accessToken)
.param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE)
.param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user")
.param(OAuth2Constants.AUDIENCE, "target")
))) {
org.junit.Assert.assertEquals(200, response.getStatus());
AccessTokenResponse accessTokenResponse = response.readEntity(AccessTokenResponse.class);
String exchangedTokenString = accessTokenResponse.getToken();
JsonNode json = JsonSerialization.readValue(oauth.introspectAccessTokenWithClientCredential("client-exchanger", "secret", exchangedTokenString), com.fasterxml.jackson.databind.JsonNode.class);
assertTrue(json.get("active").asBoolean());
assertEquals("impersonated-user", json.get("preferred_username").asText());
assertEquals("user", json.get("act").get("sub").asText());
}
}
@UncaughtServerErrorExpected
@Test
public void testImpersonationUsingPublicClient() throws Exception {
testingClient.server().run(TokenExchangeTestUtils::setupRealm);
oauth.realm(TEST);
oauth.clientId("direct-public");
Client httpClient = AdminClientUtil.createResteasyClient();
OAuthClient.AuthorizationEndpointResponse authzResponse = oauth.doLogin("user", "password");
OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(authzResponse.getCode(), "secret");
String accessToken = tokenResponse.getAccessToken();
TokenVerifier<AccessToken> accessTokenVerifier = TokenVerifier.create(accessToken, AccessToken.class);
AccessToken token = accessTokenVerifier.parse().getToken();
Assert.assertEquals(token.getPreferredUsername(), "user");
assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example"));
WebTarget exchangeUrl = httpClient.target(OAuthClient.AUTH_SERVER_ROOT)
.path("/realms")
.path(TEST)
.path("protocol/openid-connect/token");
System.out.println("Exchange url: " + exchangeUrl.getUri().toString());
Response response = exchangeUrl.request()
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("direct-public", null))
.post(Entity.form(
new Form()
.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)
.param(OAuth2Constants.SUBJECT_TOKEN, accessToken)
.param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE)
.param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user")
));
org.junit.Assert.assertEquals(200, response.getStatus());
AccessTokenResponse accessTokenResponse = response.readEntity(AccessTokenResponse.class);
response.close();
String exchangedTokenString = accessTokenResponse.getToken();
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
Assert.assertEquals("direct-public", exchangedToken.getIssuedFor());
Assert.assertEquals("impersonated-user", exchangedToken.getPreferredUsername());
Assert.assertNull(exchangedToken.getRealmAccess());
testingClient.server().run(TokenExchangeTestUtils::setUpUserImpersonatePermissions);
}
@UncaughtServerErrorExpected
@Test
public void testImpersonationUsingTokenIssuedToUntrustedPublicClient() throws Exception {
testingClient.server().run(TokenExchangeTestUtils::setupRealm);
testingClient.server().run(TokenExchangeTestUtils::setUpUserImpersonatePermissions);
oauth.realm(TEST);
oauth.clientId("direct-public-untrusted");
Client httpClient = AdminClientUtil.createResteasyClient();
OAuthClient.AuthorizationEndpointResponse authzResponse = oauth.doLogin("user", "password");
OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(authzResponse.getCode(), "secret");
String accessToken = tokenResponse.getAccessToken();
TokenVerifier<AccessToken> accessTokenVerifier = TokenVerifier.create(accessToken, AccessToken.class);
AccessToken token = accessTokenVerifier.parse().getToken();
Assert.assertEquals(token.getPreferredUsername(), "user");
assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example"));
WebTarget exchangeUrl = httpClient.target(OAuthClient.AUTH_SERVER_ROOT)
.path("/realms")
.path(TEST)
.path("protocol/openid-connect/token");
System.out.println("Exchange url: " + exchangeUrl.getUri().toString());
Response response = exchangeUrl.request()
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("direct-public-untrusted", null))
.post(Entity.form(
new Form()
.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)
.param(OAuth2Constants.SUBJECT_TOKEN, accessToken)
.param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE)
.param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user")
));
org.junit.Assert.assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus());
oauth.idTokenHint(tokenResponse.getIdToken()).openLogout();
oauth.clientId("direct-public");
authzResponse = oauth.doLogin("user", "password");
tokenResponse = oauth.doAccessTokenRequest(authzResponse.getCode(), "secret");
accessToken = tokenResponse.getAccessToken();
response = exchangeUrl.request()
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("direct-public", null))
.post(Entity.form(
new Form()
.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)
.param(OAuth2Constants.SUBJECT_TOKEN, accessToken)
.param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE)
.param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user")
));
org.junit.Assert.assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
}
@Test
@UncaughtServerErrorExpected
public void testBadImpersonator() throws Exception {
testingClient.server().run(TokenExchangeTestUtils::setupRealm);
oauth.realm(TEST);
oauth.clientId("client-exchanger");
Client httpClient = AdminClientUtil.createResteasyClient();
WebTarget exchangeUrl = httpClient.target(OAuthClient.AUTH_SERVER_ROOT)
.path("/realms")
.path(TEST)
.path("protocol/openid-connect/token");
System.out.println("Exchange url: " + exchangeUrl.getUri().toString());
OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest("secret", "bad-impersonator", "password");
String accessToken = tokenResponse.getAccessToken();
TokenVerifier<AccessToken> accessTokenVerifier = TokenVerifier.create(accessToken, AccessToken.class);
AccessToken token = accessTokenVerifier.parse().getToken();
Assert.assertEquals(token.getPreferredUsername(), "bad-impersonator");
assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example"));
// test that user does not have impersonator permission
{
Response response = exchangeUrl.request()
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("client-exchanger", "secret"))
.post(Entity.form(
new Form()
.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)
.param(OAuth2Constants.SUBJECT_TOKEN, accessToken)
.param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE)
.param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user")
));
org.junit.Assert.assertEquals(403, response.getStatus());
response.close();
}
}
@Test
@UncaughtServerErrorExpected
public void testDirectImpersonation() throws Exception {
testingClient.server().run(TokenExchangeTestUtils::setupRealm);
Client httpClient = AdminClientUtil.createResteasyClient();
WebTarget exchangeUrl = httpClient.target(OAuthClient.AUTH_SERVER_ROOT)
.path("/realms")
.path(TEST)
.path("protocol/openid-connect/token");
System.out.println("Exchange url: " + exchangeUrl.getUri().toString());
// direct-exchanger can impersonate from token "user" to user "impersonated-user"
// see https://issues.redhat.com/browse/KEYCLOAK-5492
{
Response response = exchangeUrl.request()
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("direct-exchanger", "secret"))
.post(Entity.form(
new Form()
.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)
.param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user")
));
Assert.assertEquals(200, response.getStatus());
AccessTokenResponse accessTokenResponse = response.readEntity(AccessTokenResponse.class);
response.close();
String exchangedTokenString = accessTokenResponse.getToken();
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
Assert.assertEquals("direct-exchanger", exchangedToken.getIssuedFor());
Assert.assertNull(exchangedToken.getAudience());
Assert.assertEquals(exchangedToken.getPreferredUsername(), "impersonated-user");
Assert.assertNull(exchangedToken.getRealmAccess());
}
// direct-legal can impersonate from token "user" to user "impersonated-user" and to "target" client
{
Response response = exchangeUrl.request()
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("direct-legal", "secret"))
.post(Entity.form(
new Form()
.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)
.param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user")
.param(OAuth2Constants.AUDIENCE, "target")
));
Assert.assertEquals(200, response.getStatus());
AccessTokenResponse accessTokenResponse = response.readEntity(AccessTokenResponse.class);
response.close();
String exchangedTokenString = accessTokenResponse.getToken();
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
Assert.assertEquals("direct-legal", exchangedToken.getIssuedFor());
Assert.assertEquals("target", exchangedToken.getAudience()[0]);
Assert.assertEquals(exchangedToken.getPreferredUsername(), "impersonated-user");
assertTrue(exchangedToken.getRealmAccess().isUserInRole("example"));
}
// direct-public fails impersonation
{
Response response = exchangeUrl.request()
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("direct-public", "secret"))
.post(Entity.form(
new Form()
.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)
.param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user")
.param(OAuth2Constants.AUDIENCE, "target")
));
Assert.assertEquals(403, response.getStatus());
response.close();
}
// direct-no-secret fails impersonation
{
Response response = exchangeUrl.request()
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("direct-no-secret", "secret"))
.post(Entity.form(
new Form()
.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)
.param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user")
.param(OAuth2Constants.AUDIENCE, "target")
));
assertTrue(response.getStatus() >= 400);
response.close();
}
}
private Response checkTokenExchange() {
Client httpClient = AdminClientUtil.createResteasyClient();
WebTarget exchangeUrl = httpClient.target(OAuthClient.AUTH_SERVER_ROOT)
.path("/realms")
.path(TEST)
.path("protocol/openid-connect/token");
Response response = exchangeUrl.request()
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("direct-exchanger", "secret"))
.post(Entity.form(
new Form()
.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)
.param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user")
));
return response;
}
}

View File

@ -1,21 +1,23 @@
/*
* Copyright 2020 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
* 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
* 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
* 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.
*
* 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.testsuite.oauth;
package org.keycloak.testsuite.oauth.tokenexchange;
import org.junit.Rule;
import org.junit.Test;

View File

@ -0,0 +1,33 @@
/*
* 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.testsuite.oauth.tokenexchange;
import org.keycloak.common.Profile;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
/**
* Tests for standard token exchange (internal-internal) and token-exchange-v1
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@EnableFeature(value = Profile.Feature.TOKEN_EXCHANGE, skipRestart = true)
@EnableFeature(value = Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, skipRestart = true)
public class StandardTokenExchangeV1Test extends AbstractStandardTokenExchangeTest {
}

View File

@ -0,0 +1,31 @@
/*
* 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.testsuite.oauth.tokenexchange;
import org.keycloak.common.Profile;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@EnableFeature(value = Profile.Feature.TOKEN_EXCHANGE_STANDARD_V2, skipRestart = true)
@EnableFeature(value = Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, skipRestart = true) // TODO: Replace with admin-fine-grained-authz V2
public class StandardTokenExchangeV2Test extends AbstractStandardTokenExchangeTest {
}

View File

@ -0,0 +1,41 @@
/*
* 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.testsuite.oauth.tokenexchange;
import org.junit.Test;
import org.keycloak.common.Profile;
import org.keycloak.testsuite.arquillian.annotation.DisableFeature;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.arquillian.annotation.UncaughtServerErrorExpected;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@EnableFeature(value = Profile.Feature.TOKEN_EXCHANGE, skipRestart = true)
@EnableFeature(value = Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, skipRestart = true)
public class SubjectImpersonationTokenExchangeV1Test extends AbstractSubjectImpersonationTokenExchangeTest {
@Test
@UncaughtServerErrorExpected
@DisableFeature(value = Profile.Feature.TOKEN_EXCHANGE, skipRestart = true)
public void checkFeatureDisabled() {
super.checkFeatureDisabled();
}
}

View File

@ -0,0 +1,41 @@
/*
* 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.testsuite.oauth.tokenexchange;
import org.junit.Test;
import org.keycloak.common.Profile;
import org.keycloak.testsuite.arquillian.annotation.DisableFeature;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.arquillian.annotation.UncaughtServerErrorExpected;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@EnableFeature(value = Profile.Feature.TOKEN_EXCHANGE_SUBJECT_IMPERSONATION_V2, skipRestart = true)
@EnableFeature(value = Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, skipRestart = true) // TODO: Replace with admin-fine-grained-authz V2
public class SubjectImpersonationTokenExchangeV2Test extends AbstractSubjectImpersonationTokenExchangeTest {
@Test
@UncaughtServerErrorExpected
@DisableFeature(value = Profile.Feature.TOKEN_EXCHANGE_SUBJECT_IMPERSONATION_V2, skipRestart = true)
public void checkFeatureDisabled() {
super.checkFeatureDisabled();
}
}

View File

@ -0,0 +1,272 @@
/*
* 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.testsuite.oauth.tokenexchange;
import org.keycloak.authorization.model.Policy;
import org.keycloak.authorization.model.ResourceServer;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ImpersonationConstants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserCredentialModel;
import org.keycloak.models.UserModel;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.mappers.AudienceProtocolMapper;
import org.keycloak.protocol.oidc.mappers.UserSessionNoteMapper;
import org.keycloak.representations.idm.authorization.ClientPolicyRepresentation;
import org.keycloak.representations.idm.authorization.DecisionStrategy;
import org.keycloak.services.managers.ClientManager;
import org.keycloak.services.managers.RealmManager;
import org.keycloak.services.resources.admin.permissions.AdminPermissionManagement;
import org.keycloak.services.resources.admin.permissions.AdminPermissions;
import org.keycloak.testsuite.util.OAuthClient;
import static org.junit.Assert.assertNotNull;
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID;
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME;
import static org.keycloak.testsuite.auth.page.AuthRealm.TEST;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class TokenExchangeTestUtils {
private TokenExchangeTestUtils() {
}
public static void setupRealm(KeycloakSession session) {
addDirectExchanger(session);
RealmModel realm = session.realms().getRealmByName(TEST);
RoleModel exampleRole = realm.getRole("example");
AdminPermissionManagement management = AdminPermissions.management(session, realm);
ClientModel target = realm.getClientByClientId("target");
assertNotNull(target);
RoleModel impersonateRole = management.getRealmPermissionsClient().getRole(ImpersonationConstants.IMPERSONATION_ROLE);
ClientModel differentScopeClient = realm.addClient("different-scope-client");
differentScopeClient.setClientId("different-scope-client");
differentScopeClient.setPublicClient(false);
differentScopeClient.setDirectAccessGrantsEnabled(true);
differentScopeClient.setEnabled(true);
differentScopeClient.setSecret("secret");
differentScopeClient.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
differentScopeClient.setFullScopeAllowed(false);
differentScopeClient.removeClientScope(realm.getClientScopesStream().filter(scope->"email".equals(scope.getName())).findAny().get());
ClientModel clientExchanger = realm.addClient("client-exchanger");
clientExchanger.setClientId("client-exchanger");
clientExchanger.setPublicClient(false);
clientExchanger.setDirectAccessGrantsEnabled(true);
clientExchanger.setEnabled(true);
clientExchanger.setSecret("secret");
clientExchanger.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
clientExchanger.setFullScopeAllowed(false);
clientExchanger.addScopeMapping(impersonateRole);
clientExchanger.addProtocolMapper(UserSessionNoteMapper.createUserSessionNoteMapper(IMPERSONATOR_ID));
clientExchanger.addProtocolMapper(UserSessionNoteMapper.createUserSessionNoteMapper(IMPERSONATOR_USERNAME));
clientExchanger.addProtocolMapper(AudienceProtocolMapper.createClaimMapper("different-scope-client-audience", differentScopeClient.getClientId(), null, true, false, true));
ClientModel illegal = realm.addClient("illegal");
illegal.setClientId("illegal");
illegal.setPublicClient(false);
illegal.setDirectAccessGrantsEnabled(true);
illegal.setEnabled(true);
illegal.setSecret("secret");
illegal.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
illegal.setFullScopeAllowed(false);
ClientModel legal = realm.addClient("legal");
legal.setClientId("legal");
legal.setPublicClient(false);
legal.setDirectAccessGrantsEnabled(true);
legal.setEnabled(true);
legal.setSecret("secret");
legal.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
legal.setFullScopeAllowed(false);
ClientModel directLegal = realm.addClient("direct-legal");
directLegal.setClientId("direct-legal");
directLegal.setPublicClient(false);
directLegal.setDirectAccessGrantsEnabled(true);
directLegal.setEnabled(true);
directLegal.setSecret("secret");
directLegal.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
directLegal.setFullScopeAllowed(false);
directLegal.addRedirectUri(OAuthClient.APP_ROOT + "/auth");
ClientModel directPublic = realm.addClient("direct-public");
directPublic.setClientId("direct-public");
directPublic.setPublicClient(true);
directPublic.setDirectAccessGrantsEnabled(true);
directPublic.setEnabled(true);
directPublic.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
directPublic.setFullScopeAllowed(false);
directPublic.addRedirectUri("*");
directPublic.addProtocolMapper(AudienceProtocolMapper.createClaimMapper("client-exchanger-audience", clientExchanger.getClientId(), null, true, false, true));
ClientModel directUntrustedPublic = realm.addClient("direct-public-untrusted");
directUntrustedPublic.setClientId("direct-public-untrusted");
directUntrustedPublic.setPublicClient(true);
directUntrustedPublic.setDirectAccessGrantsEnabled(true);
directUntrustedPublic.setEnabled(true);
directUntrustedPublic.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
directUntrustedPublic.setFullScopeAllowed(false);
directUntrustedPublic.addRedirectUri("*");
directUntrustedPublic.setAttribute(OIDCConfigAttributes.POST_LOGOUT_REDIRECT_URIS, "+");
directUntrustedPublic.addProtocolMapper(AudienceProtocolMapper.createClaimMapper("client-exchanger-audience", clientExchanger.getClientId(), null, true, false, true));
ClientModel directNoSecret = realm.addClient("direct-no-secret");
directNoSecret.setClientId("direct-no-secret");
directNoSecret.setPublicClient(false);
directNoSecret.setDirectAccessGrantsEnabled(true);
directNoSecret.setEnabled(true);
directNoSecret.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
directNoSecret.setFullScopeAllowed(false);
ClientModel noRefreshToken = realm.addClient("no-refresh-token");
noRefreshToken.setClientId("no-refresh-token");
noRefreshToken.setPublicClient(false);
noRefreshToken.setDirectAccessGrantsEnabled(true);
noRefreshToken.setEnabled(true);
noRefreshToken.setSecret("secret");
noRefreshToken.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
noRefreshToken.setFullScopeAllowed(false);
noRefreshToken.getAttributes().put(OIDCConfigAttributes.USE_REFRESH_TOKEN, "false");
ClientModel serviceAccount = realm.addClient("my-service-account");
serviceAccount.setClientId("my-service-account");
serviceAccount.setPublicClient(false);
serviceAccount.setServiceAccountsEnabled(true);
serviceAccount.setEnabled(true);
serviceAccount.setSecret("secret");
serviceAccount.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
serviceAccount.setFullScopeAllowed(false);
new ClientManager(new RealmManager(session)).enableServiceAccount(serviceAccount);
// permission for client to client exchange to "target" client
ClientPolicyRepresentation clientRep = new ClientPolicyRepresentation();
clientRep.setName("to");
clientRep.addClient(clientExchanger.getId());
clientRep.addClient(legal.getId());
clientRep.addClient(directLegal.getId());
clientRep.addClient(noRefreshToken.getId());
clientRep.addClient(serviceAccount.getId());
clientRep.addClient(differentScopeClient.getId());
ResourceServer server = management.realmResourceServer();
Policy clientPolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientRep);
management.clients().exchangeToPermission(target).addAssociatedPolicy(clientPolicy);
// permission for user impersonation for a client
ClientPolicyRepresentation clientImpersonateRep = new ClientPolicyRepresentation();
clientImpersonateRep.setName("clientImpersonators");
clientImpersonateRep.addClient(directLegal.getId());
clientImpersonateRep.addClient(directPublic.getId());
clientImpersonateRep.addClient(directUntrustedPublic.getId());
clientImpersonateRep.addClient(directNoSecret.getId());
server = management.realmResourceServer();
Policy clientImpersonatePolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientImpersonateRep);
management.users().setPermissionsEnabled(true);
management.users().adminImpersonatingPermission().addAssociatedPolicy(clientImpersonatePolicy);
management.users().adminImpersonatingPermission().setDecisionStrategy(DecisionStrategy.AFFIRMATIVE);
UserModel user = session.users().addUser(realm, "user");
user.setEnabled(true);
user.credentialManager().updateCredential(UserCredentialModel.password("password"));
user.grantRole(exampleRole);
user.grantRole(impersonateRole);
UserModel bad = session.users().addUser(realm, "bad-impersonator");
bad.setEnabled(true);
bad.credentialManager().updateCredential(UserCredentialModel.password("password"));
}
public static void setUpUserImpersonatePermissions(KeycloakSession session) {
RealmModel realm = session.realms().getRealmByName(TEST);
AdminPermissionManagement management = AdminPermissions.management(session, realm);
ResourceServer server = management.realmResourceServer();
Policy userImpersonationPermission = management.users().userImpersonatedPermission();
ClientPolicyRepresentation clientsAllowedToImpersonateRep = new ClientPolicyRepresentation();
clientsAllowedToImpersonateRep.setName("clientsAllowedToImpersonateRep");
clientsAllowedToImpersonateRep.addClient("direct-public");
Policy clientsAllowedToImpersonate = management.authz().getStoreFactory().getPolicyStore().create(server, clientsAllowedToImpersonateRep);
userImpersonationPermission.addAssociatedPolicy(clientsAllowedToImpersonate);
}
public static void addDirectExchanger(KeycloakSession session) {
RealmModel realm = session.realms().getRealmByName(TEST);
RoleModel exampleRole = realm.addRole("example");
AdminPermissionManagement management = AdminPermissions.management(session, realm);
ClientModel target = realm.addClient("target");
target.setName("target");
target.setClientId("target");
target.setDirectAccessGrantsEnabled(true);
target.setEnabled(true);
target.setSecret("secret");
target.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
target.setFullScopeAllowed(false);
target.addScopeMapping(exampleRole);
ClientModel directExchanger = realm.addClient("direct-exchanger");
directExchanger.setName("direct-exchanger");
directExchanger.setClientId("direct-exchanger");
directExchanger.setPublicClient(false);
directExchanger.setDirectAccessGrantsEnabled(true);
directExchanger.setEnabled(true);
directExchanger.setSecret("secret");
directExchanger.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
directExchanger.setFullScopeAllowed(false);
// permission for client to client exchange to "target" client
management.clients().setPermissionsEnabled(target, true);
ClientPolicyRepresentation clientImpersonateRep = new ClientPolicyRepresentation();
clientImpersonateRep.setName("clientImpersonatorsDirect");
clientImpersonateRep.addClient(directExchanger.getId());
ResourceServer server = management.realmResourceServer();
Policy clientImpersonatePolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientImpersonateRep);
management.users().setPermissionsEnabled(true);
management.users().adminImpersonatingPermission().addAssociatedPolicy(clientImpersonatePolicy);
management.users().adminImpersonatingPermission().setDecisionStrategy(DecisionStrategy.AFFIRMATIVE);
UserModel impersonatedUser = session.users().addUser(realm, "impersonated-user");
impersonatedUser.setEnabled(true);
impersonatedUser.credentialManager().updateCredential(UserCredentialModel.password("password"));
impersonatedUser.grantRole(exampleRole);
}
public static void removeDirectExchanger(KeycloakSession session) {
RealmModel realm = session.realms().getRealmByName(TEST);
realm.removeClient(realm.getClientByClientId("direct-exchanger").getId());
realm.removeClient(realm.getClientByClientId("target").getId());
realm.removeRole(realm.getRole("example"));
session.users().removeUser(realm, session.users().getUserByUsername(realm, "impersonated-user"));
}
}

View File

@ -109,7 +109,6 @@ import static org.keycloak.testsuite.auth.page.AuthRealm.TEST;
import org.keycloak.testsuite.updaters.ClientAttributeUpdater;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createAnyClientConditionConfig;
@EnableFeature(value = Profile.Feature.TOKEN_EXCHANGE, skipRestart = true)
public class LightWeightAccessTokenTest extends AbstractClientPoliciesTest {
private static final Logger logger = Logger.getLogger(LightWeightAccessTokenTest.class);
private static String RESOURCE_SERVER_CLIENT_ID = "resource-server";
@ -286,6 +285,7 @@ public class LightWeightAccessTokenTest extends AbstractClientPoliciesTest {
}
@Test
@EnableFeature(value = Profile.Feature.TOKEN_EXCHANGE, skipRestart = true)
public void exchangeTest() throws Exception {
ProtocolMappersResource protocolMappers = setProtocolMappers(false, true, true);
try {