Skip to content
Draft
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
21 changes: 21 additions & 0 deletions docs/guides/securing-apps/token-exchange.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ The `may_act` claim declares that the specified administrator (actor) is authori

If the administrator (actor) does not have impersonation permission, the delegation scope is silently dropped and the `may_act` claim is not included in the token. The consent for delegation is always required and is not stored permanently - the user must approve delegation each login session.

[[_adding_additional_claims_to_may_act]]
==== Adding additional claims to `may_act`

By default, only the actor's `sub` (user ID) is included in the `may_act` claim. Additional claims can be added by configuring extra "Parameterized Scope User Property" mappers on the `delegation` client scope in the Admin Console.
Expand All @@ -403,6 +404,26 @@ The resulting token will then include:

Other useful claims to add include `may_act.email` (user property `email`) or `may_act.iss` (for cross-realm delegation scenarios).

==== Configuring enforced claims

During token exchange, {project_name} validates that certain claims in `may_act` match the actor token. The default enforced claims (`sub` and `iss`) are always checked and cannot be removed. Additional enforced claim names can be configured via the `may_act enforce_claims` mapper on the `delegation` client scope — these are merged with the defaults. Since `iss` is not included in `may_act` by default (no mapper adds it), it is silently skipped unless explicitly mapped. If an enforced claim is absent from `may_act`, it is always silently skipped. If the mapper is removed, {project_name} falls back to enforcing only the defaults (`sub` and `iss`).

[source,json]
----
{
"may_act": {
"sub": "f1234567-89ab-cdef-0123-456789abcdef",
"enforce_claims": ["sub", "iss"]
}
}
----

To enforce additional claims during token exchange:

. Navigate to *Client scopes* -> *delegation* -> *Mappers* -> *may_act enforce_claims*.
. Edit the *Token Claim Value* to include the additional claim names, for example `["sub","iss","email"]`.
. Ensure the corresponding claim is also added to `may_act` via a mapper (see <<_adding_additional_claims_to_may_act>>).

[[_legacy-token-exchange]]
== Legacy token exchange

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
import org.keycloak.protocol.oidc.mappers.AllowedWebOriginsProtocolMapper;
import org.keycloak.protocol.oidc.mappers.AudienceResolveProtocolMapper;
import org.keycloak.protocol.oidc.mappers.FullNameMapper;
import org.keycloak.protocol.oidc.mappers.HardcodedClaim;
import org.keycloak.protocol.oidc.mappers.ParameterizedScopeUserPropertyMapper;
import org.keycloak.protocol.oidc.mappers.SubMapper;
import org.keycloak.protocol.oidc.mappers.UserAttributeMapper;
Expand All @@ -54,12 +55,14 @@
import org.keycloak.protocol.oidc.mappers.UserRealmRoleMappingMapper;
import org.keycloak.protocol.oidc.mappers.UserSessionNoteMapper;
import org.keycloak.protocol.oidc.scope.DelegationScopeType;
import org.keycloak.protocol.oidc.tokenexchange.TokenExchangeDelegationProvider;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.util.JsonSerialization;

import org.jboss.logging.Logger;

Expand Down Expand Up @@ -112,6 +115,7 @@ public class OIDCLoginProtocolFactory extends AbstractLoginProtocolFactory {
public static final String ALLOWED_WEB_ORIGINS = "allowed web origins";
public static final String ACR = "acr loa level";
public static final String DELEGATION_MAY_ACT_SUB = "may_act sub";
public static final String DELEGATION_ENFORCE_CLAIMS = "may_act enforce_claims";
public static final String ORGANIZATION = "organization";
// microprofile-jwt claims
public static final String UPN = "upn";
Expand Down Expand Up @@ -279,6 +283,10 @@ void initBuiltIns() {
model = ParameterizedScopeUserPropertyMapper.create(
DELEGATION_MAY_ACT_SUB, "id", MAY_ACT + "." + SUBJECT, "String", true, true, true);
builtins.put(DELEGATION_MAY_ACT_SUB, model);

model = HardcodedClaim.create(DELEGATION_ENFORCE_CLAIMS,
MAY_ACT + "." + TokenExchangeDelegationProvider.ENFORCE_CLAIMS, toJsonString(TokenExchangeDelegationProvider.DEFAULT_ENFORCED_CLAIMS), "JSON", true, true, true);
builtins.put(DELEGATION_ENFORCE_CLAIMS, model);
}

model = UserSessionNoteMapper.createClaimMapper(IDToken.AUTH_TIME, AuthenticationManager.AUTH_TIME,
Expand Down Expand Up @@ -391,6 +399,7 @@ protected void createDefaultClientScopesImpl(RealmModel newRealm) {
delegationScope.setProtocol(getId());
delegationScope.setConsentScreenText("${delegationScopeConsentText}");
delegationScope.addProtocolMapper(builtins.get(DELEGATION_MAY_ACT_SUB));
delegationScope.addProtocolMapper(builtins.get(DELEGATION_ENFORCE_CLAIMS));
}
}

Expand Down Expand Up @@ -684,4 +693,12 @@ public List<ProviderConfigProperty> getConfigMetadata() {
.add()
.build();
}

private static String toJsonString(Object value) {
try {
return JsonSerialization.writeValueAsString(value);
} catch (Exception e) {
throw new RuntimeException("Failed to serialize value to JSON", e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@
*/
package org.keycloak.protocol.oidc.tokenexchange;

import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import jakarta.ws.rs.core.Response;

Expand All @@ -39,16 +43,19 @@
import org.keycloak.representations.IDToken;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.Urls;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.UserSessionManager;
import org.keycloak.util.JsonSerialization;

/**
*
* @author rmartinc
*/
public class TokenExchangeDelegationProvider extends StandardTokenExchangeProvider {

public static final String ENFORCE_CLAIMS = "enforce_claims";
public static final List<String> DEFAULT_ENFORCED_CLAIMS = List.of(JsonWebToken.SUBJECT, OIDCLoginProtocol.ISSUER);

private AccessToken actorAccessToken;
private AccessToken subjectAccessToken;

Expand Down Expand Up @@ -149,25 +156,49 @@ protected void validateMayAct(AccessToken subjectAccessToken, UserModel subjectU
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Invalid may_act claim in the subject_token", Response.Status.BAD_REQUEST);
}

Object issObject = mayActMap.get(OIDCLoginProtocol.ISSUER);
if (issObject != null && !issObject.equals(Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()))) {
event.detail(Details.REASON, "Invalid issuer in the may_act claim of the subject_token");
if (!mayActMap.containsKey(JsonWebToken.SUBJECT)) {
event.detail(Details.REASON, "Missing mandatory 'sub' claim in may_act");
event.error(Errors.INVALID_TOKEN);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Invalid issuer in the may_act claim of the subject_token", Response.Status.BAD_REQUEST);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Missing mandatory 'sub' claim in may_act", Response.Status.BAD_REQUEST);
}

Object subjectObject = mayActMap.get(JsonWebToken.SUBJECT);
if (!(subjectObject instanceof String subject)) {
event.detail(Details.REASON, "Invalid may_act claim in the subject_token");
Collection<String> enforcedClaims;
try {
enforcedClaims = getEnforcedClaims(mayActMap);
} catch (IllegalArgumentException e) {
event.detail(Details.REASON, e.getMessage());
event.error(Errors.INVALID_TOKEN);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Invalid may_act claim in the subject_token", Response.Status.BAD_REQUEST);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, e.getMessage(), Response.Status.BAD_REQUEST);
}
Map<String, Object> actorClaims = JsonSerialization.mapper.convertValue(actorAccessToken, Map.class);
for (String claimName : enforcedClaims) {
Object mayActValue = mayActMap.get(claimName);
if (mayActValue == null) {
continue;
}
Object actorValue = actorClaims.get(claimName);
if (!mayActValue.equals(actorValue)) {
String reason = String.format("Enforced claim '%s' in may_act does not match actor token claim '%s'", claimName, claimName);
event.detail(Details.REASON, reason);
event.error(Errors.INVALID_TOKEN);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, reason, Response.Status.BAD_REQUEST);
}
}
}

if (!actorUser.getId().equals(subject)) {
event.detail(Details.REASON, "Actor user is not allowed by the may_act claim inside the subject_token");
event.error(Errors.INVALID_TOKEN);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Actor user is not allowed by the may_act claim inside the subject_token", Response.Status.BAD_REQUEST);
private Collection<String> getEnforcedClaims(Map<String, Object> mayActMap) {
Object enforceClaimsObj = mayActMap.get(ENFORCE_CLAIMS);
if (enforceClaimsObj instanceof Collection<?> list && !list.isEmpty()) {
Set<String> merged = new HashSet<>(DEFAULT_ENFORCED_CLAIMS);
for (Object item : list) {
if (!(item instanceof String s)) {
throw new IllegalArgumentException("enforce_claims must contain only string values");
}
merged.add(s);
}
return merged;
}
return DEFAULT_ENFORCED_CLAIMS;
}
Comment thread
mabartos marked this conversation as resolved.

@Override
Expand All @@ -176,6 +207,7 @@ protected void checkRequestedAudiences(TokenManager.AccessTokenResponseBuilder r

// add the "act" claim using the "may_act" claim sent in the subjectToken, chain current actor if present
Map act = new HashMap((Map) subjectAccessToken.getOtherClaims().get(IDToken.MAY_ACT)); // should be present at this point
act.remove(ENFORCE_CLAIMS);
Comment thread
mabartos marked this conversation as resolved.
Object prevAct = actorAccessToken.getOtherClaims().get(IDToken.ACT);
if (prevAct != null) {
act.put(IDToken.ACT, prevAct);
Expand Down
Loading
Loading