mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-10 15:32:05 -03:30
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:
parent
537ce7eb38
commit
ec5a8d161a
@ -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),
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
@ -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 {
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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 {
|
||||
}
|
||||
@ -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 {
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user