mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-09 15:02:05 -03:30
Experimental feature for JWT Authorization Grant (#43624)
Closes #43444 Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
parent
f6ac64907d
commit
a25a0268de
@ -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),
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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<String> 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<String> getExpectedAudiences() {
|
||||
return Collections.singletonList(expectedAudience);
|
||||
}
|
||||
|
||||
private int getAllowedClockSkew() {
|
||||
return 15;
|
||||
}
|
||||
|
||||
private int getMaximumExpirationTime() {
|
||||
return 300;
|
||||
}
|
||||
}
|
||||
@ -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<OIDCIdentityProviderConfig> implements ExchangeExternalToken, ClientAssertionIdentityProvider<OIDCIdentityProviderConfig> {
|
||||
public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIdentityProviderConfig> implements ExchangeExternalToken, ClientAssertionIdentityProvider<OIDCIdentityProviderConfig>, 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<OIDCIde
|
||||
return validator.validate();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BrokeredIdentityContext validateAuthorizationGrantAssertion(JWTAuthorizationGrantValidationContext context) {
|
||||
|
||||
//TODO: proper assertion validation
|
||||
BrokeredIdentityContext user = new BrokeredIdentityContext(context.getJWT().getSubject(), getConfig());
|
||||
user.setUsername(context.getJWT().getSubject());
|
||||
user.setIdp(this);
|
||||
return user;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,130 @@
|
||||
/*
|
||||
* 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.BrokeredIdentityContext;
|
||||
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.IdentityProviderModel;
|
||||
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;
|
||||
|
||||
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();
|
||||
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");
|
||||
}
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -109,6 +109,14 @@ public abstract class AbstractOAuthClient<T> {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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<JWTAuthorizationGrantRequest, AccessTokenResponse> {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user