From a25a0268de0c3cc866a1e194bb8ef8c5c1122857 Mon Sep 17 00:00:00 2001 From: Giuseppe Graziano Date: Wed, 22 Oct 2025 15:34:33 +0200 Subject: [PATCH] Experimental feature for JWT Authorization Grant (#43624) Closes #43444 Signed-off-by: Giuseppe Graziano --- .../java/org/keycloak/common/Profile.java | 2 + .../java/org/keycloak/OAuth2Constants.java | 3 + .../JWTAuthorizationGrantProvider.java | 22 ++ ...WTAuthorizationGrantValidationContext.java | 145 ++++++++++++ .../broker/oidc/OIDCIdentityProvider.java | 15 +- .../grants/JWTAuthorizationGrantType.java | 130 +++++++++++ .../JWTAuthorizationGrantTypeFactory.java | 62 +++++ ...rotocol.oidc.grants.OAuth2GrantTypeFactory | 1 + .../oauth/JWTAuthorizationGrantTest.java | 214 ++++++++++++++++++ .../util/oauth/AbstractOAuthClient.java | 8 + .../oauth/JWTAuthorizationGrantRequest.java | 38 ++++ 11 files changed, 639 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 48e56d0bc24..608a0180511 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 class Profile { 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 b6eb64f56f8..665823d2030 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 00000000000..9b7e6ee4779 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java @@ -0,0 +1,22 @@ +/* + * 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 { + 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 00000000000..9e4956b63ac --- /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 c93edf5a64d..4c240baa110 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.ClientAuthenticationFlowContext; 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.UserModel; 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 @@ import java.util.Optional; /** * @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,15 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider 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); + if (brokeredIdentityContext == null) { + throw new RuntimeException("Error validating JWT with identity provider"); + } + + //user must exist in keycloak + 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"); + } + + 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; + } + + @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 00000000000..d0a37dbad33 --- /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 8897da06c36..5b47a85fec5 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 00000000000..7b2aa112672 --- /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 af724a93cc1..a7ac01224b5 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 abstract class AbstractOAuthClient { 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 00000000000..64fee5db712 --- /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); + } + +}