Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions common/src/main/java/org/keycloak/common/Profile.java
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
3 changes: 3 additions & 0 deletions core/src/main/java/org/keycloak/OAuth2Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -94,7 +96,7 @@
/**
* @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";
Expand Down Expand Up @@ -1066,4 +1068,15 @@ public boolean verifyClientAssertion(ClientAuthenticationFlowContext context) th
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;

}

}
Original file line number Diff line number Diff line change
@@ -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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this the subject of the token should be the user id in keycloak. Don't remember what was finally decided here. For me it's OK for this first implementation, but probably we need something more (at least I would add the username too, I remember we talked about more complicated ideas).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIR it was decided to go this way initially.

We may need something more just if requested by users though... In that case, we would need some additional configuration on the IDP for how to map the assertion to the Keycloak UserModel ...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mmm I have a doubt if we need just that the subject of the assertion JWT must be the username of the user in keycloak without any link to the idp, or if we need a link between the keyloak user and the idp and the subject of the assertion can be the linked external id of the keycloak user

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that the link is needed, because the caller is using the ID in the broker to identify the user, so in the JWT authorization grant realm we have only that ID (ID in the original realm) in the link. So I think that it's OK like it is now.

if (user == null) {
throw new RuntimeException("User not found");
}

String scopeParam = formParams.getFirst(OAuth2Constants.SCOPE);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be good to create follow-up issues for this one.

//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;
}
}
Loading
Loading