From aa8d3939cbe7210d37e7088638399172085d7884 Mon Sep 17 00:00:00 2001 From: Giuseppe Graziano Date: Thu, 16 Oct 2025 17:39:52 +0200 Subject: [PATCH 1/2] Experimental feature for JWT Authorization Grant Closes #43444 Signed-off-by: Giuseppe Graziano --- .../java/org/keycloak/common/Profile.java | 2 + .../java/org/keycloak/OAuth2Constants.java | 3 + .../JWTAuthorizationGrantProvider.java | 25 ++ ...WTAuthorizationGrantValidationContext.java | 145 ++++++++++++ .../broker/oidc/OIDCIdentityProvider.java | 25 +- .../grants/JWTAuthorizationGrantType.java | 150 ++++++++++++ .../JWTAuthorizationGrantTypeFactory.java | 62 +++++ ...rotocol.oidc.grants.OAuth2GrantTypeFactory | 1 + .../oauth/JWTAuthorizationGrantTest.java | 214 ++++++++++++++++++ .../util/oauth/AbstractOAuthClient.java | 8 + .../oauth/JWTAuthorizationGrantRequest.java | 38 ++++ 11 files changed, 672 insertions(+), 1 deletion(-) create mode 100644 server-spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java create mode 100644 server-spi-private/src/main/java/org/keycloak/protocol/oidc/JWTAuthorizationGrantValidationContext.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantTypeFactory.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantTest.java create mode 100644 tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/JWTAuthorizationGrantRequest.java diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java index 48e56d0bc24f..608a0180511a 100755 --- a/common/src/main/java/org/keycloak/common/Profile.java +++ b/common/src/main/java/org/keycloak/common/Profile.java @@ -77,6 +77,8 @@ public enum Feature { TOKEN_EXCHANGE_STANDARD_V2("Standard Token Exchange version 2", Type.DEFAULT, 2), TOKEN_EXCHANGE_EXTERNAL_INTERNAL_V2("External to Internal Token Exchange version 2", Type.EXPERIMENTAL, 2), + JWT_AUTHORIZATION_GRANT("JWT Profile for Oauth 2.0 Authorization Grant", Type.EXPERIMENTAL), + WEB_AUTHN("W3C Web Authentication (WebAuthn)", Type.DEFAULT), CLIENT_POLICIES("Client configuration policies", Type.DEFAULT), diff --git a/core/src/main/java/org/keycloak/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java index b6eb64f56f8b..665823d20303 100755 --- a/core/src/main/java/org/keycloak/OAuth2Constants.java +++ b/core/src/main/java/org/keycloak/OAuth2Constants.java @@ -77,6 +77,9 @@ public interface OAuth2Constants { String CLIENT_CREDENTIALS = "client_credentials"; + String JWT_AUTHORIZATION_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer"; + String ASSERTION = "assertion"; + // https://tools.ietf.org/html/draft-ietf-oauth-assertions-01#page-5 String CLIENT_ASSERTION_TYPE = "client_assertion_type"; String CLIENT_ASSERTION = "client_assertion"; diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java new file mode 100644 index 000000000000..41039f85293b --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java @@ -0,0 +1,25 @@ +/* + * 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.broker.provider; +import org.keycloak.protocol.oidc.JWTAuthorizationGrantValidationContext; + +public interface JWTAuthorizationGrantProvider { + + boolean isIssuer(String issuer); + + BrokeredIdentityContext validateAuthorizationGrantAssertion(JWTAuthorizationGrantValidationContext assertion); +} 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 new file mode 100644 index 000000000000..9e4956b63acf --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/JWTAuthorizationGrantValidationContext.java @@ -0,0 +1,145 @@ +package org.keycloak.protocol.oidc; + +import org.keycloak.OAuth2Constants; +import org.keycloak.common.util.Time; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.models.ClientModel; +import org.keycloak.representations.IDToken; +import org.keycloak.representations.JsonWebToken; + +import java.util.Collections; +import java.util.List; + +public class JWTAuthorizationGrantValidationContext { + + private final String assertion; + + private final ClientModel client; + + private JsonWebToken jwt; + + private final String expectedAudience; + + private JWSInput jws; + + private final long currentTime; + + public JWTAuthorizationGrantValidationContext(String assertion, ClientModel client, String expectedAudience) { + this.assertion = assertion; + this.client = client; + this.expectedAudience = expectedAudience; + this.currentTime = Time.currentTimeMillis(); + } + + public void validateJWTFormat() { + try { + this.jws = new JWSInput(assertion); + this.jwt = jws.readJsonContent(JsonWebToken.class); + } + catch (Exception e) { + failure("The provided assertion is not a valid JWT"); + } + } + + public void validateAssertionParameters() { + if (assertion == null) { + failure("Missing parameter:" + OAuth2Constants.ASSERTION); + } + } + + public void validateClient() { + if (client.isPublicClient()) { + failure("Public client not allowed to use authorization grant"); + } + } + + public void validateTokenActive() { + JsonWebToken token = getJWT(); + int allowedClockSkew = getAllowedClockSkew(); + int maxExp = getMaximumExpirationTime(); + long lifespan; + + if (token.getExp() == null) { + failure("Token exp claim is required"); + } + + if (!token.isActive(allowedClockSkew)) { + failure("Token is not active"); + } + + lifespan = token.getExp() - currentTime; + + if (token.getIat() == null) { + if (lifespan > maxExp) { + failure("Token expiration is too far in the future and iat claim not present in token"); + } + } else { + if (token.getIat() - allowedClockSkew > currentTime) { + failure("Token was issued in the future"); + } + lifespan = Math.min(lifespan, maxExp); + if (lifespan <= 0) { + failure("Token is not active"); + } + if (currentTime > token.getIat() + maxExp) { + failure("Token was issued too far in the past to be used now"); + } + } + } + + public void validateAudience() { + JsonWebToken token = getJWT(); + List expectedAudiences = getExpectedAudiences(); + if (!token.hasAnyAudience(expectedAudiences)) { + failure("Invalid token audience"); + } + } + + public void validateIssuer() { + if (jwt == null || jwt.getIssuer() == null) { + failure("Missing claim: " + OAuth2Constants.ISSUER); + } + } + + public void validateSubject() { + if (jwt == null || jwt.getSubject() == null) { + failure("Missing claim: " + IDToken.SUBJECT); + } + } + + public void failure(String errorMessage) { + throw new RuntimeException(errorMessage); + } + + public JsonWebToken getJWT() { + return jwt; + } + + public JWSInput getJws() { + return jws; + } + + public String getIssuer() { + return jwt.getIssuer(); + } + + public String getSubject() { + return jwt.getSubject(); + } + + public String getAssertion() { + return assertion; + } + + private List getExpectedAudiences() { + return Collections.singletonList(expectedAudience); + } + + private int getAllowedClockSkew() { + return 15; + } + + private int getMaximumExpirationTime() { + return 300; + } +} diff --git a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java index c93edf5a64de..9fd49df9b1e9 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java @@ -33,6 +33,7 @@ import org.keycloak.authentication.authenticators.client.FederatedJWTClientValidator; import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper; import org.keycloak.broker.provider.AuthenticationRequest; +import org.keycloak.broker.provider.JWTAuthorizationGrantProvider; import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.broker.provider.ClientAssertionIdentityProvider; import org.keycloak.broker.provider.ExchangeExternalToken; @@ -68,6 +69,7 @@ import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.TokenExchangeContext; +import org.keycloak.protocol.oidc.JWTAuthorizationGrantValidationContext; import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.IDToken; import org.keycloak.representations.JsonWebToken; @@ -94,7 +96,7 @@ /** * @author Pedro Igor */ -public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider implements ExchangeExternalToken, ClientAssertionIdentityProvider { +public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider implements ExchangeExternalToken, ClientAssertionIdentityProvider, JWTAuthorizationGrantProvider { protected static final Logger logger = Logger.getLogger(OIDCIdentityProvider.class); public static final String SCOPE_OPENID = "openid"; @@ -1066,4 +1068,25 @@ public boolean verifyClientAssertion(ClientAuthenticationFlowContext context) th return validator.validate(); } + @Override + public boolean isIssuer(String issuer) { + return issuer != null && issuer.equals(getConfig().getIssuer()); + } + + @Override + public BrokeredIdentityContext validateAuthorizationGrantAssertion(JWTAuthorizationGrantValidationContext context) { + if (!Profile.isFeatureEnabled(Profile.Feature.JWT_AUTHORIZATION_GRANT)) { + throw new RuntimeException("FEATURE is not enabled"); + } + + //TODO: proper assertion validation + + BrokeredIdentityContext user = new BrokeredIdentityContext(context.getJWT().getId(), getConfig()); + String username = context.getJWT().getSubject(); + user.setUsername(username); + user.setIdp(this); + return user; + + } + } 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 new file mode 100644 index 000000000000..402db7742d7b --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java @@ -0,0 +1,150 @@ +/* + * 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.protocol.oidc.grants; +import jakarta.ws.rs.core.Response; +import org.jboss.logging.Logger; +import org.keycloak.OAuth2Constants; +import org.keycloak.OAuthErrorException; +import org.keycloak.broker.provider.JWTAuthorizationGrantProvider; +import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.broker.provider.IdentityProvider; +import org.keycloak.broker.provider.IdentityProviderMapper; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventType; +import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientSessionContext; +import org.keycloak.models.FederatedIdentityModel; +import org.keycloak.models.IdentityProviderMapperModel; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.oidc.JWTAuthorizationGrantValidationContext; +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.managers.AuthenticationSessionManager; +import org.keycloak.services.managers.UserSessionManager; +import org.keycloak.services.resources.IdentityBrokerService; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.sessions.RootAuthenticationSessionModel; + +import java.util.Set; +import java.util.stream.Collectors; + +public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase { + + private static final Logger logger = Logger.getLogger(JWTAuthorizationGrantType.class); + + @Override + public Response process(Context context) { + setContext(context); + + String assertion = formParams.getFirst(OAuth2Constants.ASSERTION); + String expectedAudience = Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()); + JWTAuthorizationGrantValidationContext authorizationGrantContext = new JWTAuthorizationGrantValidationContext(assertion, client, expectedAudience); + + try { + //client must be confidential + authorizationGrantContext.validateClient(); + + //validate assertion claim (grant_type already validated to select the grant type) + authorizationGrantContext.validateAssertionParameters(); + + //validate token is JWT and is valid (the signature is validated by the idp) + authorizationGrantContext.validateJWTFormat(); + authorizationGrantContext.validateTokenActive(); + + //mandatory claims + authorizationGrantContext.validateAudience(); + authorizationGrantContext.validateIssuer(); + authorizationGrantContext.validateSubject(); + + //select the idp using the issuer claim + String jwtIssuer = authorizationGrantContext.getIssuer(); + IdentityProviderModel identityProviderModel = getAuthorizationGrantIdentityProviderModel(context.getSession(), jwtIssuer); + if (identityProviderModel == null) { + throw new RuntimeException("No Identity Provider for provided issuer"); + } + JWTAuthorizationGrantProvider jwtAuthorizationGrantProvider = (JWTAuthorizationGrantProvider) IdentityBrokerService.getIdentityProvider(session, identityProviderModel.getAlias()); + + //validate the JWT assertion and get the brokered identity from the idp + BrokeredIdentityContext brokeredIdentityContext = jwtAuthorizationGrantProvider.validateAuthorizationGrantAssertion(authorizationGrantContext); + if (brokeredIdentityContext == null) { + throw new RuntimeException("Error validating JWT with identity provider"); + } + + //apply identity provider mappers + Set mappers = session.identityProviders().getMappersByAliasStream(identityProviderModel.getAlias()) + .collect(Collectors.toSet()); + KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory(); + for (IdentityProviderMapperModel mapper : mappers) { + IdentityProviderMapper target = (IdentityProviderMapper)sessionFactory.getProviderFactory(IdentityProviderMapper.class, mapper.getIdentityProviderMapper()); + target.preprocessFederatedIdentity(session, realm, mapper, brokeredIdentityContext); + } + + //user must exist in keycloak + FederatedIdentityModel federatedIdentityModel = new FederatedIdentityModel(identityProviderModel.getAlias(), brokeredIdentityContext.getUsername(), brokeredIdentityContext.getUsername(), brokeredIdentityContext.getToken()); + UserModel user = this.session.users().getUserByFederatedIdentity(realm, federatedIdentityModel); + if (user == null) { + throw new RuntimeException("User not found"); + } + + String scopeParam = formParams.getFirst(OAuth2Constants.SCOPE); + //TODO: scopes processing + + UserSessionModel userSession = new UserSessionManager(session).createUserSession(realm, user, user.getUsername(), clientConnection.getRemoteHost(), "authorization-grant", false, null, null); + RootAuthenticationSessionModel rootAuthSession = new AuthenticationSessionManager(session).createAuthenticationSession(realm, false); + AuthenticationSessionModel authSession = createSessionModel(rootAuthSession, user, client, scopeParam); + ClientSessionContext clientSessionCtx = TokenManager.attachAuthenticationSession(this.session, userSession, authSession); + return createTokenResponse(user, userSession, clientSessionCtx, scopeParam, true, null); + } + catch (Exception e) { + event.detail(Details.REASON, e.getMessage()); + event.error(Errors.INVALID_REQUEST); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, e.getMessage(), Response.Status.BAD_REQUEST); + } + } + + protected AuthenticationSessionModel createSessionModel(RootAuthenticationSessionModel rootAuthSession, UserModel targetUser, ClientModel client, String scope) { + AuthenticationSessionModel authSession = rootAuthSession.createAuthenticationSession(client); + authSession.setAuthenticatedUser(targetUser); + authSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + authSession.setClientNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName())); + authSession.setClientNote(OIDCLoginProtocol.SCOPE_PARAM, scope); + return authSession; + } + + private IdentityProviderModel getAuthorizationGrantIdentityProviderModel(KeycloakSession session, String issuer) { + return session.identityProviders().getAllStream() + .filter(idpModel -> { + IdentityProvider idp = IdentityBrokerService.getIdentityProvider(session, idpModel.getAlias()); + return idp instanceof JWTAuthorizationGrantProvider authorizationGrantProvider && authorizationGrantProvider.isIssuer(issuer); + }) + .findFirst() + .orElse(null); + } + + @Override + public EventType getEventType() { + return EventType.LOGIN; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantTypeFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantTypeFactory.java new file mode 100644 index 000000000000..d0a37dbad33b --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantTypeFactory.java @@ -0,0 +1,62 @@ +/* + * 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.protocol.oidc.grants; + + +import org.keycloak.Config; +import org.keycloak.OAuth2Constants; +import org.keycloak.common.Profile; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.EnvironmentDependentProviderFactory; + +public class JWTAuthorizationGrantTypeFactory implements OAuth2GrantTypeFactory, EnvironmentDependentProviderFactory { + + @Override + public String getId() { + return OAuth2Constants.JWT_AUTHORIZATION_GRANT; + } + + @Override + public String getShortcut() { + return "ag"; + } + + @Override + public OAuth2GrantType create(KeycloakSession session) { + return new JWTAuthorizationGrantType(); + } + + @Override + public void init(Config.Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public boolean isSupported(Config.Scope config) { + return Profile.isFeatureEnabled(Profile.Feature.JWT_AUTHORIZATION_GRANT); + } + +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.grants.OAuth2GrantTypeFactory b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.grants.OAuth2GrantTypeFactory index 8897da06c361..5b47a85fec52 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.grants.OAuth2GrantTypeFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.grants.OAuth2GrantTypeFactory @@ -7,3 +7,4 @@ org.keycloak.protocol.oidc.grants.TokenExchangeGrantTypeFactory org.keycloak.protocol.oidc.grants.ciba.CibaGrantTypeFactory org.keycloak.protocol.oidc.grants.device.DeviceGrantTypeFactory org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory +org.keycloak.protocol.oidc.grants.JWTAuthorizationGrantTypeFactory diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantTest.java new file mode 100644 index 000000000000..7b2aa1126727 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantTest.java @@ -0,0 +1,214 @@ +package org.keycloak.tests.oauth; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.broker.oidc.OIDCIdentityProviderFactory; +import org.keycloak.common.Profile; +import org.keycloak.common.util.Time; +import org.keycloak.events.EventType; +import org.keycloak.models.IdentityProviderModel; +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.annotations.KeycloakIntegrationTest; +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.server.KeycloakServerConfigBuilder; +import org.keycloak.tests.client.authentication.external.ClientAuthIdpServerConfig; +import org.keycloak.testsuite.util.IdentityProviderBuilder; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; + +import java.util.UUID; + +@KeycloakIntegrationTest(config = JWTAuthorizationGrantTest.JWTAuthorizationGrantServerConfig.class) +public class JWTAuthorizationGrantTest { + + private static final String IDP_ALIAS = "authorization-grant-idp"; + private static final String IDP_ISSUER = "authorization-grant://mytrust-domain"; + + @InjectOAuthIdentityProvider(config = JWTAuthorizationGrantTest.AGIdpConfig.class) + OAuthIdentityProvider identityProvider; + + @InjectRealm(config = JWTAuthorizationGranthRealmConfig.class) + protected ManagedRealm realm; + + @InjectUser(config = FederatedUserConfiguration.class) + ManagedUser user; + + @InjectOAuthClient + OAuthClient oAuthClient; + + @InjectEvents + Events events; + + + @Test + public void testPublicClient() { + String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); + oAuthClient.client("test-public"); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Public client not allowed to use authorization grant", response, events.poll()); + oAuthClient.client("test-app", "test-secret"); + } + + @Test + public void testMissingAssertionParameter() { + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(null).send(); + assertFailure("Missing parameter:" + OAuth2Constants.ASSERTION, response, events.poll()); + } + + @Test + public void testBadAssertionParameter() { + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest("fake-jwt").send(); + assertFailure("The provided assertion is not a valid JWT", response, events.poll()); + } + + @Test + public void testExpiredAssertion() { + String jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, null)); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Token exp claim is required", response, events.poll()); + + jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() - 1L)); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Token is not active", response, events.poll()); + } + + @Test + public void testBadAudience() { + String jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", null, IDP_ISSUER, Time.currentTime() + 300L)); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Invalid token audience", response, events.poll()); + + jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", "fake-audience", IDP_ISSUER)); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Invalid token audience", response, events.poll()); + } + + @Test + public void testBadIssuer() { + String jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), null, Time.currentTime() + 300L)); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Missing claim: " + OAuth2Constants.ISSUER, response, events.poll()); + + jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), "fake-issuer", Time.currentTime() + 300L)); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("No Identity Provider for provided issuer", response, events.poll()); + } + + @Test + public void testBadSubject() { + String jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken(null, oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 300L)); + AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Missing claim: " + IDToken.SUBJECT, response, events.poll()); + + jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("fake-user", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 300L)); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("User not found", response, events.poll()); + } + + @Test + public void testSuccessGrant() { + String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken()); + 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); + } + + protected JsonWebToken createAuthorizationGrantToken(String subject, String audience, String issuer, Long exp) { + JsonWebToken token = new JsonWebToken(); + token.id(UUID.randomUUID().toString()); + token.subject(subject); + token.audience(audience); + token.issuer(issuer); + token.exp(exp); + 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 JWTAuthorizationGranthRealmConfig implements RealmConfig { + + @Override + public RealmConfigBuilder configure(RealmConfigBuilder realm) { + + realm.addClient("test-public").publicClient(true); + + realm.identityProvider( + IdentityProviderBuilder.create() + .providerId(OIDCIdentityProviderFactory.PROVIDER_ID) + .alias(IDP_ALIAS) + .setAttribute(IdentityProviderModel.ISSUER, IDP_ISSUER) + .build()); + 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 void assertSuccess(String expectedClientId, String username, AccessTokenResponse response) { + Assertions.assertTrue(response.isSuccess()); + AccessToken accessToken = oAuthClient.parseToken(response.getAccessToken(), AccessToken.class); + Assertions.assertEquals(expectedClientId, accessToken.getIssuedFor()); + Assertions.assertEquals(username, accessToken.getPreferredUsername()); + } + + protected void assertFailure(String expectedErrorDescription, AccessTokenResponse response, EventRepresentation event) { + Assertions.assertFalse(response.isSuccess()); + Assertions.assertEquals("invalid_request", 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/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractOAuthClient.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractOAuthClient.java index af724a93cc1e..a7ac01224b5c 100644 --- a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractOAuthClient.java +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractOAuthClient.java @@ -109,6 +109,14 @@ public AccessTokenResponse doPasswordGrantRequest(String username, String passwo return passwordGrantRequest(username, password).send(); } + public JWTAuthorizationGrantRequest jwtAuthorizationGrantRequest(String assertion) { + return new JWTAuthorizationGrantRequest(assertion, this); + } + + public AccessTokenResponse doJWTAuthorizationGrantRequest(String assertion) { + return jwtAuthorizationGrantRequest(assertion).send(); + } + public AccessTokenRequest accessTokenRequest(String code) { return new AccessTokenRequest(code, this); } diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/JWTAuthorizationGrantRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/JWTAuthorizationGrantRequest.java new file mode 100644 index 000000000000..64fee5db712c --- /dev/null +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/JWTAuthorizationGrantRequest.java @@ -0,0 +1,38 @@ +package org.keycloak.testsuite.util.oauth; + +import org.apache.http.client.methods.CloseableHttpResponse; +import org.keycloak.OAuth2Constants; + +import java.io.IOException; + +public class JWTAuthorizationGrantRequest extends AbstractHttpPostRequest { + + private String assertion; + + JWTAuthorizationGrantRequest(String assertion, AbstractOAuthClient client) { + super(client); + this.assertion = assertion; + } + + @Override + protected String getEndpoint() { + return client.getEndpoints().getToken(); + } + + public JWTAuthorizationGrantRequest assertion(String assertion) { + this.assertion = assertion; + return this; + } + + protected void initRequest() { + parameter(OAuth2Constants.GRANT_TYPE, OAuth2Constants.JWT_AUTHORIZATION_GRANT); + parameter("assertion", assertion); + scope(); + } + + @Override + protected AccessTokenResponse toResponse(CloseableHttpResponse response) throws IOException { + return new AccessTokenResponse(response); + } + +} From a9b342395a6206124d48bd162220c3f22b606e0b Mon Sep 17 00:00:00 2001 From: Giuseppe Graziano Date: Wed, 22 Oct 2025 10:56:51 +0200 Subject: [PATCH 2/2] Experimental feature for JWT Authorization Grant Closes #43444 Signed-off-by: Giuseppe Graziano --- .../JWTAuthorizationGrantProvider.java | 3 -- .../broker/oidc/OIDCIdentityProvider.java | 14 +------ .../grants/JWTAuthorizationGrantType.java | 42 +++++-------------- 3 files changed, 13 insertions(+), 46 deletions(-) diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java index 41039f85293b..9b7e6ee47791 100644 --- a/server-spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java @@ -18,8 +18,5 @@ import org.keycloak.protocol.oidc.JWTAuthorizationGrantValidationContext; public interface JWTAuthorizationGrantProvider { - - boolean isIssuer(String issuer); - BrokeredIdentityContext validateAuthorizationGrantAssertion(JWTAuthorizationGrantValidationContext assertion); } diff --git a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java index 9fd49df9b1e9..4c240baa110c 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java @@ -1068,22 +1068,12 @@ public boolean verifyClientAssertion(ClientAuthenticationFlowContext context) th return validator.validate(); } - @Override - public boolean isIssuer(String issuer) { - return issuer != null && issuer.equals(getConfig().getIssuer()); - } - @Override public BrokeredIdentityContext validateAuthorizationGrantAssertion(JWTAuthorizationGrantValidationContext context) { - if (!Profile.isFeatureEnabled(Profile.Feature.JWT_AUTHORIZATION_GRANT)) { - throw new RuntimeException("FEATURE is not enabled"); - } //TODO: proper assertion validation - - BrokeredIdentityContext user = new BrokeredIdentityContext(context.getJWT().getId(), getConfig()); - String username = context.getJWT().getSubject(); - user.setUsername(username); + BrokeredIdentityContext user = new BrokeredIdentityContext(context.getJWT().getSubject(), getConfig()); + user.setUsername(context.getJWT().getSubject()); user.setIdp(this); return user; 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 402db7742d7b..1544b89f5c26 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 @@ -20,20 +20,17 @@ import org.jboss.logging.Logger; import org.keycloak.OAuth2Constants; import org.keycloak.OAuthErrorException; -import org.keycloak.broker.provider.JWTAuthorizationGrantProvider; import org.keycloak.broker.provider.BrokeredIdentityContext; -import org.keycloak.broker.provider.IdentityProvider; -import org.keycloak.broker.provider.IdentityProviderMapper; +import org.keycloak.broker.provider.JWTAuthorizationGrantProvider; +import org.keycloak.broker.provider.UserAuthenticationIdentityProvider; +import org.keycloak.cache.AlternativeLookupProvider; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.EventType; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientSessionContext; import org.keycloak.models.FederatedIdentityModel; -import org.keycloak.models.IdentityProviderMapperModel; import org.keycloak.models.IdentityProviderModel; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.oidc.JWTAuthorizationGrantValidationContext; @@ -47,9 +44,6 @@ import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.RootAuthenticationSessionModel; -import java.util.Set; -import java.util.stream.Collectors; - public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase { private static final Logger logger = Logger.getLogger(JWTAuthorizationGrantType.class); @@ -80,11 +74,16 @@ public Response process(Context context) { //select the idp using the issuer claim String jwtIssuer = authorizationGrantContext.getIssuer(); - IdentityProviderModel identityProviderModel = getAuthorizationGrantIdentityProviderModel(context.getSession(), jwtIssuer); + AlternativeLookupProvider lookupProvider = context.getSession().getProvider(AlternativeLookupProvider.class); + IdentityProviderModel identityProviderModel = lookupProvider.lookupIdentityProviderFromIssuer(session, jwtIssuer); if (identityProviderModel == null) { throw new RuntimeException("No Identity Provider for provided issuer"); } - JWTAuthorizationGrantProvider jwtAuthorizationGrantProvider = (JWTAuthorizationGrantProvider) IdentityBrokerService.getIdentityProvider(session, identityProviderModel.getAlias()); + + UserAuthenticationIdentityProvider identityProvider = IdentityBrokerService.getIdentityProvider(session, identityProviderModel.getAlias()); + if (!(identityProvider instanceof JWTAuthorizationGrantProvider jwtAuthorizationGrantProvider)) { + throw new RuntimeException("Identity Provider is not configured for JWT Authorization Grant"); + } //validate the JWT assertion and get the brokered identity from the idp BrokeredIdentityContext brokeredIdentityContext = jwtAuthorizationGrantProvider.validateAuthorizationGrantAssertion(authorizationGrantContext); @@ -92,17 +91,8 @@ public Response process(Context context) { throw new RuntimeException("Error validating JWT with identity provider"); } - //apply identity provider mappers - Set mappers = session.identityProviders().getMappersByAliasStream(identityProviderModel.getAlias()) - .collect(Collectors.toSet()); - KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory(); - for (IdentityProviderMapperModel mapper : mappers) { - IdentityProviderMapper target = (IdentityProviderMapper)sessionFactory.getProviderFactory(IdentityProviderMapper.class, mapper.getIdentityProviderMapper()); - target.preprocessFederatedIdentity(session, realm, mapper, brokeredIdentityContext); - } - //user must exist in keycloak - FederatedIdentityModel federatedIdentityModel = new FederatedIdentityModel(identityProviderModel.getAlias(), brokeredIdentityContext.getUsername(), brokeredIdentityContext.getUsername(), brokeredIdentityContext.getToken()); + FederatedIdentityModel federatedIdentityModel = new FederatedIdentityModel(identityProviderModel.getAlias(), brokeredIdentityContext.getId(), brokeredIdentityContext.getUsername(), brokeredIdentityContext.getToken()); UserModel user = this.session.users().getUserByFederatedIdentity(realm, federatedIdentityModel); if (user == null) { throw new RuntimeException("User not found"); @@ -133,16 +123,6 @@ protected AuthenticationSessionModel createSessionModel(RootAuthenticationSessio return authSession; } - private IdentityProviderModel getAuthorizationGrantIdentityProviderModel(KeycloakSession session, String issuer) { - return session.identityProviders().getAllStream() - .filter(idpModel -> { - IdentityProvider idp = IdentityBrokerService.getIdentityProvider(session, idpModel.getAlias()); - return idp instanceof JWTAuthorizationGrantProvider authorizationGrantProvider && authorizationGrantProvider.isIssuer(issuer); - }) - .findFirst() - .orElse(null); - } - @Override public EventType getEventType() { return EventType.LOGIN;