Initial integration of the JWT Authorization Grant in client Policies

Using the downscope executor for testing
Closes #44201

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
rmartinc 2025-11-19 11:01:16 +01:00 committed by Marek Posolda
parent fc67e54fde
commit ca205272ba
19 changed files with 620 additions and 158 deletions

View File

@ -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<String> getRestrictedScopes();
void setRestrictedScopes(Set<String> restrictedScopes);
default String getIssuer() {
return getJWT().getIssuer();
}

View File

@ -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,

View File

@ -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;

View File

@ -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<String> 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<String> getRestrictedScopes() {
return restrictedScopes;
}
public void setRestrictedScopes(Set<String> restrictedScopes) {
this.restrictedScopes = restrictedScopes;
}
@Override
protected void failureCallback(String errorDescription) {
throw new RuntimeException(errorDescription);

View File

@ -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:

View File

@ -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;

View File

@ -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()) {

View File

@ -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;

View File

@ -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<String> explicitSpecifiedScopes = new HashSet<>(Arrays.asList(explicitScopes.split(" ")));

View File

@ -88,6 +88,9 @@ public class GrantTypeCondition extends AbstractClientPolicyConditionProvider<Gr
case DEVICE_TOKEN_REQUEST:
if (isGrantMatching(OAuth2Constants.DEVICE_CODE_GRANT_TYPE)) return ClientPolicyVote.YES;
return ClientPolicyVote.NO;
case JWT_AUTHORIZATION_GRANT:
if (isGrantMatching(OAuth2Constants.JWT_AUTHORIZATION_GRANT)) return ClientPolicyVote.YES;
return ClientPolicyVote.NO;
default:
return ClientPolicyVote.ABSTAIN;
}

View File

@ -66,6 +66,9 @@ public class GrantTypeConditionFactory extends AbstractClientPolicyConditionProv
if (Profile.isFeatureEnabled(Profile.Feature.DEVICE_FLOW)) {
DEFAULT_GRANT_TYPES_SUPPORTED.add(OAuth2Constants.DEVICE_CODE_GRANT_TYPE);
}
if (Profile.isFeatureEnabled(Profile.Feature.JWT_AUTHORIZATION_GRANT)) {
DEFAULT_GRANT_TYPES_SUPPORTED.add(OAuth2Constants.JWT_AUTHORIZATION_GRANT);
}
property.setOptions(DEFAULT_GRANT_TYPES_SUPPORTED);
configProperties.add(property);

View File

@ -0,0 +1,50 @@
/*
* 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.services.clientpolicy.context;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.protocol.oidc.JWTAuthorizationGrantValidationContext;
import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyEvent;
/**
*
* @author rmartinc
*/
public class JWTAuthorizationGrantContext implements ClientPolicyContext {
private final JWTAuthorizationGrantValidationContext authorizationGrantContext;
private final IdentityProviderModel idp;
public JWTAuthorizationGrantContext(JWTAuthorizationGrantValidationContext authorizationGrantContext, IdentityProviderModel idp) {
this.authorizationGrantContext = authorizationGrantContext;
this.idp = idp;
}
@Override
public ClientPolicyEvent getEvent() {
return ClientPolicyEvent.JWT_AUTHORIZATION_GRANT;
}
public JWTAuthorizationGrantValidationContext getAuthorizationGrantContext() {
return authorizationGrantContext;
}
public IdentityProviderModel getIdentityProvider() {
return idp;
}
}

View File

@ -28,11 +28,13 @@ import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.JWTAuthorizationGrantValidationContext;
import org.keycloak.protocol.oidc.TokenExchangeContext;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.representations.AccessToken;
import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.services.clientpolicy.context.JWTAuthorizationGrantContext;
import org.keycloak.services.clientpolicy.context.TokenExchangeRequestContext;
/**
@ -62,6 +64,22 @@ public class DownscopeAssertionGrantEnforcerExecutor implements ClientPolicyExec
tokenExchangeContext.getParams().getScope());
tokenExchangeContext.setRestrictedScopes(restrictedScopes);
}
case JWT_AUTHORIZATION_GRANT -> {
JWTAuthorizationGrantContext jwtAuthnGrantContext = ((JWTAuthorizationGrantContext) context);
JWTAuthorizationGrantValidationContext jwtContext = jwtAuthnGrantContext.getAuthorizationGrantContext();
Set<String> 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<String> checkDownscope(ClientModel client, AccessToken token, String scopeParam) throws ClientPolicyException {

View File

@ -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<ClientPolicyConditionRepresentation> conditions = rep.getConditions();
if (conditions == null) {
conditions = new LinkedList<>();
rep.setConditions(conditions);
}
conditions.add(condition);
return this;
}
public ClientPolicyBuilder profile(String... profile) {
List<String> profiles = rep.getProfiles();
if (profiles == null) {
profiles = new LinkedList<>();
rep.setProfiles(profiles);
}
profiles.addAll(List.of(profile));
return this;
}
public ClientPolicyRepresentation build() {
return rep;
}
}

View File

@ -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<ClientPolicyExecutorRepresentation> executors = rep.getExecutors();
if (executors == null) {
executors = new LinkedList<>();
rep.setExecutors(executors);
}
executors.add(executor);
return this;
}
public ClientProfileRepresentation build() {
return rep;
}
}

View File

@ -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<ClientPolicyRepresentation> 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<ClientProfileRepresentation> 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

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}
}