diff --git a/server-spi-private/src/main/java/org/keycloak/models/ImpersonationSessionNote.java b/server-spi-private/src/main/java/org/keycloak/models/ImpersonationSessionNote.java index 64621b7f1d..aea4393043 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/ImpersonationSessionNote.java +++ b/server-spi-private/src/main/java/org/keycloak/models/ImpersonationSessionNote.java @@ -5,7 +5,8 @@ package org.keycloak.models; */ public enum ImpersonationSessionNote implements UserSessionNoteDescriptor { IMPERSONATOR_ID("Impersonator User ID"), - IMPERSONATOR_USERNAME("Impersonator Username"); + IMPERSONATOR_USERNAME("Impersonator Username"), + IMPERSONATOR_CLIENT("Impersonator Client"); final String displayName; diff --git a/services/src/main/java/org/keycloak/protocol/oidc/AccessTokenIntrospectionProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/AccessTokenIntrospectionProvider.java index ea7a429162..408f984862 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/AccessTokenIntrospectionProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/AccessTokenIntrospectionProvider.java @@ -23,9 +23,11 @@ import org.keycloak.TokenVerifier; import org.keycloak.common.VerificationException; import org.keycloak.crypto.SignatureProvider; import org.keycloak.crypto.SignatureVerifierContext; +import org.keycloak.models.ImpersonationSessionNote; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; import org.keycloak.representations.AccessToken; import org.keycloak.services.Urls; import org.keycloak.util.JsonSerialization; @@ -68,6 +70,21 @@ public class AccessTokenIntrospectionProvider implements TokenIntrospectionProvi } } } + + String sessionState = accessToken.getSessionState(); + + if (sessionState != null) { + UserSessionModel userSession = session.sessions().getUserSession(realm, sessionState); + + if (userSession != null) { + String actor = userSession.getNote(ImpersonationSessionNote.IMPERSONATOR_USERNAME.toString()); + + if (actor != null) { + // for token exchange delegation semantics when an entity (actor) other than the subject is the acting party to whom authority has been delegated + tokenMetadata.putObject("act").put("sub", actor); + } + } + } } else { tokenMetadata = JsonSerialization.createObjectNode(); } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProvider.java index d2e68d1607..7bf2b69036 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProvider.java @@ -71,6 +71,7 @@ import org.keycloak.sessions.RootAuthenticationSessionModel; import org.keycloak.util.TokenUtil; import static org.keycloak.authentication.authenticators.util.AuthenticatorUtils.getDisabledByBruteForceEventError; +import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_CLIENT; import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID; import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME; @@ -339,7 +340,7 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider { case OAuth2Constants.REFRESH_TOKEN_TYPE: return exchangeClientToOIDCClient(targetUser, targetUserSession, requestedTokenType, targetClient, audience, scope); case OAuth2Constants.SAML2_TOKEN_TYPE: - return exchangeClientToSAML2Client(targetUser, targetUserSession, requestedTokenType, targetClient, audience, scope); + return exchangeClientToSAML2Client(targetUser, targetUserSession, requestedTokenType, targetClient); } throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "requested_token_type unsupported", Response.Status.BAD_REQUEST); @@ -393,6 +394,11 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider { responseBuilder.getAccessToken().addAudience(audience); } + if (formParams.containsKey(OAuth2Constants.REQUESTED_SUBJECT)) { + // if "impersonation", store the client that originated the impersonated user session + targetUserSession.setNote(IMPERSONATOR_CLIENT.toString(), client.getId()); + } + if (requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE) && OIDCAdvancedConfigWrapper.fromClientModel(client).isUseRefreshToken()) { responseBuilder.generateRefreshToken(); @@ -412,8 +418,7 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider { return cors.builder(Response.ok(res, MediaType.APPLICATION_JSON_TYPE)).build(); } - protected Response exchangeClientToSAML2Client(UserModel targetUser, UserSessionModel targetUserSession, String requestedTokenType, - ClientModel targetClient, String audience, String scope) { + protected Response exchangeClientToSAML2Client(UserModel targetUser, UserSessionModel targetUserSession, String requestedTokenType, ClientModel targetClient) { // Create authSession with target SAML 2.0 client and authenticated user LoginProtocolFactory factory = (LoginProtocolFactory) session.getKeycloakSessionFactory() .getProviderFactory(LoginProtocol.class, SamlProtocol.LOGIN_PROTOCOL); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java index 1cf98ecd51..15e6cbe856 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -45,6 +45,7 @@ import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.ClientSessionContext; import org.keycloak.models.Constants; +import org.keycloak.models.ImpersonationSessionNote; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.RoleModel; @@ -259,6 +260,13 @@ public class TokenManager { UserSessionModel userSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, token.getSessionState(), false, client.getId()); + if (userSession == null) { + // also try to resolve sessions created during token exchange when the user is impersonated + userSession = session.sessions().getUserSessionWithPredicate(realm, + token.getSessionState(), false, + model -> client.getId().equals(model.getNote(ImpersonationSessionNote.IMPERSONATOR_CLIENT.toString()))); + } + if (AuthenticationManager.isSessionValid(realm, userSession)) { valid = isUserValid(session, realm, token, userSession.getUser()); } else { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientTokenExchangeTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientTokenExchangeTest.java index 1b698605ad..d0c11640db 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientTokenExchangeTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientTokenExchangeTest.java @@ -69,12 +69,17 @@ 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.junit.Assert.assertTrue; import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID; import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME; import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer; import org.keycloak.testsuite.util.AdminClientUtil; +import org.keycloak.util.JsonSerialization; + import static org.keycloak.testsuite.auth.page.AuthRealm.TEST; +import com.fasterxml.jackson.databind.JsonNode; + /** * @author Stian Thorgersen */ @@ -279,7 +284,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { TokenVerifier 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")); + assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example")); { response = oauth.doTokenExchange(TEST, accessToken, "target", "client-exchanger", "secret"); @@ -289,7 +294,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { Assert.assertEquals("client-exchanger", exchangedToken.getIssuedFor()); Assert.assertEquals("target", exchangedToken.getAudience()[0]); Assert.assertEquals(exchangedToken.getPreferredUsername(), "user"); - Assert.assertTrue(exchangedToken.getRealmAccess().isUserInRole("example")); + assertTrue(exchangedToken.getRealmAccess().isUserInRole("example")); } { @@ -301,7 +306,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { Assert.assertEquals("legal", exchangedToken.getIssuedFor()); Assert.assertEquals("target", exchangedToken.getAudience()[0]); Assert.assertEquals(exchangedToken.getPreferredUsername(), "user"); - Assert.assertTrue(exchangedToken.getRealmAccess().isUserInRole("example")); + assertTrue(exchangedToken.getRealmAccess().isUserInRole("example")); } { response = oauth.doTokenExchange(TEST, accessToken, "target", "illegal", "secret"); @@ -344,7 +349,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { TokenVerifier 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")); + assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example")); response = oauth.doTokenExchange(TEST, accessToken, "target", "client-exchanger", "secret"); String exchangedTokenString = response.getAccessToken(); @@ -353,7 +358,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { Assert.assertEquals("client-exchanger", exchangedToken.getIssuedFor()); Assert.assertEquals("target", exchangedToken.getAudience()[0]); Assert.assertEquals(exchangedToken.getPreferredUsername(), "user"); - Assert.assertTrue(exchangedToken.getRealmAccess().isUserInRole("example")); + 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"); @@ -388,7 +393,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { TokenVerifier 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")); + assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example")); // client-exchanger can impersonate from token "user" to user "impersonated-user" { @@ -445,7 +450,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { Assert.assertEquals("client-exchanger", exchangedToken.getIssuedFor()); Assert.assertEquals("target", exchangedToken.getAudience()[0]); Assert.assertEquals(exchangedToken.getPreferredUsername(), "impersonated-user"); - Assert.assertTrue(exchangedToken.getRealmAccess().isUserInRole("example")); + assertTrue(exchangedToken.getRealmAccess().isUserInRole("example")); } try (Response response = exchangeUrl.request() @@ -496,6 +501,65 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { } } + @Test + @UncaughtServerErrorExpected + public void testIntrospectTokenAfterImpersonation() throws Exception { + testingClient.server().run(ClientTokenExchangeTest::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 { @@ -512,7 +576,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { TokenVerifier 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")); + assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example")); WebTarget exchangeUrl = httpClient.target(OAuthClient.AUTH_SERVER_ROOT) .path("/realms") @@ -561,7 +625,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { TokenVerifier 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")); + assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example")); WebTarget exchangeUrl = httpClient.target(OAuthClient.AUTH_SERVER_ROOT) .path("/realms") @@ -621,7 +685,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { TokenVerifier accessTokenVerifier = TokenVerifier.create(accessToken, AccessToken.class); AccessToken token = accessTokenVerifier.parse().getToken(); Assert.assertEquals(token.getPreferredUsername(), "bad-impersonator"); - Assert.assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example")); + assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example")); // test that user does not have impersonator permission { @@ -699,7 +763,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { Assert.assertEquals("direct-legal", exchangedToken.getIssuedFor()); Assert.assertEquals("target", exchangedToken.getAudience()[0]); Assert.assertEquals(exchangedToken.getPreferredUsername(), "impersonated-user"); - Assert.assertTrue(exchangedToken.getRealmAccess().isUserInRole("example")); + assertTrue(exchangedToken.getRealmAccess().isUserInRole("example")); } // direct-public fails impersonation @@ -728,7 +792,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { .param(OAuth2Constants.AUDIENCE, "target") )); - Assert.assertTrue(response.getStatus() >= 400); + assertTrue(response.getStatus() >= 400); response.close(); } } @@ -779,7 +843,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { TokenVerifier 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")); + assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example")); response = oauth.doTokenExchange(TEST, accessToken, null, "client-exchanger", "secret"); assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode()); @@ -799,7 +863,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { TokenVerifier 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")); + assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example")); response = oauth.doTokenExchange(TEST, accessToken, "target", "direct-legal", "secret"); assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode()); @@ -816,7 +880,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { TokenVerifier 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")); + 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