Allow introspecting tokens issued during token exchange with delegation semantics

Closes #9337
This commit is contained in:
Pedro Igor 2022-08-15 17:10:30 -03:00
parent ea4b4b97b4
commit 25be07be17
5 changed files with 114 additions and 19 deletions

View file

@ -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;

View file

@ -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();
}

View file

@ -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);

View file

@ -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 {

View file

@ -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 <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
@ -279,7 +284,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest {
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"));
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<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"));
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<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"));
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<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"));
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<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"));
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<AccessToken> 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<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"));
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<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"));
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<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"));
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