Allow introspecting tokens issued during token exchange with delegation semantics
Closes #9337
This commit is contained in:
parent
ea4b4b97b4
commit
25be07be17
5 changed files with 114 additions and 19 deletions
|
@ -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;
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue