Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/documentation/release_notes/topics/26_8_0.adoc
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
// Release notes should contain only headline-worthy new features,
// assuming that people who migrate will read the upgrading guide anyway.

= Security and Standards

== Impersonation audit improvements

As part of ongoing secure-by-default improvements to impersonation, token lifecycle events (`CODE_TO_TOKEN`, `REFRESH_TOKEN`, and others) are now enriched with `impersonator` and `impersonator_id` details, providing a complete audit trail across all token operations performed under impersonation.

Additionally, tokens issued from impersonation sessions now include the `act` (actor) claim (https://datatracker.ietf.org/doc/html/rfc8693#section-4.1[RFC 8693 Section 4.1]) in both access tokens and ID tokens. This allows downstream resource servers to identify the impersonator for audit purposes. The claim is always present and cannot be disabled.

= Themes

== Redesigned identity provider buttons on the login page
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ Custom login themes that override `social-providers.ftl` or apply CSS targeting

Test your custom login theme to ensure the identity provider buttons display correctly.

=== Token introspection `act` claim now uses user ID for `sub`

The token introspection endpoint previously set the `act.sub` field to the impersonator's username. It now uses the impersonator's user ID to be consistent with the `act` claim in JWT access tokens and ID tokens. The `preferred_username` field has been added to the introspection `act` object for consumers that need the impersonator's username.

If you have resource servers that parse `act.sub` from introspection responses and expect a username, update them to read `act.preferred_username` instead.

=== Email is no longer marked as verified by other flows

Previously, some flows marked the email of a user as verified even though their purpose was not email verification.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ public interface Details {
String CONSENT_VALUE_PERSISTED_CONSENT = "persistent_consent"; // Persistent consent used (was already granted by user before)
String IMPERSONATOR_REALM = "impersonator_realm";
String IMPERSONATOR = "impersonator";
String IMPERSONATOR_ID = "impersonator_id";

String CLIENT_AUTH_METHOD = "client_auth_method";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@
import org.keycloak.common.ClientConnection;
import org.keycloak.common.util.Time;
import org.keycloak.models.ClientModel;
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.models.utils.KeycloakModelUtils;
import org.keycloak.tracing.TracingAttributes;
import org.keycloak.tracing.TracingProvider;
import org.keycloak.utils.StringUtil;

import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.trace.StatusCode;
Expand Down Expand Up @@ -136,7 +138,18 @@ public EventBuilder user(String userId) {
}

public EventBuilder session(UserSessionModel session) {
event.setSessionId(session == null ? null : session.getId());
if (session == null) {
event.setSessionId(null);
return this;
}
event.setSessionId(session.getId());

String impersonatorId = session.getNote(ImpersonationSessionNote.IMPERSONATOR_ID.toString());
if (StringUtil.isNotBlank(impersonatorId)) {
detail(Details.IMPERSONATOR_ID, impersonatorId);
detail(Details.IMPERSONATOR, session.getNote(ImpersonationSessionNote.IMPERSONATOR_USERNAME.toString()));
}

return this;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.jboss.logging.Logger;

import static org.keycloak.representations.IDToken.ACT;
import static org.keycloak.representations.IDToken.PREFERRED_USERNAME;
import static org.keycloak.representations.JsonWebToken.SUBJECT;

/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
Expand Down Expand Up @@ -106,10 +110,15 @@ public Response introspect(String tokenStr, EventBuilder eventBuilder) {
}
}

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);
// "act" claim (RFC 8693 Section 4.1) identifies the impersonator for audit purposes
String impersonatorId = userSession.getNote(ImpersonationSessionNote.IMPERSONATOR_ID.toString());
if (impersonatorId != null) {
ObjectNode act = tokenMetadata.putObject(ACT);
act.put(SUBJECT, impersonatorId);
String impersonatorUsername = userSession.getNote(ImpersonationSessionNote.IMPERSONATOR_USERNAME.toString());
if (impersonatorUsername != null) {
act.put(PREFERRED_USERNAME, impersonatorUsername);
}
}

tokenMetadata.put(OAuth2Constants.TOKEN_TYPE, transformedToken.getType());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.Constants;
import org.keycloak.models.IdentityProviderQuery;
import org.keycloak.models.ImpersonationSessionNote;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.ProtocolMapperModel;
Expand Down Expand Up @@ -138,6 +139,7 @@
import org.keycloak.tracing.TracingProvider;
import org.keycloak.util.JWKSUtils;
import org.keycloak.util.TokenUtil;
import org.keycloak.utils.StringUtil;

import org.jboss.logging.Logger;

Expand All @@ -146,7 +148,10 @@
import static org.keycloak.events.Details.REASON;
import static org.keycloak.models.Constants.AUTHORIZATION_DETAILS_RESPONSE;
import static org.keycloak.models.light.LightweightUserAdapter.isLightweightUser;
import static org.keycloak.representations.IDToken.ACT;
import static org.keycloak.representations.IDToken.NONCE;
import static org.keycloak.representations.IDToken.PREFERRED_USERNAME;
import static org.keycloak.representations.JsonWebToken.SUBJECT;
import static org.keycloak.services.util.DPoPUtil.DPOP_JKT_TYPE;

/**
Expand Down Expand Up @@ -480,6 +485,7 @@ public AccessToken createClientAccessToken(KeycloakSession session, RealmModel r
ClientSessionContext clientSessionCtx, boolean isOffline) {
AccessToken token = initToken(session, realm, client, user, userSession, clientSessionCtx, isOffline);
token = transformAccessToken(session, token, userSession, clientSessionCtx);
setActClaimFromImpersonator(token, userSession);
return token;
}

Expand Down Expand Up @@ -1113,6 +1119,20 @@ protected AccessToken initToken(KeycloakSession session, RealmModel realm, Clien
return token;
}

// Sets the "act" claim (RFC 8693 Section 4.1) so downstream resource servers can identify the impersonator for audit purposes
private static void setActClaimFromImpersonator(JsonWebToken token, UserSessionModel userSession) {
String impersonatorId = userSession.getNote(ImpersonationSessionNote.IMPERSONATOR_ID.toString());
if (StringUtil.isNotBlank(impersonatorId)) {
Map<String, Object> act = new HashMap<>();
act.put(SUBJECT, impersonatorId);
String impersonatorUsername = userSession.getNote(ImpersonationSessionNote.IMPERSONATOR_USERNAME.toString());
if (StringUtil.isNotBlank(impersonatorUsername)) {
act.put(PREFERRED_USERNAME, impersonatorUsername);
}
token.getOtherClaims().put(ACT, act);
}
}

private Long getTokenExpiration(RealmModel realm, ClientModel client, UserSessionModel userSession,
AuthenticatedClientSessionModel clientSession, boolean offlineTokenRequested) {
boolean implicitFlow = false;
Expand Down Expand Up @@ -1386,6 +1406,7 @@ public AccessTokenResponseBuilder generateIDToken(boolean isIdTokenAsDetachedSig
if (isIdTokenAsDetachedSignature == false) {
idToken = tokenManager.transformIDToken(session, idToken, userSession, clientSessionCtx);
}
setActClaimFromImpersonator(idToken, userSession);
return this;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ public Response process(Context context) {
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "User not found", Response.Status.BAD_REQUEST);
}

event.session(userSession);
event.user(userSession.getUser());

if (!user.isEnabled()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ private UserSessionModel createUserSession(CIBAAuthenticationRequest request, Ma

event.detail(Details.CONSENT, Details.CONSENT_VALUE_CONSENT_GRANTED);
event.detail(Details.CODE_ID, userSession.getId());
event.session(userSession.getId());
event.session(userSession);
event.user(user);
logger.debugf("Successfully verified Authe Req Id '%s'. User session: '%s', client: '%s'", request, userSession.getId(), client.getId());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,6 @@ public Response process(Context context) {

String userSessionId = deviceCodeModel.getUserSessionId();
event.detail(Details.CODE_ID, userSessionId);
event.session(userSessionId);

// Retrieve UserSession
var userSessionProvider = session.sessions();
Expand All @@ -310,6 +309,7 @@ public Response process(Context context) {
Response.Status.BAD_REQUEST);
}

event.session(userSession);
event.user(userSession.getUser());

if (!user.isEnabled()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ public TokenManager.AccessTokenResponseBuilder refreshAccessToken(RefreshTokenCo
ClientSessionContext clientSessionCtx = validation.clientSessionCtx;
UserSessionModel userSession = validation.userSession;

event.session(userSession);
tokenManager.validateSelectedOrganization(session, oldRefreshToken, user);

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ private AccountRestService getAccountRestService(ClientModel client, String vers
throw new NotAuthorizedException("Invalid audience for client " + client.getClientId());
}

event.session(authResult.session());
Auth auth = new Auth(session.getContext().getRealm(), accessToken, authResult.user(), client, authResult.session(), false);

Cors.builder().checkAllowedOrigins(auth.getToken()).allowedMethods("GET", "PUT", "POST", "DELETE").auth().add();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,18 @@
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.common.Profile;
import org.keycloak.cookie.CookieType;
import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.models.AdminRoles;
import org.keycloak.models.Constants;
import org.keycloak.models.ImpersonationSessionNote;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.protocol.oidc.mappers.HardcodedClaim;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.EventRepresentation;
Expand Down Expand Up @@ -80,6 +85,7 @@
import org.keycloak.testframework.ui.webdriver.ManagedWebDriver;
import org.keycloak.testframework.util.ApiUtil;
import org.keycloak.tests.utils.admin.AdminApiUtil;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;

import org.apache.http.HttpResponse;
import org.apache.http.client.methods.HttpUriRequest;
Expand All @@ -88,6 +94,7 @@
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.util.EntityUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.Cookie;
Expand All @@ -99,11 +106,6 @@
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.notNullValue;

/**
* Tests Undertow Adapter
*
* @author <a href="mailto:bburke@redhat.com">Bill Burke</a>
*/
@KeycloakIntegrationTest(config = ImpersonationTest.ImpersonationTestServerConfig.class)
public class ImpersonationTest {

Expand Down Expand Up @@ -140,6 +142,11 @@ public class ImpersonationTest {
@InjectEvents(ref = "test-events", realmRef = "test")
Events events;

@AfterEach
public void afterEach() {
managedRealm.admin().users().get(managedUser.getId()).logout();
}

@Test
public void testImpersonateByMasterAdmin() {
// test that composite is set up right for impersonation role
Expand Down Expand Up @@ -230,6 +237,46 @@ public void testImpersonationWorksWhenAuthenticationSessionExists() throws Excep
assertThat(driver.getCurrentUrl(), containsString(testApp.getRedirectionUri()));
}

@Test
public void testImpersonationTokenContainsActClaim() {
AccessTokenResponse tokenResponse = impersonateAndGetTokenResponse("realm-admin", managedRealm.getName());
Map<String, Object> act = assertActClaimInTokens(tokenResponse, "realm-admin");

EventRepresentation loginEvent = events.poll();
EventAssertion.assertSuccess(loginEvent)
.type(EventType.LOGIN)
.userId(managedUser.getId())
.details(Details.IMPERSONATOR, "realm-admin")
.details(Details.IMPERSONATOR_ID, (String) act.get("sub"));

EventRepresentation codeToTokenEvent = events.poll();
EventAssertion.assertSuccess(codeToTokenEvent)
.type(EventType.CODE_TO_TOKEN)
.userId(managedUser.getId())
.details(Details.IMPERSONATOR, "realm-admin")
.details(Details.IMPERSONATOR_ID, (String) act.get("sub"));
}

@Test
public void testActClaimCannotBeOverriddenByMapper() {
String mapperId;
try (Response response = oauth.clientResource().getProtocolMappers().createMapper(
ModelToRepresentation.toRepresentation(
HardcodedClaim.create("act-override-mapper", "act",
"{\"sub\": \"fake-id\", \"preferred_username\": \"fake-user\"}", "JSON",
true, true, false)))) {
mapperId = ApiUtil.getCreatedId(response);
}
managedRealm.cleanup().add(r -> {
String clientId = oauth.clientResource().toRepresentation().getId();
r.clients().get(clientId).getProtocolMappers().delete(mapperId);
});

AccessTokenResponse tokenResponse = impersonateAndGetTokenResponse("realm-admin", managedRealm.getName());
Map<String, Object> act = assertActClaimInTokens(tokenResponse, "realm-admin");
assertThat((String) act.get("sub"), is(not("fake-id")));
}

// KEYCLOAK-17655
@Test
public void testImpersonationBySameRealmServiceAccount() throws Exception {
Expand Down Expand Up @@ -282,6 +329,21 @@ public void testImpersonationByMasterRealmServiceAccount() throws Exception {
testSuccessfulServiceAccountImpersonation(user, masterRealm.getName());
}

private AccessTokenResponse impersonateAndGetTokenResponse(String admin, String adminRealm) {
driver.open(keycloakUrls.getBase());
events.skipAll();

for (Cookie cookie : testSuccessfulImpersonation(admin, adminRealm)) {
driver.cookies().add(cookie);
}

oauth.openLoginForm();
String code = oauth.parseLoginResponse().getCode();
AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code);
Assertions.assertTrue(tokenResponse.isSuccess(), tokenResponse.getError());
return tokenResponse;
}

// Return the SSO cookie from the impersonated session
private Set<Cookie> testSuccessfulImpersonation(String admin, String adminRealm) {
// Login adminClient
Expand All @@ -291,6 +353,23 @@ private Set<Cookie> testSuccessfulImpersonation(String admin, String adminRealm)
}
}

@SuppressWarnings("unchecked")
private Map<String, Object> assertActClaimInTokens(AccessTokenResponse tokenResponse, String expectedUsername) {
AccessToken accessToken = oauth.verifyToken(tokenResponse.getAccessToken(), AccessToken.class);
Map<String, Object> act = (Map<String, Object>) accessToken.getOtherClaims().get("act");
assertThat(act, notNullValue());
assertThat((String) act.get("preferred_username"), is(expectedUsername));
assertThat((String) act.get("sub"), notNullValue());

IDToken idToken = oauth.verifyToken(tokenResponse.getIdToken(), IDToken.class);
Map<String, Object> idTokenAct = (Map<String, Object>) idToken.getOtherClaims().get("act");
assertThat(idTokenAct, notNullValue());
assertThat((String) idTokenAct.get("preferred_username"), is(expectedUsername));
assertThat((String) idTokenAct.get("sub"), notNullValue());

return act;
}

private Set<Cookie> extractIdentityCookies(BasicCookieStore cookieStore) {
return cookieStore.getCookies().stream()
.filter(c -> c.getName().startsWith(CookieType.IDENTITY.getName()))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,9 @@ public void testIntrospectTokenAfterImpersonation() throws Exception {

org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = oauth.doPasswordGrantRequest("user", "password");
String accessToken = tokenResponse.getAccessToken();
TokenVerifier<AccessToken> accessTokenVerifier = TokenVerifier.create(accessToken, AccessToken.class);
AccessToken token = accessTokenVerifier.parse().getToken();
String userId = token.getSubject();

try (Response response = exchangeUrl.request()
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("client-exchanger", "secret"))
Expand All @@ -287,7 +290,8 @@ public void testIntrospectTokenAfterImpersonation() throws Exception {
JsonNode json = oauth.doIntrospectionAccessTokenRequest(exchangedTokenString).asJsonNode();
assertTrue(json.get("active").asBoolean());
assertEquals("impersonated-user", json.get("preferred_username").asText());
assertEquals("user", json.get("act").get("sub").asText());
assertEquals(userId, json.get("act").get("sub").asText());
assertEquals("user", json.get("act").get("preferred_username").asText());
}

try (Response response = exchangeUrl.request()
Expand All @@ -307,7 +311,8 @@ public void testIntrospectTokenAfterImpersonation() throws Exception {
JsonNode json = oauth.doIntrospectionAccessTokenRequest(exchangedTokenString).asJsonNode();
assertTrue(json.get("active").asBoolean());
assertEquals("impersonated-user", json.get("preferred_username").asText());
assertEquals("user", json.get("act").get("sub").asText());
assertEquals(userId, json.get("act").get("sub").asText());
assertEquals("user", json.get("act").get("preferred_username").asText());
}
}

Expand Down
Loading