8000 Customization for JWTAuthorizationGrantType to support ID-JAG by bucchi · Pull Request #46048 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content

Customization for JWTAuthorizationGrantType to support ID-JAG - #46048

Merged
mposolda merged 1 commit into
keycloak:mainfrom
Hitachi:ISSUE-43971-IDJAGReceiver
May 29, 2026
Merged

mposolda merged 1 commit into
keycloak:mainfrom
Hitachi:ISSUE-43971-IDJAGReceiver

Conversation

@bucchi
@bucchi bucchi commented Feb 5, 2026 •
Copy link
Copy Markdown
Contributor

closes #49005

This PR is for ID-JAG receiver, which receives ID-JAG and send back access token working as a part of token endpoint.

ID-JAG is one type of JWT, which can be used as Authorization Grant(https://www.rfc-editor.org/rfc/rfc7523.html#section-2.1).
AFAIK, the type for JWTAuthorizationGrant clearly mentioned in the standard specs or the draft of those is only one, for now.
But in the future there could be more types coming.
So here in this PR, we make validators for every types of JWT pluggable by introducing brand new SPI:JWTAuthorizationGrantValidatorSpi.

@bucchi
bucchi marked this pull request as ready for review February 5, 2026 10:48
@bucchi
bucchi requested a review from a team as a code owner February 5, 2026 10:48
@tnorimat
tnorimat commented Feb 5, 2026
Copy link
Copy Markdown
Contributor

@bucchi Hello, could you run ./mvnw spotless:apply ? It automatically modifies codes to align with Keycloak's code style.
It can resolve the PR's failure https://github.com/keycloak/keycloak/actions/runs/21708246440/job/62604873514?pr=46048

@bucchi
bucchi force-pushed the ISSUE-43971-IDJAGReceiver branch from 5f016d7 to bfd1d78 Compare February 5, 2026 15:56
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

8000

It is better to add javadoc comment to interface class for a reviewer who is not familiar with ID-JAG to understand them.

@bucchi bucchi Feb 22, 2026 •
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@tnorimat
Thank you for your comment.
I have just added some comments.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you.

Copilot AI left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces support for Identity Assertion JWT Authorization Grant (ID-JAG), a new type of JWT that can be used as an authorization grant per RFC 7523. To accommodate different JWT types for authorization grants, the PR implements a new Service Provider Interface (SPI) called JWTAuthorizationGrantValidatorSpi, which allows for pluggable JWT validators. The existing JWTAuthorizationGrantValidator class has been refactored into a base class (JWTAuthorizationGrantValidatorBase), with a new ID-JAG-specific validator (IDJWTAuthorizationGrantValidator) and a default validator implementation (DefaultJWTAuthorizationGrantValidator).

Changes:

  • Introduced JWTAuthorizationGrantValidatorSpi for pluggable JWT validation based on token type
  • Refactored JWT authorization grant code into a new jwtauthorization package
  • Added ID-JAG validator with client_id claim validation
  • Added feature flag IDENTITY_ASSERTION_JWT_VALIDATOR and corresponding tests

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
common/src/main/java/org/keycloak/common/Profile.java Adds IDENTITY_ASSERTION_JWT_VALIDATOR feature flag
server-spi-private/src/main/java/org/keycloak/protocol/oidc/JWTAuthorizationGrantValidatorSpi.java Defines new SPI for JWT authorization grant validators
server-spi-private/src/main/java/org/keycloak/protocol/oidc/JWTAuthorizationGrantValidatorFactory.java Factory interface for JWT authorization grant validators
server-spi-private/src/main/java/org/keycloak/protocol/oidc/JWTAuthorizationGrantValidator.java Provider interface for JWT authorization grant validators
server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi Registers the new SPI
services/src/main/java/org/keycloak/authentication/authenticators/client/AbstractBaseJWTValidator.java Makes clientAssertionState non-final to allow setting after construction
services/src/main/java/org/keycloak/protocol/oidc/grants/jwtauthorization/JWTAuthorizationGrantType.java Refactored grant type to support pluggable validators based on JWT type header
services/src/main/java/org/keycloak/protocol/oidc/grants/jwtauthorization/JWTAuthorizationGrantTypeFactory.java Moved to new package location
services/src/main/java/org/keycloak/protocol/oidc/grants/jwtauthorization/validator/JWTAuthorizationGrantValidatorBase.java Base validator class refactored from original validator
services/src/main/java/org/keycloak/protocol/oidc/grants/jwtauthorization/validator/DefaultJWTAuthorizationGrantValidator.java Default validator for standard JWT authorization grants
services/src/main/java/org/keycloak/protocol/oidc/grants/jwtauthorization/validator/IDJWTAuthorizationGrantValidator.java ID-JAG specific validator that validates client_id claim
services/src/main/java/org/keycloak/protocol/oidc/grants/jwtauthorization/validator/IDJWTAuthorizationGrantValidatorFactory.java Factory for ID-JAG validator
services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.grants.OAuth2GrantTypeFactory Updates path for JWTAuthorizationGrantTypeFactory
services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.JWTAuthorizationGrantValidatorFactory Registers ID-JAG validator factory
test-framework/oauth/src/main/java/org/keycloak/testframework/oauth/OAuthIdentityProvider.java Adds encodeIDJAG method for creating ID-JAG tokens
tests/base/src/test/java/org/keycloak/tests/oauth/IDJWTAuthorizationGrantTest.java Tests for ID-JAG functionality
Comments suppressed due to low confidence (4)

services/src/main/java/org/keycloak/protocol/oidc/grants/jwtauthorization/JWTAuthorizationGrantType.java:89

  • The catch block has inconsistent indentation. The throw statement should be aligned with the opening brace of the catch block (should not have extra indentation).
    services/src/main/java/org/keycloak/protocol/oidc/grants/jwtauthorization/JWTAuthorizationGrantType.java:81
  • Missing space after the 'if' keyword. The code should be formatted as "if (" instead of "if(".
    services/src/main/java/org/keycloak/protocol/oidc/grants/jwtauthorization/JWTAuthorizationGrantType.java:90
  • The JWT parsing is being done twice: once in line 73-76 and again in lines 86-90. This is inefficient. Consider parsing the JWT once and reusing the result, or refactor the logic to avoid duplicate parsing.
    services/src/main/java/org/keycloak/protocol/oidc/grants/jwtauthorization/validator/JWTAuthorizationGrantValidatorBase.java:42
  • The JWTAuthorizationGrantValidatorBase class implements JWTAuthorizationGrantValidator (which extends JWTAuthorizationGrantValidationContext), but it's missing the implementation of the getJws() method declared in JWTAuthorizationGrantValidationContext. This method should be implemented to return clientAssertionState.getJws().

protected ManagedUser user;

@Test
public void testClientInTokenAndInRequestPrameter() {
Copilot AI Feb 15, 2026
Copy link

Choose a reason for hiding this comment

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

The method name contains a typo: "Prameter" should be "Parameter"

Suggested change
public void testClientInTokenAndInRequestPrameter() {
public void testClientInTokenAndInRequestParameter() {

Copilot uses AI. Check for mistakes.
@bucchi bucchi Feb 22, 2026 •
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have just fixed this as suggested.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you.

Comment on lines +23 to +24
String clientIdInToken = (String) accessToken.getOtherClaims().get("client_id");
if (!session.getContext().getClient().getClientId().equals(clientIdInToken)){
Copilot AI Feb 15, 2026
Copy link

Choose a reason for hiding this comment

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

There's a potential NullPointerException if clientIdInToken is null. The code should check if clientIdInToken is null before using equals comparison. Consider using Objects.equals or check for null explicitly before comparison.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have just added explicit null check.

And also I have just added a test case for Identity Assertion JWT with no client_id included.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you.

public void validateClient() {
super.validateClient();

try{
Copilot AI Feb 15, 2026
Copy link

Choose a reason for hiding this comment

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

Missing space after 'try' keyword. The code should be formatted as "try {" instead of "try{".

Suggested change
try{
try {

Copilot uses AI. Check for mistakes.
@bucchi bucchi Feb 22, 2026 •
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have just fixed this as suggested.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you.

try{
AccessToken accessToken = new JWSInput(getAssertion()).readJsonContent(AccessToken.class);
String clientIdInToken = (String) accessToken.getOtherClaims().get("client_id");
if (!session.getContext().getClient().getClientId().equals(clientIdInToken)){
Copilot AI Feb 15, 2026
Copy link

Choose a reason for hiding this comment

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

Missing space after 'if' keyword. The code should be formatted as "if (" instead of "if (".

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The code should be formatted as "if (" instead of "if (".
From this, I can not find out what is the problem here.

But at the end of "if condition statement", there is a space missing at "oken)){".
So, I have just changed this to "oken)) {" for here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you.

super.validateClient();

try{
AccessToken accessToken = new JWSInput(getAssertion()).readJsonContent(AccessToken.class);
Copilot AI Feb 15, 2026
Copy link

Choose a reason for hiding this comment

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

The assertion is being parsed again with new JWSInput(getAssertion()), even though it was already parsed when creating the ClientAssertionState. This is inefficient. Consider reusing the already parsed JWSInput from clientAssertionState.getJws() instead.

Suggested change
AccessToken accessToken = new JWSInput(getAssertion()).readJsonContent(AccessToken.class);
JWSInput jws = clientAssertionState.getJws();
AccessToken accessToken = jws.readJsonContent(AccessToken.class);

Copilot uses AI. Check for mistakes.
@bucchi bucchi Feb 22, 2026 •
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have just fixed this as suggested.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you.

@VinodAnandan
VinodAnandan commented Feb 19, 2026 •
Copy link
Copy Markdown
Contributor

Hi @bucchi and @tnorimat, Thank you very much for ths PR, I really appreciate your work on this. I wanted to kindly check whether the PR is currently stalled, and if there is a tentative timeline your team has in mind for it. Are you planning for this PR to be included in the Keycloak 26.6 release?

If you haven’t had a chance to watch it yet, I would kindly recommend taking a look at the following video, which demonstrates how Okta is leveraging ID-JAG for MCP use cases - https://www.brighttalk.com/webcast/14899/661521

@thomasdarimont
thomasdarimont commented Feb 19, 2026 •
Copy link
Copy Markdown
Contributor

Would it be possible to expose the initial assertion (read-only) to subsequently executed protocol mappers?

If the initial assertion contained additional claims that should be carried over the the new token, we would need access to the original claims from the initial assertion.
I encountered this exact use case in two independent projects this week that looked into using the JWT bearer-based grant.

@thomasdarimont thomasdarimont left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good so far. I added a few minor remarks.

< 10BC8 div class="blob-wrapper border-bottom js-check-hidden-unicode">

public static DefaultJWTAuthorizationGrantValidator createValidator(KeycloakSession session, ClientModel client, String assertion, String scope) {
if (assertion == null) {
throw new RuntimeException("Missing parameter:" + OAuth2Constants.ASSERTION);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
throw new RuntimeException("Missing parameter:" + OAuth2Constants.ASSERTION);
throw new IllegalArgumentException("Missing parameter:" + OAuth2Constants.ASSERTION);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you for your commnet.

I have just fixed this as you suggested.

JWSInput jws = clientAssertionState.getJws();
AccessToken accessToken = jws.readJsonContent(AccessToken.class);
String clientIdInToken = (String) accessToken.getOtherClaims().get("client_id");
if (clientIdInToken == null || !session.getContext().getClient().getClientId().equals(clientIdInToken)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You could extract the client into a local var, this eases debugging.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have just fixed this as you suggested.

@@ -57,6 +57,10 @@ public String encodeToken(JsonWebToken token, OAuthIdentityProviderKeys keys) {
return new JWSBuilder().type("JWT").jsonContent(token).sign(new ECDSASignatureSignerContext(keys.getKeyWrapper()));
}

public String encodeIDJAG(JsonWebToken token) {
return new JWSBuilder().type("oauth-id-jag+jwt").jsonContent(token).sign(new ECDSASignatureSignerContext(keys.getKeyWrapper()));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Some custom jwt types are defined in org.keycloak.util.TokenUtil and org.keycloak.OAuth2Constants. I suggest to create a constant for "oauth-id-jag+jwt" in OAuth2Constants.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have just fixed this as you suggested.

@@ -56,8 +64,38 @@ public Response process(Context context) {

try {

JWTAuthorizationGrantValidator authorizationGrantContext = JWTAuthorizationGrantValidator.createValidator(
if (assertion == null) {
throw new RuntimeException("Missing parameter:" + OAuth2Constants.ASSERTION);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
throw new RuntimeException("Missing parameter:" + OAuth2Constants.ASSERTION);
throw new IllegalArgumentException("Missing parameter:" + OAuth2Constants.ASSERTION);

@bucchi bucchi Mar 1, 2026 •
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have just fixed this as you suggested,
and also added some test cases for this.

JWTAuthorizationGrantValidatorBase authorizationGrantContext =
(JWTAuthorizationGrantValidatorBase) session.getProvider(JWTAuthorizationGrantValidator.class, jwtTokenType);
if( authorizationGrantContext == null){
authorizationGrantContext = DefaultJWTAuthorizationGrantValidator.createValidator(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I sugget to create a protected method like createValidator(...) that uses DefaultJWTAuthorizationGrantValidator.createValidator(..) by default.

This would enable to customize the validation behavior if necessary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have just fixed this as you suggested.

@bucchi
bucchi force-pushed the ISSUE-43971-IDJAGReceiver branch from 055b308 to 37a541e Compare March 2, 2026 00:52
@VinodAnandan
Copy link
Copy Markdown
Contributor

@bucchi The PR branch currently has some conflicts that need to be resolved. When you have a moment, could you please help address them?

@bucchi
bucchi force-pushed the ISSUE-43971-IDJAGReceiver branch from 37a541e to 4e4e205 Compare March 2, 2026 22:24
@graziang graziang left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@bucchi Thanks for this PR, it’s nice to see the work to support this new specification. A few considerations:

The Identity Assertion JWT Authorization Grant (ID-JAG) is defined in the draft specification https://www.ietf.org/archive/id/draft-ietf-oauth-identity-assertion-authz-grant-01.html as a profile of the JWT Authorization Grant (RFC7523).

This specification is also based on another draft specification, Identity Chaining Across Trust Domains https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-chaining/, which combines Token Exchange (RFC8693) and the JWT Authorization Grant (RFC7523).

So this ID-JAG is obtained with a token exchange request and then used as an assertion in an access token request using the JWT Authorization Grant.

With this PR we are adding support for validating the Identity Assertion JWT Authorization Grant (ID-JAG) for JWT Authorization Grant requests.

Because of this, I was wondering if it would make sense to use a more general feature to support the whole specification, instead of having the feature IDENTITY_ASSERTION_JWT_VALIDATOR only for the ID-JAG validation.

I added some inline comments, but in general the introduction of a new SPI for the validator in this way IMO makes the classes a bit more complex, and it also introduces some duplication in the assertion parsing.

Additionally, we should consider the options configured on the identity provider. For example, jti is not mandatory for the JWT Authorization Grant, but it is required for ID-JAG. On the identity provider we have an option for replay checking of the assertion using the jti claim. This option should probably be hidden or disabled and enforced when ID-JAG is used.

@@ -162,6 +162,8 @@ public enum Feature {

CIMD("OAuth Client ID Metadata Document", Type.EXPERIMENTAL),

IDENTITY_ASSERTION_JWT_VALIDATOR("Identity Assertion JWT validator", Type.EXPERIMENTAL),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I was thinking if it makes sense to have a dedicated feature for the entire specification instead of only for validating the ID JAG.

@bucchi bucchi Mar 16, 2026 •
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you for your comment.
I made that IDENTITY_ASSERTION_JWT so that it could be used not only for validator part but also for issuer part.


try {
JWSInput jws = clientAssertionState.getJws();
AccessToken accessToken = jws.readJsonContent(AccessToken.class);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It should already be parsed in the client assertion state: clientAssertionState.getToken()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I fixed as you suggested.
Thank you.

*
* @author <a href="mailto:yutaka.obuchi.sd@hitachi.com">Yutaka Obuchi</a>
*/
public class IDJWTAuthorizationGrantValidator extends JWTAuthorizationGrantValidatorBase {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Some checks are missing. For example, jti and iat are REQUIRED according to the specification: https://www.ietf.org/archive/id/draft-ietf-oauth-identity-assertion-authz-grant-01.html#section-3.”

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I added some checks according to the specifications.

Here I also added some test cases for required fields.

Comment on lines +68 to +98
if (assertion == null) {
throw new IllegalArgumentException("Missing parameter:" + OAuth2Constants.ASSERTION);
}

JWSInput jws;
try {
jws = new JWSInput(assertion);
} catch (JWSInputException e) {
throw new RuntimeException("The provided assertion is not a valid JWT");
}

String jwtTokenType = jws.getHeader().getType();
JWTAuthorizationGrantValidatorBase authorizationGrantContext =
(JWTAuthorizationGrantValidatorBase) session.getProvider(JWTAuthorizationGrantValidator.class, jwtTokenType);
if( authorizationGrantContext == null){
authorizationGrantContext = createDefaultValidator(
context.getSession(), client, assertion, formParams.getFirst(OAuth2Constants.SCOPE));
} else {
JsonWebToken jwt;
try {
jwt = jws.readJsonContent(JsonWebToken.class);
} catch (JWSInputException e) {
throw new RuntimeException("The provided assertion is not a valid JWT");
}

ClientAssertionState clientAssertionState = new ClientAssertionState(OAuth2Constants.JWT_AUTHORIZATION_GRANT, assertion, jws, jwt);
clientAssertionState.setClient(client);

authorizationGrantContext.setClientAssertionState(clientAssertionState);
authorizationGrantContext.setScopeParam(formParams.getFirst(OAuth2Constants.SCOPE));
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Only IDJWTAuthorizationGrantValidator is obtained via getProvider, for normal jwt it is created manually. There are also some duplications in the parsing of the assertion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I removed some duplications.

@VinodAnandan
Copy link
Copy Markdown
Contributor

@bucchi @graziang I think it would be best to use the links below to access the latest versions of the drafts. This should help ensure everyone is reviewing the up-to-date documents and avoid any confusion caused by earlier versions.

https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/

@VinodAnandan
Copy link
Copy Markdown
Contributor

@bucchi @tnorimat Just a kind reminder regarding the 26.6 release schedule, which is planned for the next few weeks. If it may be difficult for you to prioritise this PR within that time frame, would it be possible to consider granting the Keycloak core maintainer write access to the PR to adopt the changes they have suggested? This might help ensure the PR can be merged in time for the 26.6 release. Thank you very much for considering this. I really appreciate your help.

@bucchi
bucchi force-pushed the ISSUE-43971-IDJAGReceiver branch 2 times, most recently from f6a7d2b to 5afbf42 Compare March 15, 2026 22:00
@bucchi
bucchi commented Mar 16, 2026 •
Copy link
Copy Markdown
Contributor Author

Additionally, we should consider the options configured on the identity provider. For example, jti is not mandatory for the JWT Authorization Grant, but it is required for ID-JAG. On the identity provider we have an option for replay checking of the assertion using the jti claim. This option should probably be hidden or disabled and enforced when ID-JAG is used.

@graziang,
I agree with you here.
For now, a current implementation simply ignores the option for jti on the identity provider.
Ideally, like you said here, this option should be hidden for ID-JAG.

From my understanding, it could be done by extending the identity provider of JWT Auhtorization Grant.
But maybe it should not be replacing, but it should be adding because, otherwise, the identity provider would be unable to process "normal" JWT Authorization Grants.
What do you think?

Btw, will you be in KeycloakCon EU next weak by any chance?
I will be there and talk about this in the session(https://colocatedeventseu2026.sched.com/event/2DY2B/model-context-protocolmcp-authorization-for-enterprise-use-case-with-keycloak-yutaka-obuchi-hitachi-ltd).
We can talk about this implementation if you also will be there.

@joeyorlando
Copy link
Copy Markdown

really looking forward to this PR landing in a (hopefully soon 🙂) release (in the mean-time for testing over in https://github.com/archestra-ai/archestra I've built an image off of your latest PR commit)

@bucchi
bucchi force-pushed the ISSUE-43971-IDJAGReceiver branch from 5afbf42 to 37fd6a9 Compare April 6, 2026 20:36
@thomasdarimont
thomasdarimont self-requested a review April 7, 2026 21:23
@thomasdarimont thomasdarimont left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM


public interface JWTAuthorizationGrantValidator extends Provider, JWTAuthorizationGrantValidationContext{

public void validateClient();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You could remove the public modifier from those interface methods as there are public by default :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you for your comment.

I fixed this.

@bucchi
bucchi force-pushed the ISSUE-43971-IDJAGReceiver branch from 37fd6a9 to 768893a Compare April 12, 2026 09:11
@bucchi
bucchi commented Apr 13, 2026 •
Copy link
Copy Markdown
Contributor Author

Hi everyone and @mposolda,
thank you for your helpful reviews.
I believe I have addressed all the comments and feedback provided.
Could you please take another look when you have a moment?
And let me know if I missed anything!

@y-tabata
y-tabata force-pushed the ISSUE-43971-IDJAGReceiver branch from 768893a to 19f78f4 Compare April 14, 2026 04:33
@bucchi
bucchi force-pushed the ISSUE-43971-IDJAGReceiver branch from 19f78f4 to a079b97 Compare April 21, 2026 04:48
@bucchi
bucchi force-pushed the ISSUE-43971-IDJAGReceiver branch from a079b97 to 3e5427e Compare May 14, 2026 06:58
@tnorimat
tnorimat commented May 14, 2026 •
Copy link
Copy Markdown
Contributor

@stianst @mposolda Hello,
Could you or your team review the PR?
Several people including your team has already reviewed the PR, and the issue (#43971) of the PR in on 26.7.0 milestone and many people over 10 are interested on the issue.

@mposolda
Copy link
Copy Markdown
Contributor

@tnorimat @bucchi Yes, my colleague @graziang will re-review the pull request.

FYI. I've linked this PR with the issue #49005 . As the scope of the issue #43971 is to deliver the whole feature ID-JAG and make sure it is properly tested, documented and supported.

@graziang graziang left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hi @bucchi, thanks for the updates on the PR, and sorry for the delay in the review. I added a few more inline comments, but I think we’re getting close to being ready. Thanks again for this work!

authorizationGrantContext = createDefaultValidator(
context.getSession(), formParams.getFirst(OAuth2Constants.SCOPE), clientAssertionState);
} else {
authorizationGrantContext.setClientAssertionState(clientAssertionState);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A provider created via SPI starts with clientAssertionState = null. Any method called before setClientAssertionState() can causes NPE, non-ideal use of the provider, It might be better to pass clientAssertionState as a parameter to validation methods instead, or some other refactor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@graziang
Thank you for your comment.

As you can see, that is actually one of tricky part. And it might make my implementation a little bit ugly.
So it is OK to modify the source code as your suggestion.

From my understanding, your suggestion is that all methods in JWTAuthorizationGrantValidator interface below better be modified to have ClientAssertionState as parameter.

Do I understand your suggestion correctly?


    void validateClient();
    
    void validateIssuer();
    
    void validateSubject();
    
    boolean validateTokenActive(int allowedClockSkew, int maxExp, boolean reusePermitted); 
    
    boolean validateSignatureAlgorithm(String expectedSignatureAlg);
    
    boolean validateTokenAudience(List<String> expectedAudiences, boolean multipleAudienceAllowed);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reason here I am asking this is that for this modification, AbstractBaseJWTValidator, AbstractJWTClientValidator and the related test codes have to be modified too and the affected area will be larger.
I wanted to confirm whether it was intended for just in case.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hi @bucchi, yeah, or create a ClientAssertionState for each method without touching the AbstractBaseJWTValidator, but the point here is that an implementation of an SPI should be usable via the methods defined by the SPI itself. If every SPI implementation requires different ways to configure or use it, and you need to cast to the concrete implementation in order to use it properly, there’s a design problem.

I know the issue is related to the relationship with AbstractBaseJWTValidator, but as things are getting complicated, maybe you could check if it would be simpler to just extend the original JWTAuthorizationGrantValidator as IDJWTAuthorizationGrantValidator and override only the methods needed to validate the ID-JAG and use it if needed in the JWTAuthorizationGrantType. WDYT?

@bucchi bucchi May 19, 2026 •
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@graziang
I have a couple of random ideas.

As you mentioned, I too think that the relationship with AbstractBaseJWTValidator makes this implementation complex and ugly.
So how about not using AbstractBaseJWTValidator like below:

  1. Keeping the original logic as it is in JWTAuhtorizationGrantType and just adding some code to get provider from session for making it possible to plugin extra validator for each token type.
    The SPI implementation here does not extend AbstractBaseJWTValidator. We better keep it as simple as possible.
    This way, no matter what JWT token type comes in, original logic in JWTAuhtorizationGrantType is executed and also depending on the incoming token type, the corresponding extra validation is executed.

  2. Using ClientPolicy with TokenType condition and IDJAGEnforcer executor for instance:
    Like built-in GranTypeCondition.java, TokenTypeCondition.java is implemented for checking token type in JWT header.
    Like built-in JWTClaimEnforcerExecutor.java, IDJAGEnforcerExecutor is implemented for checking if the other claims comply with the IDJAG specification.

There are three alternatives including your idea so far.
I have no particular preference among the these.

Please let me know your preference.
Thank you

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@bucchi If I understood correctly the first proposal is to keep the current behavior and add extra validation based on the provider. The problem with this approach is that the validation is not necessarily more restrictive than the default one, so it might be necessary to override the validation of some claims. I’m not against keeping the provider, but we need to find a way to use it that works well.

For the second approach, I’d prefer not to use client policies because I’d like support for this specification to be integrated with the idp and clients. As a follow-up, I can imagine enabling ID-JAG validation on the identity provider as well, and if we use client policies, that might not be possible. I see client policies for extra custom claims validation which is also already supported using the JWTClaimEnforcerExecutor.

I’ll leave it to you to find a solution that works better, maybe something in between the first approach and my proposal.

Thanks for all the work and the discussion on this

@bucchi bucchi May 22, 2026 •
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@graziang

A provider created via SPI starts with clientAssertionState = null. Any method called before setClientAssertionState() can causes NPE, non-ideal use of the provider, It might be better to pass clientAssertionState as a parameter to validation methods instead, or some other refactor.

About your concern above,
I have just modified the instance creation process of JWTAuthorizationGrantValidator implementation class like below:

    public DefaultJWTAuthorizationGrantValidator(KeycloakSession session) {
        this(session, 
            session.getContext().getHttpRequest().getDecodedFormParameters().getFirst(OAuth2Constants.SCOPE),
            buildClientAssertionState(session));
    }

So that a provider created via SPI starts with non null clientAssertionState.

How about this workaround?
Does it work for you?
Let me know what you think.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@bucchi The workaround initializes the provider by reading from the HTTP context in the constructor. This works for normal HTTP flows and single assertion because the provider is recreated for each session, but it creates tight coupling between the provider and the HTTP context, which can cause issues.
For example, if the provider is invoked multiple times in the same session with different assertions the cached provider will have stale state from the first invocation, how you can select the assertion to validate?

If we want to use the SPI provider pattern, I see two options:

  1. Pass and set state in each method:
    This way we don't need to change AbstractBaseJWTValidator:
  public void validateClient(ClientAssertionState state) {
      this.clientAssertionState = state;  // Update field before validation
      super.validateClient();             // Call existing base logic
  }

  public void validateIssuer(ClientAssertionState state) {
      this.clientAssertionState = state;                                                                                                                                                                                                                                                           
      super.validateIssuer();
  }
  // ... same pattern for other methods
  1. : Initialize method: Set the state at the beginning using an initialize method (similar to the previous setAssertionState approach but clearer), with proper NPE handling:
public interface JWTAuthorizationGrantValidator {
     void initialize(String scope, ClientAssertionState state);

     void validateClient();  // Call after initialize()
     void validateIssuer();
     // ...
 }

I don't see much additional value in using the provider via SPI for this use case, I see more complications than benefits. But if we want to go in this direction, I don't see any other good options compared to the two listed above, I slightly prefer the first one. WDYT? Do you have better ideas?

@bucchi bucchi May 26, 2026 •
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@graziang

I don't see much additional value in using the provider via SPI for this use case, I see more complications than benefits.

Maybe you are right about this.
I can not find the way to implement this more simpler than above.

I think your original idea below is the simplest because there is no other specific JWT type than ID-JAG so far. So, I have just modified the code based on your suggestion:

I know the issue is related to the relationship with AbstractBaseJWTValidator, but as things are getting complicated, maybe you could check if it would be simpler to just extend the original JWTAuthorizationGrantValidator as IDJWTAuthorizationGrantValidator and override only the methods needed to validate the ID-JAG and use it if needed in the JWTAuthorizationGrantType. WDYT?

jwtTokenType = OAuth2Constants.JWT;
}

JWTAuthorizationGrantValidatorBase authorizationGrantContext =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Consider removing JWTAuthorizationGrantValidatorBase it seems to exist only as an adapter between AbstractBaseJWTValidator and the SPI interfaces, you could extend AbstractBaseJWTValidator directly in DefaultJWTAuthorizationGrantValidator and implement the interface methods themselves, simplifying the
hierarchy which is already a bit complex. Then IDJWTAuthorizationGrantValidator could directly extend DefaultJWTAuthorizationGrantValidator. And in this line you could get the provider directly as interface without casting to an implementation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1 that hierarchy is a bit complex. If there is possibility to simplify a bit as suggested above, it can be nice.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@graziang and @mposolda

Thank you for your comment and suggestion.

I totally agree with you for making the hierarchy more simple.
I have just tried removing JWTAuthorizationGrantValidatorBase in my local repository.
It looks ok , but 
for the part

And in this line you could get the provider directly as interface without casting to an implementation.

we can make the line your mentioned like below

            JWTAuthorizationGrantValidator authorizationGrantContext = 
                             session.getProvider(JWTAuthorizationGrantValidator.class, jwtTokenType);

without casting.

But some other lines needs some casting like below:

UserModel user = lookupUserByFederatedIdentity(federatedIdentityModel, ((DefaultJWTAuthorizationGrantValidator) authorizationGrantContext).getState());
((DefaultJWTAuthorizationGrantValidator) authorizationGrantContext).setScopeParam(formParams.getFirst(OAuth2Constants.SCOPE));

As you can imagine, that is because those methods are originally from AbstractBaseJWTValidator, not in JWTAuthorizationGrantValidator interface.

Do I understand correctly?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@graziang

Thank you for your feedback.
I have just removed JWTAuthorizationGrantValidatorBase as you suggested.

And the following is also included in the PR source code as you wanted

            JWTAuthorizationGrantValidator authorizationGrantContext = 
                             session.getProvider(JWTAuthorizationGrantValidator.class, jwtTokenType);

without any extra casting.

With some modification to how to create an instance of JWTAuthorizationGrantValidator implementation class like below:

    public DefaultJWTAuthorizationGrantValidator(KeycloakSession session) {
        this(session, 
            session.getContext().getHttpRequest().getDecodedFormParameters().getFirst(OAuth2Constants.SCOPE),
            buildClientAssertionState(session));
    }

We do not need this any more
authorizationGrantContext).setScopeParam(formParams.getFirst(OAuth2Constants.SCOPE));

And for the following:

UserModel user = lookupUserByFederatedIdentity(federatedIdentityModel, ((DefaultJWTAuthorizationGrantValidator) authorizationGrantContext).getState());
((DefaultJWTAuthorizationGrantValidator) 

I have just removed authorizationGrantContext).getState() parameter, because it is not used in lookupUserByFederatedIdentity method.

@bucchi
bucchi force-pushed the ISSUE-43971-IDJAGReceiver branch 2 times, most recently from b3f3b81 to bbf607b Compare May 26, 2026 15:21
@bucchi
bucchi commented May 27, 2026 •
Copy link
Copy Markdown
Contributor Author

Hi, @graziang

Just a quick heads-up:
I've applied the code fix as you suggested and left a comment on the thread.
#46048 (comment)

Could you please take another look?

Thanks!

@graziang graziang left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@bucchi Thanks for all the work and discussion on this PR! Good job!

@@ -164,7 +190,7 @@ public Response process(Context context) {
}
}

protected UserModel lookupUserByFederatedIdentity(FederatedIdentityModel federatedIdentityModel, ClientAssertionState clientAssertionState) {
protected UserModel lookupUserByFederatedIdentity(FederatedIdentityModel federatedIdentityModel) {
@thomasdarimont thomasdarimont May 27, 2026 •
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I was using the assertionState in a subclass to reference some claims from the assertion to narrow the client/user lookup.

Any chance to get that in again?

@mposolda mposolda left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@bucchi @graziang Thanks!

I hope to merge the PR once the last remaining comment by @thomasdarimont regarding the removed method parameter is solved. @bucchi Is it ok to add the parameter back?

@bucchi
bucchi commented May 28, 2026
Copy link
Copy Markdown
Contributor Author

Given that it is already in use, I would be more than happy to.

Give me some time, please.

…ssertion and send back access token working as as a part of token endpoint

Signed-off-by: Yutaka Obuchi <yutaka.obuchi.sd@hitachi.com>
@bucchi
bucchi force-pushed the ISSUE-43971-IDJAGReceiver branch from bbf607b to 7887708 Compare May 28, 2026 13:27
@bucchi
bucchi commented May 28, 2026
Copy link
Copy Markdown
Contributor Author

@mposolda , @thomasdarimont, @graziang

I have just modified to make the lookupUserByFederatedIdentity's parameter back.
How about that?
Let me know what you think.

@thomasdarimont
Copy link
Copy Markdown
Contributor

Thanks @bucchi that works for me again :)

@mposolda mposolda left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@bucchi @graziang @tnorimat @thomasdarimont Thanks everyone for the participation and review of this PR!

@mposolda
mposolda merged commit 1466eb4 into keycloak:main May 29, 2026
95 of 97 checks passed
@stianst stianst mentioned this pull request Jun 1, 2026
sashyo pushed a commit to tide-foundation/keycloak-IGA that referenced this pull request Jul 20, 2026
…ssertion and send back access token working as as a part of token endpoint (keycloak#46048)

closes keycloak#49005


Signed-off-by: Yutaka Obuchi <yutaka.obuchi.sd@hitachi.com>
Co-authored-by: Yutaka Obuchi <yutaka.obuchi.sd@hitachi.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ID-JAG: initial support

9 participants

0