From ca205272ba6360bc808d19b5f8e2af119fa37c5a Mon Sep 17 00:00:00 2001 From: rmartinc Date: Wed, 19 Nov 2025 11:01:16 +0100 Subject: [PATCH] Initial integration of the JWT Authorization Grant in client Policies Using the downscope executor for testing Closes #44201 Signed-off-by: rmartinc --- ...WTAuthorizationGrantValidationContext.java | 8 + .../clientpolicy/ClientPolicyEvent.java | 1 + .../grants/JWTAuthorizationGrantType.java | 17 +- .../JWTAuthorizationGrantValidator.java | 25 ++- .../condition/ClientAccessTypeCondition.java | 2 + .../condition/ClientAttributesCondition.java | 2 + .../condition/ClientProtocolCondition.java | 2 + .../condition/ClientRolesCondition.java | 2 + .../condition/ClientScopesCondition.java | 10 + .../condition/GrantTypeCondition.java | 3 + .../condition/GrantTypeConditionFactory.java | 3 + .../context/JWTAuthorizationGrantContext.java | 50 +++++ ...wnscopeAssertionGrantEnforcerExecutor.java | 25 ++- .../realm/ClientPolicyBuilder.java | 93 ++++++++ .../realm/ClientProfileBuilder.java | 68 ++++++ .../realm/RealmConfigBuilder.java | 26 +++ .../AbstractJWTAuthorizationGrantTest.java | 149 +------------ ...BaseAbstractJWTAuthorizationGrantTest.java | 207 ++++++++++++++++++ ...ationGrantDownscopeClientPoliciesTest.java | 85 +++++++ 19 files changed, 620 insertions(+), 158 deletions(-) create mode 100644 services/src/main/java/org/keycloak/services/clientpolicy/context/JWTAuthorizationGrantContext.java create mode 100644 test-framework/core/src/main/java/org/keycloak/testframework/realm/ClientPolicyBuilder.java create mode 100644 test-framework/core/src/main/java/org/keycloak/testframework/realm/ClientProfileBuilder.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/oauth/BaseAbstractJWTAuthorizationGrantTest.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantDownscopeClientPoliciesTest.java diff --git a/server-spi-private/src/main/java/org/keycloak/protocol/oidc/JWTAuthorizationGrantValidationContext.java b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/JWTAuthorizationGrantValidationContext.java index 66dea31a806..de3e7ff45c8 100644 --- a/server-spi-private/src/main/java/org/keycloak/protocol/oidc/JWTAuthorizationGrantValidationContext.java +++ b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/JWTAuthorizationGrantValidationContext.java @@ -1,5 +1,7 @@ package org.keycloak.protocol.oidc; +import java.util.Set; + import org.keycloak.jose.jws.JWSInput; import org.keycloak.representations.JsonWebToken; @@ -12,6 +14,12 @@ public interface JWTAuthorizationGrantValidationContext { JWSInput getJws(); + String getScopeParam(); + + Set getRestrictedScopes(); + + void setRestrictedScopes(Set restrictedScopes); + default String getIssuer() { return getJWT().getIssuer(); } diff --git a/server-spi/src/main/java/org/keycloak/services/clientpolicy/ClientPolicyEvent.java b/server-spi/src/main/java/org/keycloak/services/clientpolicy/ClientPolicyEvent.java index a9ab46a4217..195bf84fd0e 100644 --- a/server-spi/src/main/java/org/keycloak/services/clientpolicy/ClientPolicyEvent.java +++ b/server-spi/src/main/java/org/keycloak/services/clientpolicy/ClientPolicyEvent.java @@ -54,6 +54,7 @@ public enum ClientPolicyEvent { TOKEN_EXCHANGE_REQUEST, RESOURCE_OWNER_PASSWORD_CREDENTIALS_REQUEST, RESOURCE_OWNER_PASSWORD_CREDENTIALS_RESPONSE, + JWT_AUTHORIZATION_GRANT, SAML_AUTHN_REQUEST, SAML_LOGOUT_REQUEST, diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java index 453e3a8b356..c52cce23eb8 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java @@ -38,6 +38,8 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.TokenManager; import org.keycloak.services.CorsErrorResponseException; import org.keycloak.services.Urls; +import org.keycloak.services.clientpolicy.ClientPolicyException; +import org.keycloak.services.clientpolicy.context.JWTAuthorizationGrantContext; import org.keycloak.services.managers.AuthenticationSessionManager; import org.keycloak.services.managers.UserSessionManager; import org.keycloak.services.resources.IdentityBrokerService; @@ -55,7 +57,7 @@ public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase { try { JWTAuthorizationGrantValidator authorizationGrantContext = JWTAuthorizationGrantValidator.createValidator( - context.getSession(), client, assertion); + context.getSession(), client, assertion, formParams.getFirst(OAuth2Constants.SCOPE)); //client must be confidential authorizationGrantContext.validateClient(); @@ -107,12 +109,23 @@ public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase { String scopeParam = getRequestedScopes(); + try { + session.clientPolicy().triggerOnEvent(new JWTAuthorizationGrantContext(authorizationGrantContext, identityProviderModel)); + } catch (ClientPolicyException cpe) { + event.detail(Details.REASON, Details.CLIENT_POLICY_ERROR); + event.detail(Details.CLIENT_POLICY_ERROR, cpe.getError()); + event.detail(Details.CLIENT_POLICY_ERROR_DETAIL, cpe.getErrorDetail()); + event.error(cpe.getError()); + throw new CorsErrorResponseException(cors, cpe.getError(), cpe.getErrorDetail(), cpe.getErrorStatus()); + } + RootAuthenticationSessionModel rootAuthSession = new AuthenticationSessionManager(session).createAuthenticationSession(realm, false); AuthenticationSessionModel authSession = createSessionModel(rootAuthSession, user, client, scopeParam); UserSessionModel userSession = new UserSessionManager(session).createUserSession(authSession.getParentSession().getId(), realm, user, user.getUsername(), clientConnection.getRemoteHost(), "authorization-grant", false, null, null, UserSessionModel.SessionPersistenceState.TRANSIENT); event.session(userSession); - ClientSessionContext clientSessionCtx = TokenManager.attachAuthenticationSession(this.session, userSession, authSession); + ClientSessionContext clientSessionCtx = TokenManager.attachAuthenticationSession(this.session, userSession, + authSession, authorizationGrantContext.getRestrictedScopes(), false); return createTokenResponse(user, userSession, clientSessionCtx, scopeParam, true, null); } catch (CorsErrorResponseException e) { throw e; diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantValidator.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantValidator.java index 47ae366ac0e..3ac34cc4597 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantValidator.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantValidator.java @@ -19,6 +19,8 @@ package org.keycloak.protocol.oidc.grants; +import java.util.Set; + import org.keycloak.OAuth2Constants; import org.keycloak.authentication.authenticators.client.AbstractBaseJWTValidator; import org.keycloak.authentication.authenticators.client.ClientAssertionState; @@ -39,7 +41,10 @@ import org.keycloak.representations.JsonWebToken; */ public class JWTAuthorizationGrantValidator extends AbstractBaseJWTValidator implements JWTAuthorizationGrantValidationContext { - public static JWTAuthorizationGrantValidator createValidator(KeycloakSession session, ClientModel client, String assertion) { + private final String scope; + private Set restrictedScopes; + + public static JWTAuthorizationGrantValidator createValidator(KeycloakSession session, ClientModel client, String assertion, String scope) { if (assertion == null) { throw new RuntimeException("Missing parameter:" + OAuth2Constants.ASSERTION); } @@ -47,14 +52,15 @@ public class JWTAuthorizationGrantValidator extends AbstractBaseJWTValidator imp JWSInput jws = new JWSInput(assertion); JsonWebToken jwt = jws.readJsonContent(JsonWebToken.class); ClientAssertionState clientAssertionState = new ClientAssertionState(client, OAuth2Constants.JWT_AUTHORIZATION_GRANT, assertion, jws, jwt); - return new JWTAuthorizationGrantValidator(session, clientAssertionState); + return new JWTAuthorizationGrantValidator(session, scope, clientAssertionState); } catch (JWSInputException e) { throw new RuntimeException("The provided assertion is not a valid JWT"); } } - private JWTAuthorizationGrantValidator(KeycloakSession session, ClientAssertionState clientAssertionState) { + private JWTAuthorizationGrantValidator(KeycloakSession session, String scope, ClientAssertionState clientAssertionState) { super(session, clientAssertionState); + this.scope = scope; } public void validateClient() { @@ -90,6 +96,19 @@ public class JWTAuthorizationGrantValidator extends AbstractBaseJWTValidator imp return clientAssertionState.getClientAssertion(); } + @Override + public String getScopeParam() { + return scope; + } + + public Set getRestrictedScopes() { + return restrictedScopes; + } + + public void setRestrictedScopes(Set restrictedScopes) { + this.restrictedScopes = restrictedScopes; + } + @Override protected void failureCallback(String errorDescription) { throw new RuntimeException(errorDescription); diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAccessTypeCondition.java b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAccessTypeCondition.java index 830864cf126..c7812c0e260 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAccessTypeCondition.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAccessTypeCondition.java @@ -83,6 +83,8 @@ public class ClientAccessTypeCondition extends AbstractClientPolicyConditionProv case UPDATE: case UPDATED: case REGISTERED: + case TOKEN_EXCHANGE_REQUEST: + case JWT_AUTHORIZATION_GRANT: if (isClientAccessTypeMatched()) return ClientPolicyVote.YES; return ClientPolicyVote.NO; case REGISTER: diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAttributesCondition.java b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAttributesCondition.java index ffae65fd0c6..2c2e5bab1d7 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAttributesCondition.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientAttributesCondition.java @@ -91,6 +91,8 @@ public class ClientAttributesCondition extends AbstractClientPolicyConditionProv case REGISTERED: case UPDATE: case UPDATED: + case TOKEN_EXCHANGE_REQUEST: + case JWT_AUTHORIZATION_GRANT: case SAML_AUTHN_REQUEST: case SAML_LOGOUT_REQUEST: if (isAttributesMatched(session.getContext().getClient())) return ClientPolicyVote.YES; diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientProtocolCondition.java b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientProtocolCondition.java index e88d3558564..7d3052e59c5 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientProtocolCondition.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientProtocolCondition.java @@ -83,6 +83,8 @@ public class ClientProtocolCondition extends AbstractClientPolicyConditionProvid case UPDATE: case UPDATED: case REGISTERED: + case TOKEN_REVOKE_RESPONSE: + case JWT_AUTHORIZATION_GRANT: case SAML_AUTHN_REQUEST: case SAML_LOGOUT_REQUEST: if (isCorrectProtocolFromContext()) { diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientRolesCondition.java b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientRolesCondition.java index 3e2ebc2f168..2ecbf104cbe 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientRolesCondition.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientRolesCondition.java @@ -93,6 +93,8 @@ public class ClientRolesCondition extends AbstractClientPolicyConditionProvider< case REGISTERED: case UPDATE: case UPDATED: + case TOKEN_EXCHANGE_REQUEST: + case JWT_AUTHORIZATION_GRANT: case SAML_AUTHN_REQUEST: case SAML_LOGOUT_REQUEST: if (isRolesMatched(session.getContext().getClient())) return ClientPolicyVote.YES; diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientScopesCondition.java b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientScopesCondition.java index 0c9a7681caa..c5088248d37 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientScopesCondition.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/condition/ClientScopesCondition.java @@ -27,6 +27,7 @@ import org.keycloak.OAuth2Constants; import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.JWTAuthorizationGrantValidationContext; import org.keycloak.protocol.oidc.TokenExchangeContext; import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest; import org.keycloak.protocol.oidc.grants.ciba.channel.CIBAAuthenticationRequest; @@ -38,6 +39,7 @@ import org.keycloak.services.clientpolicy.ClientPolicyContext; import org.keycloak.services.clientpolicy.ClientPolicyException; import org.keycloak.services.clientpolicy.ClientPolicyVote; import org.keycloak.services.clientpolicy.context.AuthorizationRequestContext; +import org.keycloak.services.clientpolicy.context.JWTAuthorizationGrantContext; import org.keycloak.services.clientpolicy.context.ServiceAccountTokenRequestContext; import org.keycloak.services.clientpolicy.context.ServiceAccountTokenResponseContext; import org.keycloak.services.clientpolicy.context.TokenExchangeRequestContext; @@ -119,6 +121,9 @@ public class ClientScopesCondition extends AbstractClientPolicyConditionProvider case TOKEN_EXCHANGE_REQUEST: if (isScopeMatched(((TokenExchangeRequestContext) context).getTokenExchangeContext())) return ClientPolicyVote.YES; return ClientPolicyVote.NO; + case JWT_AUTHORIZATION_GRANT: + if (isScopeMatched(((JWTAuthorizationGrantContext) context).getAuthorizationGrantContext())) return ClientPolicyVote.YES; + return ClientPolicyVote.NO; default: return ClientPolicyVote.ABSTAIN; } @@ -144,6 +149,11 @@ public class ClientScopesCondition extends AbstractClientPolicyConditionProvider return isScopeMatched(context.getParams().getScope(), context.getClient()); } + private boolean isScopeMatched(JWTAuthorizationGrantValidationContext context) { + if (context == null) return false; + return isScopeMatched(context.getScopeParam(), session.getContext().getClient()); + } + private boolean isScopeMatched(String explicitScopes, ClientModel client) { if (explicitScopes == null) explicitScopes = ""; Collection explicitSpecifiedScopes = new HashSet<>(Arrays.asList(explicitScopes.split(" "))); diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/condition/GrantTypeCondition.java b/services/src/main/java/org/keycloak/services/clientpolicy/condition/GrantTypeCondition.java index 02f6de4234a..c77e1e741c5 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/condition/GrantTypeCondition.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/condition/GrantTypeCondition.java @@ -88,6 +88,9 @@ public class GrantTypeCondition extends AbstractClientPolicyConditionProvider { + JWTAuthorizationGrantContext jwtAuthnGrantContext = ((JWTAuthorizationGrantContext) context); + JWTAuthorizationGrantValidationContext jwtContext = jwtAuthnGrantContext.getAuthorizationGrantContext(); + Set restrictedScopes = checkDownscope(session.getContext().getClient(), + getAccessTokenFromAssertion(jwtContext.getAssertion()), + jwtContext.getScopeParam()); + jwtContext.setRestrictedScopes(restrictedScopes); + } + } + } + + private AccessToken getAccessTokenFromAssertion(String assertion) throws ClientPolicyException { + try { + return new JWSInput(assertion).readJsonContent(AccessToken.class); + } catch (JWSInputException e) { + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Assertion contains an invalid access token"); } } @@ -69,12 +87,7 @@ public class DownscopeAssertionGrantEnforcerExecutor implements ClientPolicyExec if (!OAuth2Constants.ACCESS_TOKEN_TYPE.equals(context.getParams().getSubjectTokenType())) { throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Parameter 'subject_token' should be access_token for the executor"); } - try { - return new JWSInput(context.getParams().getSubjectToken()) - .readJsonContent(AccessToken.class); - } catch (JWSInputException e) { - throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Parameter 'subject_token' contains an invalid access token"); - } + return getAccessTokenFromAssertion(context.getParams().getSubjectToken()); } private Set checkDownscope(ClientModel client, AccessToken token, String scopeParam) throws ClientPolicyException { diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/realm/ClientPolicyBuilder.java b/test-framework/core/src/main/java/org/keycloak/testframework/realm/ClientPolicyBuilder.java new file mode 100644 index 00000000000..d71384512bd --- /dev/null +++ b/test-framework/core/src/main/java/org/keycloak/testframework/realm/ClientPolicyBuilder.java @@ -0,0 +1,93 @@ +package org.keycloak.testframework.realm; + +import java.io.IOException; +import java.util.LinkedList; +import java.util.List; + +import org.keycloak.representations.idm.ClientPolicyConditionConfigurationRepresentation; +import org.keycloak.representations.idm.ClientPolicyConditionRepresentation; +import org.keycloak.representations.idm.ClientPolicyRepresentation; +import org.keycloak.services.clientpolicy.condition.GrantTypeCondition; +import org.keycloak.util.JsonSerialization; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * + * @author rmartinc + */ +public class ClientPolicyBuilder { + + private final ClientPolicyRepresentation rep; + + private ClientPolicyBuilder(ClientPolicyRepresentation rep) { + this.rep = rep; + } + + public static ClientPolicyBuilder create() { + ClientPolicyRepresentation rep = new ClientPolicyRepresentation(); + rep.setEnabled(true); + return new ClientPolicyBuilder(rep); + } + + public static GrantTypeCondition.Configuration grantTypeConditionConfiguration(String... types) { + GrantTypeCondition.Configuration config = new GrantTypeCondition.Configuration(); + if (types != null && types.length > 0) { + config.setGrantTypes(List.of(types)); + } + return config; + } + + public static ClientPolicyBuilder update(ClientPolicyRepresentation rep) { + return new ClientPolicyBuilder(rep); + } + + public ClientPolicyBuilder enabled(boolean enabled) { + rep.setEnabled(enabled); + return this; + } + + public ClientPolicyBuilder name(String name) { + rep.setName(name); + return this; + } + + public ClientPolicyBuilder description(String description) { + rep.setDescription(description); + return this; + } + + public ClientPolicyBuilder condition(String providerId, ClientPolicyConditionConfigurationRepresentation config) { + ClientPolicyConditionRepresentation condition = new ClientPolicyConditionRepresentation(); + condition.setConditionProviderId(providerId); + if (config == null) { + config = new ClientPolicyConditionConfigurationRepresentation(); + } + try { + condition.setConfiguration(JsonSerialization.mapper.readValue(JsonSerialization.mapper.writeValueAsBytes(config), JsonNode.class)); + } catch(IOException e) { + throw new IllegalArgumentException("Invalid configuration", e); + } + List conditions = rep.getConditions(); + if (conditions == null) { + conditions = new LinkedList<>(); + rep.setConditions(conditions); + } + conditions.add(condition); + return this; + } + + public ClientPolicyBuilder profile(String... profile) { + List profiles = rep.getProfiles(); + if (profiles == null) { + profiles = new LinkedList<>(); + rep.setProfiles(profiles); + } + profiles.addAll(List.of(profile)); + return this; + } + + public ClientPolicyRepresentation build() { + return rep; + } +} diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/realm/ClientProfileBuilder.java b/test-framework/core/src/main/java/org/keycloak/testframework/realm/ClientProfileBuilder.java new file mode 100644 index 00000000000..d5b7f8bac98 --- /dev/null +++ b/test-framework/core/src/main/java/org/keycloak/testframework/realm/ClientProfileBuilder.java @@ -0,0 +1,68 @@ +package org.keycloak.testframework.realm; + +import java.io.IOException; +import java.util.LinkedList; +import java.util.List; + +import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation; +import org.keycloak.representations.idm.ClientPolicyExecutorRepresentation; +import org.keycloak.representations.idm.ClientProfileRepresentation; +import org.keycloak.util.JsonSerialization; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * + * @author rmartinc + */ +public class ClientProfileBuilder { + + private final ClientProfileRepresentation rep; + + private ClientProfileBuilder(ClientProfileRepresentation rep) { + this.rep = rep; + } + + public static ClientProfileBuilder create() { + ClientProfileRepresentation rep = new ClientProfileRepresentation(); + return new ClientProfileBuilder(rep); + } + + public static ClientProfileBuilder update(ClientProfileRepresentation rep) { + return new ClientProfileBuilder(rep); + } + + public ClientProfileBuilder name(String name) { + rep.setName(name); + return this; + } + + public ClientProfileBuilder description(String description) { + rep.setDescription(description); + return this; + } + + public ClientProfileBuilder executor(String providerId, ClientPolicyExecutorConfigurationRepresentation config) { + ClientPolicyExecutorRepresentation executor = new ClientPolicyExecutorRepresentation(); + executor.setExecutorProviderId(providerId); + if (config == null) { + config = new ClientPolicyExecutorConfigurationRepresentation(); + } + try { + executor.setConfiguration(JsonSerialization.mapper.readValue(JsonSerialization.mapper.writeValueAsBytes(config), JsonNode.class)); + } catch(IOException e) { + throw new IllegalArgumentException("Invalid configuration", e); + } + List executors = rep.getExecutors(); + if (executors == null) { + executors = new LinkedList<>(); + rep.setExecutors(executors); + } + executors.add(executor); + return this; + } + + public ClientProfileRepresentation build() { + return rep; + } +} diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java b/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java index 2a807bbacb6..b115efa69f3 100644 --- a/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java +++ b/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java @@ -5,6 +5,10 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import org.keycloak.representations.idm.ClientPoliciesRepresentation; +import org.keycloak.representations.idm.ClientPolicyRepresentation; +import org.keycloak.representations.idm.ClientProfileRepresentation; +import org.keycloak.representations.idm.ClientProfilesRepresentation; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.IdentityProviderRepresentation; @@ -236,6 +240,28 @@ public class RealmConfigBuilder { return this; } + public RealmConfigBuilder clientPolicy(ClientPolicyRepresentation clienPolicyRep) { + ClientPoliciesRepresentation clientPolicies = rep.getParsedClientPolicies(); + if (clientPolicies == null) { + clientPolicies = new ClientPoliciesRepresentation(); + } + List policies = clientPolicies.getPolicies(); + policies.add(clienPolicyRep); + rep.setParsedClientPolicies(clientPolicies); + return this; + } + + public RealmConfigBuilder clientProfile(ClientProfileRepresentation clienProfileRep) { + ClientProfilesRepresentation clientProfiles = rep.getParsedClientProfiles(); + if (clientProfiles == null) { + clientProfiles = new ClientProfilesRepresentation(); + } + List profiles = clientProfiles.getProfiles(); + profiles.add(clienProfileRep); + rep.setParsedClientProfiles(clientProfiles); + return this; + } + /** * Best practice is to use other convenience methods when configuring a realm, but while the framework is under * active development there may not be a way to perform all updates required. In these cases this method allows diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/AbstractJWTAuthorizationGrantTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/AbstractJWTAuthorizationGrantTest.java index 5a8a8211e44..a6ec88c56cf 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oauth/AbstractJWTAuthorizationGrantTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/AbstractJWTAuthorizationGrantTest.java @@ -1,69 +1,22 @@ package org.keycloak.tests.oauth; import java.util.List; -import java.util.UUID; import org.keycloak.OAuth2Constants; import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; -import org.keycloak.common.Profile; import org.keycloak.common.util.Time; import org.keycloak.crypto.Algorithm; -import org.keycloak.events.EventType; -import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.representations.AccessToken; import org.keycloak.representations.IDToken; import org.keycloak.representations.JsonWebToken; -import org.keycloak.representations.idm.EventRepresentation; -import org.keycloak.testframework.annotations.InjectEvents; -import org.keycloak.testframework.annotations.InjectRealm; -import org.keycloak.testframework.annotations.InjectUser; -import org.keycloak.testframework.events.EventAssertion; -import org.keycloak.testframework.events.Events; -import org.keycloak.testframework.oauth.OAuthClient; import org.keycloak.testframework.oauth.OAuthIdentityProvider; -import org.keycloak.testframework.oauth.OAuthIdentityProviderConfig; -import org.keycloak.testframework.oauth.OAuthIdentityProviderConfigBuilder; -import org.keycloak.testframework.oauth.annotations.InjectOAuthClient; -import org.keycloak.testframework.oauth.annotations.InjectOAuthIdentityProvider; -import org.keycloak.testframework.realm.ManagedRealm; -import org.keycloak.testframework.realm.ManagedUser; -import org.keycloak.testframework.realm.RealmConfig; -import org.keycloak.testframework.realm.RealmConfigBuilder; -import org.keycloak.testframework.realm.UserConfig; -import org.keycloak.testframework.realm.UserConfigBuilder; -import org.keycloak.testframework.remote.timeoffset.InjectTimeOffSet; -import org.keycloak.testframework.remote.timeoffset.TimeOffSet; -import org.keycloak.testframework.server.KeycloakServerConfigBuilder; -import org.keycloak.tests.client.authentication.external.ClientAuthIdpServerConfig; import org.keycloak.testsuite.util.oauth.AccessTokenResponse; import org.hamcrest.MatcherAssert; import org.hamcrest.Matchers; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -public abstract class AbstractJWTAuthorizationGrantTest { - - public static String IDP_ALIAS = "authorization-grant-idp-alias"; - public static final String IDP_ISSUER = "https://authorization-grant-issuer"; - - @InjectOAuthIdentityProvider(config = AbstractJWTAuthorizationGrantTest.AGIdpConfig.class) - OAuthIdentityProvider identityProvider; - - @InjectRealm(config = JWTAuthorizationGrantRealmConfig.class) - protected ManagedRealm realm; - - @InjectUser(config = FederatedUserConfiguration.class) - ManagedUser user; - - @InjectOAuthClient - OAuthClient oAuthClient; - - @InjectEvents - Events events; - - @InjectTimeOffSet - TimeOffSet timeOffSet; +public abstract class AbstractJWTAuthorizationGrantTest extends BaseAbstractJWTAuthorizationGrantTest { @Test public void testPublicClient() { @@ -115,7 +68,7 @@ public abstract class AbstractJWTAuthorizationGrantTest { assertFailure("Token is not active", response, events.poll()); //test max exp default settings - jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 301L)); + jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 305L)); response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); assertFailure("Token expiration is too far in the future and iat claim not present in token", response, events.poll()); @@ -278,102 +231,4 @@ public abstract class AbstractJWTAuthorizationGrantTest { AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); assertSuccess("test-app", "basic-user", response); } - - protected JsonWebToken createDefaultAuthorizationGrantToken() { - return createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 300L); - } - - protected JsonWebToken createAuthorizationGrantToken(String subject, String audience, String issuer) { - return createAuthorizationGrantToken(subject, audience, issuer, Time.currentTime() + 300L, (long) Time.currentTime()); - } - - protected JsonWebToken createAuthorizationGrantToken(String subject, String audience, String issuer, Long exp) { - return createAuthorizationGrantToken(subject, audience, issuer, exp, null); - } - - protected JsonWebToken createAuthorizationGrantToken(String subject, String audience, String issuer, Long exp, Long iat) { - JsonWebToken token = new JsonWebToken(); - token.id(UUID.randomUUID().toString()); - token.subject(subject); - token.audience(audience); - token.issuer(issuer); - token.exp(exp); - token.iat(iat); - return token; - } - - public OAuthIdentityProvider getIdentityProvider() { - return identityProvider; - } - - public static class AGIdpConfig implements OAuthIdentityProviderConfig { - - @Override - public OAuthIdentityProviderConfigBuilder configure(OAuthIdentityProviderConfigBuilder config) { - return config; - } - } - - public static class JWTAuthorizationGrantServerConfig extends ClientAuthIdpServerConfig { - - @Override - public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) { - return super.configure(config).features(Profile.Feature.JWT_AUTHORIZATION_GRANT); - } - } - - public static class JWTAuthorizationGrantRealmConfig implements RealmConfig { - - @Override - public RealmConfigBuilder configure(RealmConfigBuilder realm) { - realm.addClient("test-public").publicClient(true); - realm.addClient("authorization-grant-disabled-client").publicClient(false).secret("test-secret"); - realm.addClient("authorization-grant-not-allowed-idp-client").publicClient(false).attribute(OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_ENABLED, "true").secret("test-secret"); - return realm; - } - } - - public static class FederatedUserConfiguration implements UserConfig { - - @Override - public UserConfigBuilder configure(UserConfigBuilder user) { - return user - .username("basic-user") - .password("password") - .email("basic@localhost") - .name("First", "Last") - .federatedLink(IDP_ALIAS, "basic-user-id", "basic-user"); - } - } - - protected AccessToken assertSuccess(String expectedClientId, String username, AccessTokenResponse response) { - Assertions.assertTrue(response.isSuccess()); - Assertions.assertNull(response.getRefreshToken()); - AccessToken accessToken = oAuthClient.parseToken(response.getAccessToken(), AccessToken.class); - Assertions.assertNull(accessToken.getSessionId()); - MatcherAssert.assertThat(accessToken.getId(), Matchers.startsWith("trrtag:")); - Assertions.assertEquals(expectedClientId, accessToken.getIssuedFor()); - Assertions.assertEquals(username, accessToken.getPreferredUsername()); - EventAssertion.assertSuccess(events.poll()) - .type(EventType.LOGIN) - .clientId(expectedClientId) - .details("grant_type", OAuth2Constants.JWT_AUTHORIZATION_GRANT) - .details("username", username); - return accessToken; - } - - protected void assertFailure(String expectedErrorDescription, AccessTokenResponse response, EventRepresentation event) { - assertFailure("invalid_grant", expectedErrorDescription, response, event); - } - - protected void assertFailure(String expectedError, String expectedErrorDescription, AccessTokenResponse response, EventRepresentation event) { - Assertions.assertFalse(response.isSuccess()); - Assertions.assertEquals(expectedError, response.getError()); - Assertions.assertEquals(expectedErrorDescription, response.getErrorDescription()); - EventAssertion.assertError(event) - .type(EventType.LOGIN_ERROR) - .error("invalid_request") - .details("grant_type", OAuth2Constants.JWT_AUTHORIZATION_GRANT) - .details("reason", expectedErrorDescription); - } } diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/BaseAbstractJWTAuthorizationGrantTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/BaseAbstractJWTAuthorizationGrantTest.java new file mode 100644 index 00000000000..e21d2562307 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/BaseAbstractJWTAuthorizationGrantTest.java @@ -0,0 +1,207 @@ +/* + * Copyright 2025 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.keycloak.tests.oauth; + +import java.util.UUID; + +import org.keycloak.OAuth2Constants; +import org.keycloak.common.Profile; +import org.keycloak.common.util.Time; +import org.keycloak.events.Details; +import org.keycloak.events.EventType; +import org.keycloak.protocol.oidc.OIDCConfigAttributes; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.JsonWebToken; +import org.keycloak.representations.idm.EventRepresentation; +import org.keycloak.testframework.annotations.InjectEvents; +import org.keycloak.testframework.annotations.InjectRealm; +import org.keycloak.testframework.annotations.InjectUser; +import org.keycloak.testframework.events.EventAssertion; +import org.keycloak.testframework.events.Events; +import org.keycloak.testframework.oauth.OAuthClient; +import org.keycloak.testframework.oauth.OAuthIdentityProvider; +import org.keycloak.testframework.oauth.OAuthIdentityProviderConfig; +import org.keycloak.testframework.oauth.OAuthIdentityProviderConfigBuilder; +import org.keycloak.testframework.oauth.annotations.InjectOAuthClient; +import org.keycloak.testframework.oauth.annotations.InjectOAuthIdentityProvider; +import org.keycloak.testframework.realm.ManagedRealm; +import org.keycloak.testframework.realm.ManagedUser; +import org.keycloak.testframework.realm.RealmConfig; +import org.keycloak.testframework.realm.RealmConfigBuilder; +import org.keycloak.testframework.realm.UserConfig; +import org.keycloak.testframework.realm.UserConfigBuilder; +import org.keycloak.testframework.remote.timeoffset.InjectTimeOffSet; +import org.keycloak.testframework.remote.timeoffset.TimeOffSet; +import org.keycloak.testframework.server.KeycloakServerConfigBuilder; +import org.keycloak.tests.client.authentication.external.ClientAuthIdpServerConfig; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; + +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; + +import static org.keycloak.tests.oauth.AbstractJWTAuthorizationGrantTest.IDP_ISSUER; + +/** + * + * @author rmartinc + */ +public class BaseAbstractJWTAuthorizationGrantTest { + + public static String IDP_ALIAS = "authorization-grant-idp-alias"; + public static final String IDP_ISSUER = "https://authorization-grant-issuer"; + + @InjectOAuthIdentityProvider(config = AbstractJWTAuthorizationGrantTest.AGIdpConfig.class) + OAuthIdentityProvider identityProvider; + + @InjectRealm(config = AbstractJWTAuthorizationGrantTest.JWTAuthorizationGrantRealmConfig.class) + protected ManagedRealm realm; + + @InjectUser(config = AbstractJWTAuthorizationGrantTest.FederatedUserConfiguration.class) + ManagedUser user; + + @InjectOAuthClient + OAuthClient oAuthClient; + + @InjectEvents + Events events; + + @InjectTimeOffSet + TimeOffSet timeOffSet; + + protected JsonWebToken createDefaultAuthorizationGrantToken() { + return createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 300L, null, null); + } + + protected JsonWebToken createAuthorizationGrantToken(String subject, String audience, String issuer) { + return createAuthorizationGrantToken(subject, audience, issuer, Time.currentTime() + 300L, (long) Time.currentTime(), null); + } + + protected JsonWebToken createAuthorizationGrantToken(String subject, String audience, String issuer, Long exp) { + return createAuthorizationGrantToken(subject, audience, issuer, exp, null, null); + } + + protected AccessToken createDefaultAuthorizationGrantToken(String scope) { + return createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 300L, null, scope); + } + + protected AccessToken createAuthorizationGrantToken(String subject, String audience, String issuer, Long exp, Long iat) { + return createAuthorizationGrantToken(subject, audience, issuer, exp, iat, null); + } + + protected AccessToken createAuthorizationGrantToken(String subject, String audience, String issuer, Long exp, Long iat, String scope) { + AccessToken token = new AccessToken(); + token.id(UUID.randomUUID().toString()); + token.subject(subject); + token.audience(audience); + token.issuer(issuer); + token.exp(exp); + token.iat(iat); + token.setScope(scope); + return token; + } + + public OAuthIdentityProvider getIdentityProvider() { + return identityProvider; + } + + public static class AGIdpConfig implements OAuthIdentityProviderConfig { + + @Override + public OAuthIdentityProviderConfigBuilder configure(OAuthIdentityProviderConfigBuilder config) { + return config; + } + } + + public static class JWTAuthorizationGrantServerConfig extends ClientAuthIdpServerConfig { + + @Override + public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) { + return super.configure(config).features(Profile.Feature.JWT_AUTHORIZATION_GRANT); + } + } + + public static class JWTAuthorizationGrantRealmConfig implements RealmConfig { + + @Override + public RealmConfigBuilder configure(RealmConfigBuilder realm) { + realm.addClient("test-public").publicClient(true); + realm.addClient("authorization-grant-disabled-client").publicClient(false).secret("test-secret"); + realm.addClient("authorization-grant-not-allowed-idp-client").publicClient(false).attribute(OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_ENABLED, "true").secret("test-secret"); + return realm; + } + } + + public static class FederatedUserConfiguration implements UserConfig { + + @Override + public UserConfigBuilder configure(UserConfigBuilder user) { + return user + .username("basic-user") + .password("password") + .email("basic@localhost") + .name("First", "Last") + .federatedLink(IDP_ALIAS, "basic-user-id", "basic-user"); + } + } + + protected AccessToken assertSuccess(String expectedClientId, String username, AccessTokenResponse response) { + Assertions.assertTrue(response.isSuccess()); + Assertions.assertNull(response.getRefreshToken()); + AccessToken accessToken = oAuthClient.parseToken(response.getAccessToken(), AccessToken.class); + Assertions.assertNull(accessToken.getSessionId()); + MatcherAssert.assertThat(accessToken.getId(), Matchers.startsWith("trrtag:")); + Assertions.assertEquals(expectedClientId, accessToken.getIssuedFor()); + Assertions.assertEquals(username, accessToken.getPreferredUsername()); + EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .clientId(expectedClientId) + .details("grant_type", OAuth2Constants.JWT_AUTHORIZATION_GRANT) + .details("username", username); + return accessToken; + } + + protected void assertFailure(String expectedErrorDescription, AccessTokenResponse response, EventRepresentation event) { + assertFailure("invalid_grant", expectedErrorDescription, response, event); + } + + protected void assertFailure(String expectedError, String expectedErrorDescription, AccessTokenResponse response, EventRepresentation event) { + Assertions.assertFalse(response.isSuccess()); + Assertions.assertEquals(expectedError, response.getError()); + Assertions.assertEquals(expectedErrorDescription, response.getErrorDescription()); + EventAssertion.assertError(event) + .type(EventType.LOGIN_ERROR) + .error("invalid_request") + .details("grant_type", OAuth2Constants.JWT_AUTHORIZATION_GRANT) + .details("reason", expectedErrorDescription); + } + + protected void assertFailurePolicy(String expectedError, String expectedErrorDescription, AccessTokenResponse response, EventRepresentation event) { + Assertions.assertFalse(response.isSuccess()); + Assertions.assertEquals(expectedError, response.getError()); + Assertions.assertEquals(expectedErrorDescription, response.getErrorDescription()); + EventAssertion.assertError(event) + .type(EventType.LOGIN_ERROR) + .error(expectedError) + .details("grant_type", OAuth2Constants.JWT_AUTHORIZATION_GRANT) + .details("reason", Details.CLIENT_POLICY_ERROR) + .details(Details.CLIENT_POLICY_ERROR, expectedError) + .details(Details.CLIENT_POLICY_ERROR_DETAIL, expectedErrorDescription); + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantDownscopeClientPoliciesTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantDownscopeClientPoliciesTest.java new file mode 100644 index 00000000000..c0820d5885a --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantDownscopeClientPoliciesTest.java @@ -0,0 +1,85 @@ +package org.keycloak.tests.oauth; + +import java.util.List; + +import org.keycloak.OAuth2Constants; +import org.keycloak.representations.AccessToken; +import org.keycloak.services.clientpolicy.condition.GrantTypeConditionFactory; +import org.keycloak.services.clientpolicy.executor.DownscopeAssertionGrantEnforcerExecutorFactory; +import org.keycloak.testframework.annotations.InjectRealm; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.realm.ClientPolicyBuilder; +import org.keycloak.testframework.realm.ClientProfileBuilder; +import org.keycloak.testframework.realm.ManagedRealm; +import org.keycloak.testframework.realm.RealmConfigBuilder; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; + +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; + +/** + * + * @author rmartinc + */ +@KeycloakIntegrationTest(config = JWTAuthorizationGrantTest.JWTAuthorizationGrantServerConfig.class) +public class JWTAuthorizationGrantDownscopeClientPoliciesTest extends BaseAbstractJWTAuthorizationGrantTest { + + @InjectRealm(config = JWTAuthorizationGranthRealmConfig.class) + protected ManagedRealm realm; + + @Test + public void testDownscope() throws Exception { + // test with all the scopes + String jwt = identityProvider.encodeToken(createDefaultAuthorizationGrantToken("email profile address")); + AccessTokenResponse response = oAuthClient.openid(false).scope("address").jwtAuthorizationGrantRequest(jwt).send(); + AccessToken token = assertSuccess("test-app", "basic-user", response); + MatcherAssert.assertThat(List.of(token.getScope().split(" ")), Matchers.containsInAnyOrder("email", "profile", "address")); + + // test with less scopes => downscope + jwt = identityProvider.encodeToken(createDefaultAuthorizationGrantToken("email profile address")); + response = oAuthClient.openid(false).scope(null).jwtAuthorizationGrantRequest(jwt).send(); + token = assertSuccess("test-app", "basic-user", response); + MatcherAssert.assertThat(List.of(token.getScope().split(" ")), Matchers.containsInAnyOrder("email", "profile")); + + // test default scopes are restricted if not present in initial token + jwt = identityProvider.encodeToken(createDefaultAuthorizationGrantToken("profile address")); + response = oAuthClient.openid(false).scope("address").jwtAuthorizationGrantRequest(jwt).send(); + token = assertSuccess("test-app", "basic-user", response); + MatcherAssert.assertThat(List.of(token.getScope().split(" ")), Matchers.containsInAnyOrder("profile", "address")); + + // test requesting a valid optional scope for the client but not present initially + jwt = identityProvider.encodeToken(createDefaultAuthorizationGrantToken("email profile")); + response = oAuthClient.openid(false).scope("address").jwtAuthorizationGrantRequest(jwt).send(); + assertFailurePolicy("invalid_scope", "Scopes [address] not present in the initial access token [profile, email]", response, events.poll()); + + // test requesting a default scope not present in the initial token + jwt = identityProvider.encodeToken(createDefaultAuthorizationGrantToken("email address")); + response = oAuthClient.openid(false).scope("email profile address").jwtAuthorizationGrantRequest(jwt).send(); + assertFailurePolicy("invalid_scope", "Scopes [profile] not present in the initial access token [address, email]", response, events.poll()); + } + + public static class JWTAuthorizationGranthRealmConfig extends OIDCIdentityProviderJWTAuthorizationGrantTest.JWTAuthorizationGrantRealmConfig { + + @Override + public RealmConfigBuilder configure(RealmConfigBuilder realm) { + super.configure(realm); + + realm.clientProfile(ClientProfileBuilder.create() + .name("executor") + .description("executor description") + .executor(DownscopeAssertionGrantEnforcerExecutorFactory.PROVIDER_ID, null) + .build()); + + realm.clientPolicy(ClientPolicyBuilder.create() + .name("policy") + .description("description of policy") + .condition(GrantTypeConditionFactory.PROVIDER_ID, ClientPolicyBuilder.grantTypeConditionConfiguration( + OAuth2Constants.JWT_AUTHORIZATION_GRANT)) + .profile("executor") + .build()); + + return realm; + } + } +}