-
Notifications
You must be signed in to change notification settings - Fork 7.9k
Experimental feature for JWT Authorization Grant #43624
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
22 changes: 22 additions & 0 deletions
22
...spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
145 changes: 145 additions & 0 deletions
145
...vate/src/main/java/org/keycloak/protocol/oidc/JWTAuthorizationGrantValidationContext.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
130 changes: 130 additions & 0 deletions
130
services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| if (user == null) { | ||
| throw new RuntimeException("User not found"); | ||
| } | ||
|
|
||
| String scopeParam = formParams.getFirst(OAuth2Constants.SCOPE); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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...There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.