Skip to content

Customize token exchange endpoint to issue ID-JAG - #49998

Open
bucchi wants to merge 1 commit into
keycloak:mainfrom
Hitachi:ISSUE-48818-IDJAGIssuer
Open

Customize token exchange endpoint to issue ID-JAG#49998
bucchi wants to merge 1 commit into
keycloak:mainfrom
Hitachi:ISSUE-48818-IDJAGIssuer

Conversation

@bucchi

@bucchi bucchi commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

closes #48818

This PR is for ID-JAG issuer, which receives ID Token and send back ID-JAG working as a part of token exchange 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).

(Key Points to Discuss) : "How to handle the relationship between clients"

My understanding is that Keycloak as IdP inherently manages its relationships with client applications (RPs in OIDC, SPs in SAML) on a per-client basis. In contrast, for ID-JAG, the relationship between clients is critical, which means we likely need to build a mechanism within Keycloak to manage that interaction.

As an initial commit, I propose using client attribute like below. Let me know if there is more good approach.

    In attribute of client assigned to Requesting App, 
       - "idjag.clientid.at.<client id of MCP Authorization Server>": Requesting App's client id in MCP Authorization Sever  
       - "idjag.permitted.scopes.at.<client id of MCP Authorization Server>": scope of MCP Authorization Sever permitted to Requesting App"
    In attribute of client assigned to MCP Authorization Sever, 
      - "idjag.resource.authorization.server.identifier": Issuer URL of MCP Authorization Sever, which should be equal to audience parameter in TokenExchange request of ID-JAG

@@ -0,0 +1,334 @@
/*

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.

This header comments are no longer needed.

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 have just fixed as suggested.

@@ -0,0 +1,77 @@
/*

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.

The header comments are no longer needed.

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 as suggested for here too.

@@ -0,0 +1,517 @@
/*

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.

The header comments are no longer needed.

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 as suggested for here too.

@mposolda mposolda self-assigned this Jun 16, 2026
@mposolda
mposolda requested a review from graziang June 16, 2026 08:49
@bucchi
bucchi marked this pull request as draft June 17, 2026 03:47
@JsonProperty("client_id")
protected String client_id;

public String getClient_id() {

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.

Any reason we use getClient_id here and not getClientId? The json property should handle de/serialization already.

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 have just fixed as suggested.

@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! Added some inline comments, could you please check?

}
}

public AccessTokenResponseBuilder generateIDJag() {

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.

not sure if it is used.

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.

It is used in exchangeClientToOIDCClient method.

* @author <a href="mailto:yutaka.obuchi.sd@hitachi.com">Yutaka Obuchi</a>
* @version $Revision: 1 $
*/
public class IDJAG extends AccessToken {

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 would not introduce a new type of token

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.

Hi @graziang,

Thank you for you comment.
I am rethinking about this and I have just remembered why I introduced that.

It is to set ID-JAG's token type to "oauth-id-jag+jwt" in the JWT header, which is determined by the TokenCategory during the token encoding process.
For example, in the AccessToken class, the TokenCategory is TokenCategory.ACCESS. From my understanding, this sets the token type in the JWT header to "JWT" or "at+jwt".
This is why I believe we need a new ID-JAG class with a dedicated TokenCategory to correctly set the header type to "oauth-id-jag+jwt".

Please let me know if I misunderstood anything or if you have any better ideas.

Thank you!

}

@Override
public boolean supports(TokenExchangeContext context) {

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'm not sure if introducing a new provider is needed, we need to evaluate if it is sufficient to just modify the StandardTokenExchangeProvider, otherwise the new provider should contain only the bare essentials, I see a lot of duplicate code.

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 valuable feedback.
I have thoroughly reviewed the source code and removed the duplicated and unnecessary parts to keep this provider as minimal and focused as possible.

Could you please take another look?

*/
@KeycloakIntegrationTest(config = IDJAGTokenExchangeTest.JWTAuthorizationGrantServerConfig.class)
@TestMethodOrder(MethodOrderer.MethodName.class)
public class IDJAGTokenExchangeTest {

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.

do we need all these oauth clients?, can it be simplified?

@bucchi bucchi Jul 7, 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.

Yes.

I made it simpler.

public boolean supports(TokenExchangeContext context) {

String requestedTokenType = context.getFormParams().getFirst(OAuth2Constants.REQUESTED_TOKEN_TYPE);
if (!requestedTokenType.equals(OAuth2Constants.IDENTITY_ASSERTION_JWT_TOKEN_TYPE)) {

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 think, the "requested_token_type" is an optional as per RFC 8693, and possible NPEs and breaks all token exchange when the feature is enabled. Note: this fails as per the current factory order().

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 that as suggested.

return new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Client not found for audience identifier: " + audiencePrameterString, Response.Status.BAD_REQUEST);
});

if (targetClient == null) {

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.

This block seems unreachable. Since .orElseThrow(...) already guarantees a non-null result.

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 also fixed that as suggested.

@bucchi
bucchi marked this pull request as ready for review June 25, 2026 07:09
Copilot AI balanced review requested due to automatic review settings June 25, 2026 07:09

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 adds experimental support for issuing an Identity Assertion JWT Authorization Grant (ID-JAG) from Keycloak via the OAuth 2.0 Token Exchange endpoint, including a new token representation/category and an integration test covering successful and error flows.

Changes:

  • Introduces a new token exchange provider/factory (IDJWTTokenExchangeProvider*) selected when requested_token_type=urn:ietf:params:oauth:token-type:id-jag.
  • Adds an IDJAG token representation and a new TokenCategory.IDJAG, wiring encoding/header type and response builder support.
  • Extends test utilities and adds an integration test validating ID-JAG issuance and common failure cases.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/TokenExchangeRequest.java Adds ability to override the scope parameter for token exchange test requests.
tests/base/src/test/java/org/keycloak/tests/oauth/IDJAGTokenExchangeTest.java New integration test for ID-JAG issuance via token exchange.
services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.TokenExchangeProviderFactory Registers the new token exchange provider factory.
services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java Adds ID-JAG generation support to the token response builder.
services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/AbstractTokenExchangeProvider.java Generalizes token exchange flow methods from AccessToken to JsonWebToken.
services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java Updates signatures to match the generalized token type.
services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/V1TokenExchangeProvider.java Updates signatures to match the generalized token type (with existing AccessToken usage).
services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/IDJWTTokenExchangeProviderFactory.java New factory for the ID-JAG token exchange provider.
services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/IDJWTTokenExchangeProvider.java New provider implementing ID-JAG issuance logic and client-attribute based scope/client mapping.
services/src/main/java/org/keycloak/jose/jws/DefaultTokenManager.java Adds token header type/signature selection for the new IDJAG token category.
core/src/main/java/org/keycloak/util/TokenUtil.java Adds a new TOKEN_TYPE_IDJAG constant.
core/src/main/java/org/keycloak/TokenCategory.java Adds new IDJAG token category.
core/src/main/java/org/keycloak/representations/IDJAG.java Adds new token representation extending AccessToken.
core/src/main/java/org/keycloak/OAuth2Constants.java Adds constant for the ID-JAG requested_token_type URN.

Comment thread core/src/main/java/org/keycloak/representations/IDJAG.java Outdated
Comment thread tests/base/src/test/java/org/keycloak/tests/oauth/IDJAGTokenExchangeTest.java Outdated
Comment thread tests/base/src/test/java/org/keycloak/tests/oauth/IDJAGTokenExchangeTest.java Outdated
@VinodAnandan

Copy link
Copy Markdown
Contributor

Thank you very much @bucchi for all your contributions to this effort, and especially for this PR and for taking it out of draft status.

As you can see from the reactions/comments on the issue and the PR, as well as the feedback from community members outside the core team, this is a feature that has generated significant interest across the wider community. Some contributors have even stepped forward to help with follow-up work ( #50288 ) as your colleague mentioned in Slack there is some concern that this PR may not fit within the Keycloak 26.7 release timeline while it is still under his review ( https://cloud-native.slack.com/archives/C09CGDKPTSQ/p1781859663508469?thread_ts=1771504128.722829&cid=C09CGDKPTSQ ). I understand that you and @mposolda would like to focus on driving this PR forward. At the same time, because Keycloak is a community project and this feature has attracted broader community interest, it would be very helpful if the community could better understand the current plan and expected timeline for bringing this PR to completion. It would also be great to know whether you are interested in collaborating with the wider community by engaging with the review comments and, where appropriate, accepting contributions that could help move the work forward.

I want to be clear that my intention is not to create unnecessary pressure or add to your workload. I appreciate that priorities and timelines can change. If however it looks unlikely that this PR will be ready for Keycloak 26.7, I would kindly ask that we avoid promoting the feature too broadly until its status is clearer. Historically, the Keycloak project has been careful about marketing experimental features (e.g: OID4VCI). The recent Okta press release ( https://www.okta.com/newsroom/press-releases/okta-announces-cross-app-access-partners/ ) gives the impression that Keycloak already supports XAA/ID-JAG, which is not currently the case. I think it would be beneficial to ask Okta either to remove the reference to Keycloak from the press release or to update it with a clarification that Keycloak does not currently provide complete XAA support, and that XAA may become available as an experimental or preview feature after September/October 2026.

@tnorimat tnorimat 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 Hello, I added some minor comments. Could you check them?

String errorMessage = "Permitted scopes not configured for the audience client";
event.detail(Details.REASON, errorMessage);
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.SERVER_ERROR, errorMessage, Response.Status.BAD_REQUEST);

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 think that the combination of OAuthErrorException.SERVER_ERROR and Response.Status.BAD_REQUEST is inconsistent. The inadequate client configuration in keycloak causes the error. The client request does not cause it.
Considering this point, Response.Status.INTERNAL_SERVER_ERROR seems to be appropriate.
What do you think about that?

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 as you suggested.

List<String> audienceParams = params.getAudience();
List<ClientModel> targetAudienceClients = new ArrayList<>();
if (audienceParams != null) {
if(audienceParams.size() == 0) {

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.

if(audienceParams.size() == 0)
might be
if (audienceParams.size() == 0)

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 this as suggested.

@bucchi
bucchi force-pushed the ISSUE-48818-IDJAGIssuer branch from ab908b3 to 35c0e7a Compare July 6, 2026 20:54
Copilot AI review requested due to automatic review settings July 6, 2026 21:55
@bucchi
bucchi force-pushed the ISSUE-48818-IDJAGIssuer branch from 35c0e7a to 592bb88 Compare July 6, 2026 21:55

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

Copilot reviewed 14 out of 14 changed files in this pull request and generated 9 comments.

Copilot AI review requested due to automatic review settings July 12, 2026 08:58
@bucchi
bucchi force-pushed the ISSUE-48818-IDJAGIssuer branch from 592bb88 to 2d8dd22 Compare July 12, 2026 08:58

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

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

Comment on lines +366 to +368
ClientModel targetClient = session.clients().getClientsStream(realm)
.filter(c -> audienceParameterString.equals(c.getAttribute(RESOURCE_AUTHORIZATION_SERVER_IDENTIFIER)))
.findFirst()

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 pointing out the scalability and uniqueness concerns regarding the unindexed attribute lookup.

You are absolutely right.

Since the ID-JAG / XAA feature is currently in the Experimental status, the overall specifications and implementation design are still subject to change. Modifying Keycloak's core database schema (e.g., adding index/uniqueness constraints) at this early stage could make future design pivots much more difficult to manage.

Therefore, we would prefer to keep this lightweight attribute lookup for now. Once the implementation design and specification details of this feature are fully consolidated and stabilized, we absolutely plan to address this database schema enhancement, index optimization, and uniqueness enforcement as we transition the feature to Preview and eventually to GA.

Copilot AI review requested due to automatic review settings July 16, 2026 11:57
@bucchi
bucchi force-pushed the ISSUE-48818-IDJAGIssuer branch from 2d8dd22 to 7d8a65c Compare July 16, 2026 11:57

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

Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.

Comment thread services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java
@lanasalameh1

Copy link
Copy Markdown

Hi @bucchi - this is super cool feature!! looking forward to it!
when do we expect to merge it ?

Copilot AI review requested due to automatic review settings August 1, 2026 12:12
@bucchi
bucchi force-pushed the ISSUE-48818-IDJAGIssuer branch from 7d8a65c to 51856ad Compare August 1, 2026 12:12

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

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

Suppressed comments (4)

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/IDJWTTokenExchangeProvider.java:150

  • This applies revocation policy to the authenticated requester rather than the client that issued the ID token (token.getIssuedFor()). Since inherited audience validation permits a different requester when it is listed in a multi-audience token, an ID token from a disabled, deleted, or revoke-before issuing client can still mint an ID-JAG; resolve and validate the token-holder client (or require it to equal client) first.
        if (client.getNotBefore() > notBefore) {
            notBefore = client.getNotBefore();
        }

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/IDJWTTokenExchangeProvider.java:228

  • A client can trigger this WARN on every otherwise successful exchange by appending an unpermitted scope, allowing routine input to flood operational logs. Log filtered client input at debug level instead.
                logger.warn("Requested scope [" + requested + "] is not permitted and was filtered out.");

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/IDJWTTokenExchangeProvider.java:66

  • The public ID-JAG guide still states that issuer support is not implemented and that issuer configuration is unavailable (docs/guides/securing-apps/identity-assertion-jwt-authorization-grant.adoc:31-35,52-70). Update it to document this provider and its three required client attributes; otherwise administrators cannot reliably configure the new feature.
    // client attribute used to find the resource authorization server client by the audience parameter
    private static final String RESOURCE_AUTHORIZATION_SERVER_IDENTIFIER = "idjag.resource.authorization.server.identifier";

    // client attribute prefix for the client's client_id in resource authorization server
    private static final String CLIENTID_IN_RESOURCE_AUTHORIZATION_SERVER = "idjag.clientid.at.";

    // client attribute prefix for the permitted scopes in the resource authorization server
    private static final String PERMITTED_SCOPES_IN_RESOURCE_AUTHORIZATION_SERVER = "idjag.permitted.scopes.at.";

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/IDJWTTokenExchangeProvider.java:387

  • The new repeated-audience rejection branch is not exercised by the added integration tests; the wrong-audience test sends only one value. Add a request with two .audience(...) calls and assert invalid_request so multiple audiences cannot regress to silent truncation.
            } else if (audienceParams.size() > 1) {
                event.detail(Details.REASON, "Multiple audiences are not supported");
                event.error(Errors.INVALID_REQUEST);

                throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, 
                    "The ID-JAG token exchange provider does not support multiple audiences. Please request one audience at a time.", 
                    Response.Status.BAD_REQUEST);

Copilot AI review requested due to automatic review settings August 1, 2026 18:48
@bucchi
bucchi force-pushed the ISSUE-48818-IDJAGIssuer branch from 51856ad to d5be6fa Compare August 1, 2026 18:48

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

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

Suppressed comments (3)

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/IDJWTTokenExchangeProvider.java:174

  • This validates the requester’s client session, not the client identified by the signed ID token’s azp (token.getIssuedFor()). For a cross-client token whose audience includes the requester, the token can remain exchangeable after its issuing client is disabled, revoked, or removed; resolve the issuing client and apply its enabled/not-before/client-session checks, while keeping the separate requester-audience validation.
        AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId());
        if (clientSession == null || !AuthenticationManager.isClientSessionValid(realm, client, userSession, clientSession)) {
            event.detail(Details.REASON, "Client session not found or revoked");
            event.error(Errors.INVALID_TOKEN);
            throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_TOKEN, "Client session not found or revoked", Response.Status.BAD_REQUEST);

services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java:1351

  • The mapper context here belongs to the requesting client, so AbstractPairwiseSubMapper derives sub from that client’s sector rather than the ID-JAG audience. A requester exchanging for multiple authorization servers will therefore disclose the same pairwise subject to each; subject derivation needs an audience-specific mapper context or sector.
            idjag = transformIDJag(session, idjag, userSession, clientSessionCtx);

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/IDJWTTokenExchangeProvider.java:236

  • A client can generate one WARN entry per unpermitted scope while the request is otherwise valid, allowing routine input to flood operational logs. Keep the event/error response for auditing and log filtered user input at debug level.
                logger.warn("Requested scope [" + requested + "] is not permitted and was filtered out.");

Copilot AI review requested due to automatic review settings August 1, 2026 21:03
@bucchi
bucchi force-pushed the ISSUE-48818-IDJAGIssuer branch from d5be6fa to 073070a Compare August 1, 2026 21:03

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

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (3)

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/IDJWTTokenExchangeProvider.java:150

  • This applies the requesting client's revocation policy to the subject ID token instead of the client in token.getIssuedFor(). Since the inherited audience check permits a different token holder when the requester is in aud, an ID token whose issuing client was disabled, revoked, or detached from the session can still be exchanged; resolve and validate the subject-token client and use it for both not-before and client-session checks.
        if (client.getNotBefore() > notBefore) {
            notBefore = client.getNotBefore();
        }

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/IDJWTTokenExchangeProvider.java:80

  • For an ID-JAG request from a client without standard token exchange enabled, returning false lets lower-priority providers overwrite this reason (the standard provider will report that the ID-token subject is unsupported). Once the ID-JAG token type selects this provider, claim the request and reject the disabled setting from tokenExchange() so clients receive the actual configuration error.
        if(!OIDCAdvancedConfigWrapper.fromClientModel(context.getClient()).isStandardTokenExchangeEnabled()) {
            context.setUnsupportedReason("Standard token exchange is not enabled for the requested client");
            return false;
        }

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/IDJWTTokenExchangeProvider.java:55

  • This adds the issuer implementation, but the existing ID-JAG guide still states that issuer support and issuer configuration are unavailable (docs/guides/securing-apps/identity-assertion-jwt-authorization-grant.adoc:31-65). Update that guide with the feature flag, token-exchange request, and the three required client attributes; otherwise users cannot configure this new provider from the product documentation.
public class IDJWTTokenExchangeProvider extends StandardTokenExchangeProvider {

Copilot AI review requested due to automatic review settings August 3, 2026 16:14
@bucchi
bucchi force-pushed the ISSUE-48818-IDJAGIssuer branch from 073070a to 889a974 Compare August 3, 2026 16:14

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

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/IDJWTTokenExchangeProvider.java:150

  • The revocation checks are applied to the requesting client, not to the client that issued the subject ID token (token.getIssuedFor()). If both clients share an SSO session and the ID token includes the requester in aud, disabling/revoking the token-holder client still leaves that token exchangeable; resolve the token-holder client and apply its enabled/not-before/client-session checks before proceeding.
        if (client.getNotBefore() > notBefore) {
            notBefore = client.getNotBefore();

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/IDJWTTokenExchangeProvider.java:123

  • A syntactically valid JWT with a missing alg header makes getAlgorithm() return null, so .name() throws an uncaught NullPointerException and returns a 500 instead of invalid_token. Validate the header algorithm before dereferencing it so malformed client input follows the existing verification-error path.
            String kid = verifier.getHeader().getKeyId();
            String algorithm = verifier.getHeader().getAlgorithm().name();
            SignatureProvider signatureProvider = session.getProvider(SignatureProvider.class, algorithm);

Copilot AI review requested due to automatic review settings August 3, 2026 17:02
@bucchi
bucchi force-pushed the ISSUE-48818-IDJAGIssuer branch from 889a974 to e1943fe Compare August 3, 2026 17:02

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

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/IDJWTTokenExchangeProvider.java:179

  • This validates the requesting client's session, not the client identified by the ID token's azp. Because inherited validateAudience also permits a different requester that appears in the token audience, such a requester can still exchange the token after the token-holder client is disabled, removed, revoked, or restarted; resolve token.getIssuedFor() and apply enabled, not-before, client-session, and session-start checks to that client/session.
        AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId());
        if (clientSession == null || !AuthenticationManager.isClientSessionValid(realm, client, userSession, clientSession)) {

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/IDJWTTokenExchangeProvider.java:244

  • An authenticated client can generate a WARN entry for every unpermitted scope while the exchange still succeeds if any requested scope is valid. Invalid request input should not flood operational logs; log this at debug level or reject the request once with invalid_scope.
                logger.warn("Requested scope [" + requested + "] is not permitted and was filtered out.");

…d sends back ID-JAG assertion working as a tokenexchange endpoint

Signed-off-by: Yutaka Obuchi <yutaka.obuchi.sd@hitachi.com>
Copilot AI review requested due to automatic review settings August 5, 2026 02:52
@bucchi
bucchi force-pushed the ISSUE-48818-IDJAGIssuer branch from e1943fe to 6836bfd Compare August 5, 2026 02:52

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

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (6)

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/IDJWTTokenExchangeProvider.java:118

  • Checking only actor_token still accepts actor_token_type by itself and silently ignores it. Since this provider does not support delegation, either actor parameter must make the ID-JAG request fail.
        if (context.getParams().getActorToken() != null) {
            event.detail(Details.REASON, "Actor tokens are not supported for ID-JAG token exchange");
            event.error(Errors.INVALID_REQUEST);
            throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
                "Actor tokens are not supported for ID-JAG token exchange", Response.Status.BAD_REQUEST);

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/IDJWTTokenExchangeProvider.java:164

  • The revocation and session checks are applied to the authenticated requester client, not to the client identified by the subject ID token's issuedFor. Inherited audience validation permits cross-client exchange when the requester is in aud, so disabling/revoking or setting not-before on the token-holder client does not invalidate that ID token; resolve and validate the token-holder client/session separately.
        int notBefore = realm.getNotBefore();

        if (client.getNotBefore() > notBefore) {
            notBefore = client.getNotBefore();
        }

services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java:1351

  • This mapper context belongs to the requesting application, so AbstractPairwiseSubMapper derives sub from the requester's sector rather than the resource authorization server that is placed in aud. The same target server can therefore receive different subjects for one user depending on which requesting app obtained the ID-JAG; generate the subject using the resolved audience client's sector/context.
            idjag = transformIDJag(session, idjag, userSession, clientSessionCtx);

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/IDJWTTokenExchangeProvider.java:75

  • Overriding StandardTokenExchangeProvider.supports() drops its guards for requested_subject, requested_issuer, and subject_issuer. An ID-JAG request containing any of those parameters is therefore accepted and the requested impersonation/issuer semantics are silently ignored; reject these unsupported parameters before claiming the request.

This issue also appears on line 114 of the same file.

        String requestedTokenType = context.getFormParams().getFirst(OAuth2Constants.REQUESTED_TOKEN_TYPE);
        if (!OAuth2Constants.IDENTITY_ASSERTION_JWT_TOKEN_TYPE.equals(requestedTokenType)) {
            context.setUnsupportedReason("Parameter 'requested_token_type' should be 'urn:ietf:params:oauth:token-type:id-jag' for IDJWT token exchange");
            return false;

services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java:1363

  • OIDCIDTokenMapper is allowed to return an IDToken, so a custom mapper that returns a replacement token causes this cast to throw ClassCastException and turns ID-JAG issuance into a 500. Avoid relying on the mapper preserving the IDJAG runtime subtype, or introduce an ID-JAG-specific mapping contract.
                        return (IDJAG) ((OIDCIDTokenMapper) mapper.getValue()).transformIDToken(token, mapper.getKey(), session, userSession, clientSessionCtx);

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/IDJWTTokenExchangeProvider.java:250

  • Each client-controlled, unpermitted scope is logged at WARN even though the request can still succeed with the remaining scopes. An authenticated client can submit many such values per request and flood operational logs; keep this at debug level or aggregate it once.
                logger.warn("Requested scope [" + requested + "] is not permitted and was filtered out.");

@bucchi

bucchi commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

@graziang , @mposolda
I have pushed the latest commits and addressed all the feedback, including the one from CopilotAI.
Could you please take a look when you have a moment?

}

AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId());
if (clientSession == null || !AuthenticationManager.isClientSessionValid(realm, client, userSession, clientSession)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

An ID-token exchange surely needs an explicit session/revocation check that access-token exchange gets for free from verifyIdentityToken.

But standard exchange validates the subject token's client session (token.getIssuedFor()), while this checks the requesting client's (client.getId()), which rejects a client that's legitimately in the audience but wasn't the session participant (the relayed-token case the audience match otherwise permits).

Is binding to the requesting client's session intended here rather than the subject token's?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Experimental Support issuing Identity Assertion JWT Authorization Grant (ID-JAG)