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
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ The `claim_token` parameter expects a BASE64 encoded JSON with a format similar

The format expects one or more claims where the value for each claim must be an array of strings.

== Pushing claims Using UMA
Claims with the `kc.` prefix are reserved for server-controlled evaluation context attributes (such as `kc.time.date_time`, `kc.client.id`, and `kc.realm.name`) and are filtered out from user-supplied claims. Do not use the `kc.` prefix for custom claims.

For more details about how to push claims when using UMA and permission tickets, please take a look at <<_service_protection_permission_api_papi, Permission API>>
== Pushing claims using UMA

For more details about how to push claims when using UMA and permission tickets, please take a look at <<_service_protection_permission_api_papi, Permission API>>.

When a UMA permission ticket contains claims and the authorization request also includes a `claim_token`, both sets of claims are merged. If the same claim key exists in both sources, the ticket claim takes precedence over the `claim_token` claim.
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ The token introspection endpoint previously set the `act.sub` field to the imper

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

=== Authorization services claim handling changes

Two changes affect how claims are handled in the authorization services token endpoint:

* Claims with the `kc.` prefix are now reserved for server-controlled evaluation context attributes (such as `kc.time.date_time`, `kc.client.id`, and `kc.realm.name`). User-supplied claims with this prefix, whether pushed via `claim_token` or via the UMA permission ticket, are filtered out before policy evaluation. If your authorization policies rely on custom claims that start with `kc.`, rename them to use a different prefix.

* When both a UMA permission ticket and a `claim_token` contain claims with the same key, ticket claims now take precedence. Previously, `claim_token` claims would silently override ticket claims on collision. This change ensures that claims set by the resource server when creating the permission ticket cannot be overridden by the requesting party.

=== 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 @@ -28,6 +28,7 @@
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
Expand All @@ -43,6 +44,7 @@
import org.keycloak.authorization.AuthorizationProvider;
import org.keycloak.authorization.common.DefaultEvaluationContext;
import org.keycloak.authorization.common.KeycloakIdentity;
import org.keycloak.authorization.identity.Identity;
import org.keycloak.authorization.model.PermissionTicket;
import org.keycloak.authorization.model.Resource;
import org.keycloak.authorization.model.ResourceServer;
Expand Down Expand Up @@ -112,37 +114,36 @@ public class AuthorizationTokenService {
private static final String RESPONSE_MODE_DECISION = "decision";
private static final String RESPONSE_MODE_PERMISSIONS = "permissions";
private static final String RESPONSE_MODE_DECISION_RESULT = "result";
private static Map<String, BiFunction<KeycloakAuthorizationRequest, AuthorizationProvider, EvaluationContext>> SUPPORTED_CLAIM_TOKEN_FORMATS;
private static Map<String, BiFunction<KeycloakAuthorizationRequest, AuthorizationProvider, Identity>> CLAIM_TOKEN_FORMAT_HANDLERS;

static {
SUPPORTED_CLAIM_TOKEN_FORMATS = new HashMap<>();
SUPPORTED_CLAIM_TOKEN_FORMATS.put(CLAIM_TOKEN_FORMAT_JWT, (request, authorization) -> {
Map claims = request.getClaims();
CLAIM_TOKEN_FORMAT_HANDLERS = new HashMap<>();
CLAIM_TOKEN_FORMAT_HANDLERS.put(CLAIM_TOKEN_FORMAT_JWT, (request, authorization) -> {
Map<String, List<String>> claims = new HashMap<>();
String claimToken = request.getClaimToken();

if (claimToken != null) {
try {
claims = JsonSerialization.readValue(Base64Url.decode(request.getClaimToken()), Map.class);
request.setClaims(claims);
Map<String, List<String>> claimTokenClaims = JsonSerialization.readValue(Base64Url.decode(request.getClaimToken()), Map.class);
claims.putAll(claimTokenClaims);
} catch (Exception cause) {
throw new CorsErrorResponseException(request.getCors(), "invalid_request", "Invalid claims",
Status.BAD_REQUEST);
}
}

KeycloakIdentity identity;
claims.putAll(Optional.ofNullable(request.getClaims()).orElse(Map.of()));
request.setClaims(claims);

try {
identity = new KeycloakIdentity(authorization.getKeycloakSession(),
return new KeycloakIdentity(authorization.getKeycloakSession(),
Tokens.getAccessToken(request.getSubjectToken(), authorization.getKeycloakSession()));
} catch (Exception cause) {
fireErrorEvent(request.getEvent(), Errors.INVALID_TOKEN, cause);
throw new CorsErrorResponseException(request.getCors(), "unauthorized_client", "Invalid identity", Status.BAD_REQUEST);
}

return new DefaultEvaluationContext(identity, claims, authorization.getKeycloakSession());
});
SUPPORTED_CLAIM_TOKEN_FORMATS.put(CLAIM_TOKEN_FORMAT_ID_TOKEN, (request, authorization) -> {
CLAIM_TOKEN_FORMAT_HANDLERS.put(CLAIM_TOKEN_FORMAT_ID_TOKEN, (request, authorization) -> {
KeycloakSession keycloakSession = authorization.getKeycloakSession();
String subjectToken = request.getSubjectToken();

Expand Down Expand Up @@ -174,16 +175,12 @@ public class AuthorizationTokenService {
throw exception;
}

KeycloakIdentity identity;

try {
identity = new KeycloakIdentity(keycloakSession, idToken);
return new KeycloakIdentity(keycloakSession, idToken);
} catch (Exception cause) {
fireErrorEvent(request.getEvent(), Errors.INVALID_TOKEN, cause);
throw new CorsErrorResponseException(request.getCors(), "unauthorized_client", "Invalid identity", Status.BAD_REQUEST);
}

return new DefaultEvaluationContext(identity, request.getClaims(), keycloakSession);
});
}

Expand Down Expand Up @@ -424,19 +421,21 @@ private boolean isUpgraded(AuthorizationRequest request, Authorization authoriza
}

private PermissionTicketToken getPermissionTicket(KeycloakAuthorizationRequest request) {
PermissionTicketToken ticket;

// if there is a ticket is because it is a UMA flow and the ticket was sent by the client after obtaining it from the target resource server
if (request.getTicket() != null) {
return verifyPermissionTicket(request);
}

// if there is no ticket, we use the permissions the client is asking for.
// This is a Keycloak extension to UMA flow where clients are capable of obtaining a RPT without a ticket
PermissionTicketToken permissions = request.getPermissions();
ticket = verifyPermissionTicket(request);
} else {
// if there is no ticket, we use the permissions the client is asking for.
// This is a Keycloak extension to UMA flow where clients are capable of obtaining a RPT without a ticket
ticket = request.getPermissions();

// an issuedFor must be set by the client when doing this method of obtaining RPT, that is how we know the target resource server
permissions.issuedFor(request.getAudience());
// an issuedFor must be set by the client when doing this method of obtaining RPT, that is how we know the target resource server
ticket.issuedFor(request.getAudience());
}

return permissions;
return ticket;
}

private ResourceServer getResourceServer(PermissionTicketToken ticket, KeycloakAuthorizationRequest request) {
Expand Down Expand Up @@ -477,15 +476,23 @@ private EvaluationContext createEvaluationContext(KeycloakAuthorizationRequest r
claimTokenFormat = CLAIM_TOKEN_FORMAT_JWT;
}

BiFunction<KeycloakAuthorizationRequest, AuthorizationProvider, EvaluationContext> evaluationContextProvider = SUPPORTED_CLAIM_TOKEN_FORMATS.get(claimTokenFormat);
BiFunction<KeycloakAuthorizationRequest, AuthorizationProvider, Identity> claimTokenFormatHandler = CLAIM_TOKEN_FORMAT_HANDLERS.get(claimTokenFormat);

if (evaluationContextProvider == null) {
if (claimTokenFormatHandler == null) {
CorsErrorResponseException unsupportedClaimTokenFormatException = new CorsErrorResponseException(request.getCors(), OAuthErrorException.INVALID_REQUEST, "Claim token format [" + claimTokenFormat + "] not supported", Status.BAD_REQUEST);
fireErrorEvent(request.getEvent(), Errors.INVALID_REQUEST, unsupportedClaimTokenFormatException);
throw unsupportedClaimTokenFormatException;
}

return evaluationContextProvider.apply(request, request.getAuthorization());
Identity identity = claimTokenFormatHandler.apply(request, request.getAuthorization());

Map<String, List<String>> claims = request.getClaims();

if (claims != null) {
claims.keySet().removeIf(key -> key.startsWith("kc."));
}

return new DefaultEvaluationContext(identity, claims, request.getKeycloakSession());
}

private Collection<ResourcePermission> createPermissions(PermissionTicketToken ticket, KeycloakAuthorizationRequest request, ResourceServer resourceServer, AuthorizationProvider authorization, EvaluationContext context) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;

import org.keycloak.authorization.attribute.Attributes;
import org.keycloak.authorization.identity.Identity;
Expand Down Expand Up @@ -59,7 +60,7 @@ public Identity getIdentity() {
}

protected Map<String, Collection<String>> getBaseAttributes() {
Map<String, Collection<String>> attributes = new HashMap<>();
Map<String, Collection<String>> attributes = new HashMap<>(Optional.ofNullable(claims).orElse(Map.of()));

attributes.put("kc.time.date_time", Arrays.asList(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date())));
attributes.put("kc.client.network.ip_address", Arrays.asList(this.keycloakSession.getContext().getConnection().getRemoteAddr()));
Expand All @@ -73,17 +74,11 @@ protected Map<String, Collection<String>> getBaseAttributes() {

attributes.put("kc.realm.name", Arrays.asList(this.keycloakSession.getContext().getRealm().getName()));

if (claims != null) {
for (Entry<String, List<String>> entry : claims.entrySet()) {
attributes.put(entry.getKey(), entry.getValue());
}
}

if (KeycloakIdentity.class.isInstance(identity)) {
AccessToken accessToken = KeycloakIdentity.class.cast(this.identity).getAccessToken();
if (identity instanceof KeycloakIdentity) {
AccessToken accessToken = ((KeycloakIdentity) this.identity).getAccessToken();

if (accessToken != null) {
attributes.put("kc.client.id", Arrays.asList(accessToken.getIssuedFor()));
attributes.put("kc.client.id", Collections.singletonList(accessToken.getIssuedFor()));
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package org.keycloak.testsuite.authorization;

import org.keycloak.Config;
import org.keycloak.authorization.AuthorizationProvider;
import org.keycloak.authorization.attribute.Attributes;
import org.keycloak.authorization.model.Policy;
import org.keycloak.authorization.model.ResourceServer;
import org.keycloak.authorization.policy.evaluation.Evaluation;
import org.keycloak.authorization.policy.provider.PolicyProvider;
import org.keycloak.authorization.policy.provider.PolicyProviderAdminService;
import org.keycloak.authorization.policy.provider.PolicyProviderFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.representations.idm.authorization.PolicyRepresentation;

public class ClaimAttributePolicyProvider implements PolicyProviderFactory<PolicyRepresentation>, PolicyProvider {

@Override
public String getName() {
return "Claim Attribute";
}

@Override
public String getGroup() {
return "Test Suite";
}

@Override
public PolicyProvider create(AuthorizationProvider authorization) {
return this;
}

@Override
public PolicyRepresentation toRepresentation(Policy policy, AuthorizationProvider authorization) {
return new PolicyRepresentation();
}

@Override
public Class getRepresentationType() {
return PolicyRepresentation.class;
}

@Override
public PolicyProviderAdminService getAdminResource(ResourceServer resourceServer, AuthorizationProvider authorization) {
return null;
}

@Override
public PolicyProvider create(KeycloakSession session) {
return null;
}

@Override
public void init(Config.Scope config) {
}

@Override
public void postInit(KeycloakSessionFactory factory) {
}

@Override
public void close() {
}

@Override
public String getId() {
return "claim-attribute";
}

@Override
public void evaluate(Evaluation evaluation) {
Policy policy = evaluation.getPolicy();
String claimName = policy.getConfig().get("claimName");
String claimValue = policy.getConfig().get("claimValue");
Attributes attributes = evaluation.getContext().getAttributes();
Attributes.Entry entry = attributes.getValue(claimName);

if (entry != null && claimValue.equals(entry.asString(0))) {
evaluation.grant();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ org.keycloak.testsuite.authorization.DenyPolicyProvider
org.keycloak.testsuite.authorization.GrantResourceOwnerPolicyProvider
org.keycloak.testsuite.authorization.ResourceVisibilityAttributePolicyProvider
org.keycloak.testsuite.authorization.SpecificDomainOrAdminPolicyProvider
org.keycloak.testsuite.authorization.SpecificAddressPolicyProvider
org.keycloak.testsuite.authorization.SpecificAddressPolicyProvider
org.keycloak.testsuite.authorization.ClaimAttributePolicyProvider
Loading
Loading