8000 Customize token exchange endpoint to issue ID-JAG by bucchi · Pull Request #49998 · keycloak/keycloak · GitHub
[go: up one dir, main page]

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

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 adds an ID-JAG issuer to the token exchange endpoint: it receives an ID token and sends back an ID-JAG.
ID-JAG is a type of JWT that can be used as an authorization grant (https://www.rfc-editor.org/rfc/rfc7523.html#section-2.1).

Design overview

  • Resource Authorization Server as a client.
    The Resource AS is registered as a client of the realm. The audience parameter must be the clientId of that client, so to meet the draft (aud = issuer of the Resource AS), the client must be named with the issuer URL of the Resource AS. Exactly one audience parameter is accepted.
  • Claims come from client scopes and protocol mappers.
    client_id comes from a Hardcoded Claim mapper (JSON type String), and the granted scopes from client scopes. The ID-JAG is built from the evaluated access token, copying only an allowlisted set of claims (iss, sub, aud, iat, exp, jti, scope, auth_time, acr, client_id, amr), plus the Keycloak typ claim (IDJAG). The JWT header type is oauth-id-jag+jwt.
  • Shared token exchange pipeline.
    The ID-JAG provider reuses the standard token exchange flow, including the audience check, transient session handling and the TokenExchangeResponseContext client policies. It only overrides the subject token verification and the response encoding.
  • Subject token.
    Only ID tokens are accepted. The ID token must be bound to an online user session that is not being logged out: ID tokens without sid (transient sessions) and ID tokens bound only to an offline session are rejected.
  • Not supported yet.
    actor_token and resource are rejected.

Administrator responsibilities

  • sub
    The requesting client and the Resource AS must use the same subject identifier: either no pairwise identifiers, or a pairwise mapper in a default client scope with the same sector for all clients that take part in ID-JAG.
  • Lightweight access tokens
    The mappers producing sub and client_id need "Add to lightweight access token" enabled when the requesting client uses lightweight access tokens.
  • Signing algorithm
    The ID-JAG is signed with the ID token signature algorithm of the requesting client; it must be asymmetric.
  • jwt-claim-enforcer
    This executor only supports access tokens as the subject token for token exchange, so it rejects ID-JAG exchanges for the clients it applies to. It should not be applied to clients that request ID-JAGs.

@@ -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?

@@ -1401,6 +1407,29 @@ private void generateRefreshToken(boolean offlineTokenRequested) {
}
}

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!

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.

Still not convinced here, an ID-JAG is not an AccessToken the inheritance is only for reuse, also, the only purpose of TokenCategory.IDJAG is to have the typ: oauth-id-jag+jwt header. Consider an alternative way to set the header without introducing a new TokenCategory if it is possible.

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 the review, @graziang.

As an alternative, I am thinking of using the base JsonWebToken directly and signing it by explicitly calling the sign method of JWSBuilder within the provider. What do you think about this?

As you are well aware, from my understanding, by doing this, we can explicitly set the custom typ header (oauth-id-jag+jwt) via JWSBuilder.type(...) without needing a new TokenCategory. It also prevents AccessToken-specific noise from being included, allowing us to cleanly construct the exact claims required for ID-JAG.

This approach would completely eliminate the need for the IDJAG class and keep Keycloak's core data model untouched.

I would love to hear your thoughts on this alternative approach!

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! Sounds good to use JWSBuilder. UserInfoEndpoint is a good reference, also CIBAAuthenticationRequest.

@bucchi bucchi Sep 14, 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 just fixed as your suggestion with removing client attribute/IDJAG.java

}

@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?

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.

ok to keep the new provider but I think there are still many duplicate parts. To be checked if getTargetAudienceClients() getRequestedScope() can be reused from StandardTokenExchangeProvider if we remove the idjag.* attributes from the client. I think we can also do some refactoring between StandardTokenExchangeProvider and this provider for example in tokenExchange() and exchangeClientToOIDCClient() there are many duplicate parts that could be extracted. Ideally IDJWTTokenExchangeProvider should have only the missing parts that are different from StandardTokenExchangeProvider.

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 the suggestion. I have refactored StandardTokenExchangeProvider's exchangeClientToOIDCClient() and also updated IDJWTTokenExchangeProvider's tokenExchange() to call the existing functions to reduce duplication.

*/
@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.

@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.

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.

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.

Copilot review overview

🟡 Changes recommended

Critical cross-client and audience failures prevent successful ID-JAG issuance, with additional validation, policy, audit, and test issues unresolved.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 3 High severity

Open (3)
Resolved since last review (3)

@bucchi
bucchi commented Sep 24, 2026
Copy link
Copy Markdown
Contributor Author

@graziang , @mposolda
I have pushed the latest commits and addressed all the feedback.
Could you please take a look?

@mposolda
mposolda commented Sep 29, 2026 •
Copy link
Copy Markdown
Contributor

@bucchi Thanks! I did not check the details of ID-JAG , but from quickly checking the PR, it seems that at least IDJWTTokenExchangeProvider has still big amount of duplicated logic with StandardTokenExchangeProvider . Is it possible to somehow update to make sure that IDJWTTokenExchangeProvider uses just bare minimum, which really differs?

As duplicated code is source of issues and bugs in the future (EG. the case when some future bug will be fixed just in the StandardTokenExchangeProvider, but not in the ID-JAG provider etc...)

To achieve that, maybe it is possible to use some pattern like "Template method" or something similar to make sure duplications are avoided? Maybe using common super-class for StandardTokenExchangeProvider and IDJWTTokenExchangeProvider ?

For the inspiration, you can possibly take a look at AbstractJWTClientValidator and it's subclasses.

Also, it would be good if the PR is reviewed and approved by someone from community with the knowledge of ID-JAG. Right now, it was not approved by anyone yet. Especially review/approve from @tnorimat would be nice (currently it is marked as "requesting change").

@bucchi
bucchi commented Sep 29, 2026 •
Copy link
Copy Markdown
Contributor Author

Thanks for the feedback, @mposolda.

I agree that reducing duplication is important. I think helper methods like getTargetAudienceClients() or getRequestedTokenType() could be refactored more. However, I suspect those may not be the main areas you’re concerned about.
Could you clarify which specific parts you are most concerned about? Are you mainly looking at tokenExchange() or buildTokenExchangeResponse()?

@tnorimat, could you please also point out any specific duplicated code in those methods that you think could be refactored?

As a side note, to keep the changes minimal for now, I’m considering keeping the existing inheritance structure and adding narrowly scoped hooks to StandardTokenExchangeProvider for the ID-JAG-specific behavior. IDJWTTokenExchangeProvider would then override only the parts that differ. Do you think this would be a reasonable approach?

@tnorimat
Copy link
Copy Markdown
Contributor

@mposolda @bucchi Hello, yes, I will re-review the PR.

@tnorimat
tnorimat commented Oct 6, 2026
Copy link
Copy Markdown
Contributor

@bucchi Hello, I used Claude Opus 4.8 to find duplicated codes. The result is as follows.

IDJWTTokenExchangeProvider, StandardTokenExchangeProvider and AbstractTokenExchangeProvider:

Duplication 1 — tokenExchange() reimplements the base flow

The base tokenExchange() is only two lines that call two already-protected seams:

AuthenticationManager.AuthResult authResult = processSubjectToken();
return exchangeClientToClient(authResult.user(), authResult.session(), authResult.token(), true);

The ID-JAG override is ~100 lines. Genuinely different: (a) subject_token_type must be ID_TOKEN_TYPE, (b) reject actor_token, (c) verify the subject as an IDToken. Everything else is duplicated: the user-session validity check, user.isEnabled(), client-session lookup/validity, the NotBeforeCheck revocation check, the isIssuedBeforeSessionStart replay guard, and the event population (event.user, Details.USERNAME, event.session, Details.SUBJECT_TOKEN_CLIENT_ID — copied verbatim from base processSubjectToken()).

Resolve: delete the tokenExchange() override; override only processSubjectToken(). The PR already widened exchangeClientToClient/exchangeClientToOIDCClient/validateAudience to JsonWebToken, so an IDToken flows through the base path. Put just the ID-token verification (a–c) in the override; if the manual session/revocation/replay checks must stay, extract them into one protected helper both providers call instead of copying.

Duplication 2 — buildTokenExchangeResponse() (largest, and it hides two bugs)

The base version (extracted in this PR) runs the shared pipeline:

var responseBuilder = tokenManager.responseBuilder(...).generateAccessToken();
checkRequestedAudiences(responseBuilder);
// transient-session handling
// session.clientPolicy().triggerOnEvent(new TokenExchangeResponseContext(...));

The ID-JAG override hand-rolls the token and re-computes what the pipeline already yields:

  • the whole lifespan/expiration block (realm.getAccessTokenLifespan(), parsing the client ACCESS_TOKEN_LIFESPAN attribute, SessionExpirationUtils.calculateClientSessionMaxLifespanTimestamp(...), Math.min) → duplicates TokenManager.initToken()/AccessTokenResponseBuilder;
  • the issuer resolution (clientSession.getNote(ISSUER) else Urls.realmIssuer(...)) → duplicates initToken;
  • manual copying of otherClaims/scope/aud.

It also drops the transient-session handling and the TokenExchangeResponseContext client-policy trigger — that omission is precisely the "ID-JAG bypasses token exchange response policies" finding, i.e. the exact drift mposolda warned about.

Resolve: route ID-JAG through tokenManager.responseBuilder(...).generateAccessToken() too, so exp/iss/aud/scope/client_id/mapped-claims, checkRequestedAudiences, transient handling, and the policy trigger all come from one place. Since AccessToken extends IDToken extends JsonWebToken, sign that token directly:

String encoded = new JWSBuilder()
    .type(OAuth2Constants.IDENTITY_ASSERTION_JWT_HEADER_TYPE) // oauth-id-jag+jwt
    .jsonContent(responseBuilder.getAccessToken())
    .sign(signer);

Concretely (the Template Method mposolda asked for): let the base own buildTokenExchangeResponse() and add one overridable seam, e.g. protected Response encodeExchangeResponse(AccessTokenResponseBuilder rb, String requestedTokenType), that IDJWTTokenExchangeProvider overrides for the id-jag typ header / token_type=N/A / issued_token_type. ~90 lines collapse to a few, and the expiration/issuer duplication and policy-bypass both disappear.

Duplication 3 — getRequestedTokenType() near-verbatim copy

Identical to the base except IDENTITY_ASSERTION_JWT_TOKEN_TYPE is added to the accepted set (and the error string still says JWTTokenExchangeProvider).

Resolve: add an overridable seam for the extra accepted type (e.g. getAdditionalSupportedRequestedTokenTypes()), or handle the id-jag case and then return super.getRequestedTokenType();.

Duplication 4 — getTargetAudienceClients() reimplemented

The only real difference from the base loop is "exactly one audience required".

Resolve: reuse the base and add a small validateSingleAudience(...) hook instead of duplicating the client lookup and error handling (graziang flagged this too).


Net result: after these four, IDJWTTokenExchangeProvider keeps only what truly differs — the supports() gate, ID-token verification in processSubjectToken(), the single-audience constraint, the extra requested-token-type, and the final JWSBuilder encoding — which is the "bare minimum" mposolda/graziang want, and it fixes the policy-bypass and expiration-calculation findings for free.

Want me to prototype the refactor (extract the encodeExchangeResponse seam in StandardTokenExchangeProvider + override processSubjectToken in IDJWTTokenExchangeProvider) so you have a concrete diff to attach to the PR? diff to attach to the PR?

@bucchi
bucchi commented Oct 7, 2026 •
Copy link
Copy Markdown
Contributor Author

Hi @tnorimat,

Thank you very much for the comprehensive review and for organizing the feedback into actionable points.

Regarding the 4 duplication points you raised, I would like to address "Duplication 1 — tokenExchange() reimplements the base flow" first.


First, I want to clarify that simply moving the logic from tokenExchange() to processSubjectToken() within IDJWTTokenExchangeProvider does not actually eliminate any code duplication.

From my understanding the checks you listed (user-session validity, user.isEnabled(), NotBeforeCheck, isIssuedBeforeSessionStart, and event population) are not written directly in the class body of StandardTokenExchangeProvider. Instead, the base provider delegates everything to a single wrapper:

AuthenticationManager.AuthResult authResult = AuthenticationManager.verifyIdentityToken(
    session, realm, session.getContext().getUri(), clientConnection, 
    true, true, null, false, subjectToken, context.getHeaders(), verifier -> {}
);

Since the base provider relies entirely on verifyIdentityToken(), which is strictly hardcoded to verify an AccessToken (expecting typ: Bearer), we cannot pass an IDToken through this method without it failing token-type and deserialization checks.

Therefore, to make the ID-JAG token exchange work with an IDToken as the subject, I had to unpack and decompose the internal checks executed inside verifyIdentityToken() and manually convert them to be compatible with IDToken structure directly within IDJWTTokenExchangeProvider.

Since the base provider relies on that monolithic 1-line wrapper and has no manual validation checks in its own class body to extract, simply shifting our explicit code to processSubjectToken() keeps the duplicate code size exactly the same.

However, while drafting this response, a potential alternative came to mind:

Were you suggesting that we try calling the standard verifyIdentityToken() with the checkTokenType parameter set to false to force it to accept and process the IDToken?

If that was your underlying intent, I would be happy to investigate whether we can bypass the strict access token constraints of verifyIdentityToken() directly. This would allow us to reuse the base flow and truly eliminate the duplicate verification checks.

Please let me know if this is the direction you had in mind, and I will follow up on the remaining duplication points (Duplication 2–4) shortly!

@bucchi
bucchi commented Oct 13, 2026 •
Copy link
Copy Markdown
Contributor Author

Hi @tnorimat,

Thank you for the detailed feedback on "Duplication 2". I would like to explain the design rationale behind our current implementation, and propose a highly simplified solution that combines both of our ideas.

1. Why we chose our current approach

When designing the ID-JAG assertion, we carefully evaluated three classes to represent it: AccessToken, IDToken, and a clean JsonWebToken.

We intentionally chose JsonWebToken with a whitelist-based minimum manual claim copy to strictly prevent information leakage. An ID-JAG is an identity assertion targeted for an external SaaS/AS. If we issue AccessTokenResponseBuilder's generated token directly as-is, it inherently includes standard internal Keycloak claims like realm_access (realm roles), resource_access (client roles), and other sensitive user profile mappings. Exposing internal enterprise authorization structures to an external audience presents significant privacy and security concerns.

For this reason, we avoided using the standard builder directly and constructed the assertion explicitly on a clean JsonWebToken.


2. Proposed Solution (The Simplified Hybrid Approach)

However, I completely agree with your point regarding Keycloak core consistency—routing through AccessTokenResponseBuilder to centrally unify lifespan/expiration calculations, transient handling, and response policy execution is much cleaner.

To achieve both strict security/leakage prevention and zero code duplication without introducing a brand-new method, I propose we refactor the existing buildTokenExchangeResponse() signature in the base class to directly accept AccessTokenResponseBuilder as a parameter:

// In the base StandardTokenExchangeProvider class:
protected Response buildTokenExchangeResponse(
    org.keycloak.protocol.oidc.TokenManager.AccessTokenResponseBuilder responseBuilder, 
    String requestedTokenType, 
    List<ClientModel> targetAudienceClients
) {
    // Base implementation builds standard OIDC response...
    AccessTokenResponse res = responseBuilder.build();
    res.setOtherClaims(OAuth2Constants.ISSUED_TOKEN_TYPE, requestedTokenType);
    event.success();
    return cors.add(Response.ok(res, MediaType.APPLICATION_JSON_TYPE));
}

Then, in our IDJWTTokenExchangeProvider, we can simply override this buildTokenExchangeResponse() method, extract the fully-evaluated token, perform our secure whitelist manual copy into a clean JsonWebToken, sign it, and return the customized response:

@Override
protected Response buildTokenExchangeResponse(
    org.keycloak.protocol.oidc.TokenManager.AccessTokenResponseBuilder responseBuilder, 
    String requestedTokenType, 
    List<ClientModel> targetAudienceClients
) {
    // 1. Let the base provider/TokenManager centrally resolve expirations, policies, and mappers
    AccessToken transformedToken = responseBuilder.getAccessToken();

    // 2. Safely copy ONLY necessary evaluated claims (Whitelist approach)
    JsonWebToken idjag = new JsonWebToken();
    idjag.id(transformedToken.getId());
    idjag.issuer(transformedToken.getIssuer());

    idjag.issuedAt(transformedToken.getIssuedAt());

    // 3. Extract dynamically evaluated claims from the standard pipeline
    if (transformedToken.getOtherClaims() != null) {

    }

    // 4. Sign only the clean 'idjag' assertion
    String encodedToken = new JWSBuilder()
            .type(OAuth2Constants.IDENTITY_ASSERTION_JWT_HEADER_TYPE) // oauth-id-jag+jwt
            .jsonContent(idjag)
            .sign(signer);


    // 5. Build AccessTokenResponse with token_type = "N_A" and return
}

What do you think about this proposed approach?
If you think this is a cleaner path forward, I would be happy to proceed with this refactoring immediately!

@bucchi
bucchi commented Oct 13, 2026
Copy link
Copy Markdown
Contributor Author

@tnorimat

Regarding Duplication 3 & Duplication 4:

I agree with your suggestions for both points, and I will refactor them accordingly to fully eliminate these duplications.

@tnorimat
Copy link
Copy Markdown
Contributor

@bucchi Hello,

Thank you for considering Duplication 1.
#49998 (comment)

If your codes in tokenExchange() duplicates AuthenticationManager.verifyIdentityToken() and there is proper augments to the method to execute duplicated part of your overridden tokenExchange(), it might be good.

If not, I think it is OK to keep the current overriden tokenExchange().

@tnorimat
Copy link
Copy Markdown
Contributor

@bucchi Hello,

Thank you for considering Duplication 2.
#49998 (comment)

Your proposal (The Simplified Hybrid Approach) makes sense to me.

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.

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.

🟡 Changes recommended

Offline-session fallback can bypass logout protection, and non-string client identifiers can produce unusable grants.

2 open issues
4 resolved since last review

Review effort: Balanced

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.

🟡 Changes recommended

The offline-session regression test leaves the online session active, invalidating its rejection assertions.

1 open issue
2 resolved since last review

Review effort: Balanced

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.

🔵 Needs a closer look

Security-sensitive grant issuance needs maintainer review, with unresolved subject-preservation and certificate-enforcement gaps.

2 open issues
1 resolved since last review

Review effort: Balanced

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.

🟡 Changes recommended

Resource restrictions and target-specific subject mapping remain unresolved.

2 open issues
2 resolved since last review

Review effort: Balanced

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.

🟡 Changes recommended

A new integration test expects an error response that does not match the provider’s session validation.

1 open issue
2 resolved since last review

Review effort: Balanced

@bucchi
bucchi commented Oct 21, 2026
Copy link
Copy Markdown
Contributor Author

@bucchi Hello,

Thank you for considering Duplication 2. #49998 (comment)

Your proposal (The Simplified Hybrid Approach) makes sense to me.

Hi @tnorimat,
Thank you. I implemented it: StandardTokenExchangeProvider now runs the shared pipeline (access token generation, audience check, transient session handling and the client policy trigger) in a private method, and buildTokenExchangeResponse() only builds the response from the evaluated builder. The ID-JAG provider overrides only buildTokenExchangeResponse(): it copies an allowlisted set of claims into a clean JsonWebToken and signs it.

@bucchi
bucchi commented Oct 21, 2026
Copy link
Copy Markdown
Contributor Author

@bucchi Hello,

Thank you for considering Duplication 1. #49998 (comment)

If your codes in tokenExchange() duplicates AuthenticationManager.verifyIdentityToken() and there is proper augments to the method to execute duplicated part of your overridden tokenExchange(), it might be good.

If not, I think it is OK to keep the current overriden tokenExchange().

Hi @tnorimat,
Thank you. I found that verifyIdentityToken() can be reused with checkTokenType=false and an ID token type check, so I replaced the duplicated checks with it.
I also added two hooks to StandardTokenExchangeProvider (getSupportedSubjectTokenType() and verifySubjectToken()). The ID-JAG provider now overrides only these, and only the ID-JAG specific session checks remain in it. tokenExchange() just validates the request parameters and calls super.tokenExchange().

@bucchi
bucchi commented Oct 21, 2026
Copy link
Copy Markdown
Contributor Author

@tnorimat

Regarding Duplication 3 & Duplication 4:

I agree with your suggestions for both points, and I will refactor them accordingly to fully eliminate these duplications.

@tnorimat
I’ve refactored Duplication 3 and 4:

Duplication 3: getRequestedTokenType() now just returns OAuth2Constants.IDENTITY_ASSERTION_JWT_TOKEN_TYPE, since supports() already restricts requested_token_type to id-jag.
Duplication 4: I removed the getTargetAudienceClients() override, so the base client lookup is reused. The single-audience check is done in validateRequestParams(), together with the other ID-JAG specific request checks. An unknown audience now returns invalid_client (“Audience not found”), the same as standard token exchange.

@tnorimat
Copy link
Copy Markdown
Contributor

@bucchi Hello, I conformed that you resolved my review comments. Thank you.

I'd make approval conditional on item 1 (revert the unneeded JsonWebToken widening) and item 2 (update the description). Item 3, at least a client-policy test, would be good to have.

  1. The widening from AccessToken to JsonWebToken is now dead code. verifyIdentityToken parses the ID token as AccessToken.class, so authResult.token() is always an AccessToken. Nothing passes anything else to exchangeClientToClient, validateAudience, getRequestedScope or exchangeClientToOIDCClient. These changes can be reverted:

    • The signature changes in AbstractTokenExchangeProvider and V1TokenExchangeProvider.
    • The instanceof/cast branches in V1. Its new else branch is unreachable, and it throws without calling event.error().
    • The new checkRequestedAudiences(JsonWebToken) overload.
    • The whitespace-only change around line 553.

    Reverting them would remove most of the risk this PR puts on V1 token exchange.

  2. The audience meaning changed and the PR description is out of date. With the base lookup reused, audience must now equal the clientId of a Keycloak client. The idjag.resource.authorization.server.identifier attribute is gone. So does idjag.clientid.at.*/idjag.permitted.scopes.at.*: client_id now comes from a Hardcoded Claim mapper and scopes from client scopes. This matches the draft (aud = the issuer of the Resource AS) only if admins name the client with that issuer URL. The description's "Key Points to Discuss" section still describes the old attribute design, so ask the author to update it.

  3. Test gaps. IDJAGTokenExchangeTest has no test for:

    • a client policy rejecting the ID-JAG exchange, which is the bug Dup 2 fixed;
    • a missing sub/client_id claim, or a non-String client_id;
    • more than one audience value;
    • the resource parameter being rejected.
  4. Minor points:

    • verifySubjectToken in IDJWTTokenExchangeProvider is missing @Override.
    • The tokenExchange() override sets Details.REQUESTED_TOKEN_TYPE again; the base already sets it twice.
    • The success event's AUDIENCE detail uses params.getAudience(), while the base uses the token's aud.
    • The ID-JAG payload gets a Keycloak-specific "typ":"IDJAG" claim, which the draft doesn't define. That's harmless, but worth asking whether it's intended.
    • Several lines have trailing whitespace, and some put two statements on one line (event.detail(...); event.error(...)).

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.

🟡 Changes recommended

Unresolved identity-mapping and validation issues affect successful redemption and safe ID-JAG issuance.

3 open issues
1 resolved since last review

Review effort: Balanced

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.

🔵 Needs a closer look

Cross-server grant issuance warrants human security review, with unresolved authentication-context and client-policy integration issues.

2 open issues
3 resolved since last review

Review effort: Balanced

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

Signed-off-by: Yutaka Obuchi <yutaka.obuchi.sd@hitachi.com>
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.

🔵 Needs a closer look

Cross-authorization-server grant issuance and authentication-session handling warrant human security and profile-compliance review.

No open issues 🎉

2 resolved since last review

Review effort: Balanced

@bucchi
bucchi commented Oct 24, 2026
Copy link
Copy Markdown
Contributor Author

Hi @tnorimat,
Thank you for the review. I addressed all points:

  1. JsonWebToken widening: Reverted.
    The signatures in AbstractTokenExchangeProvider, V1TokenExchangeProvider and StandardTokenExchangeProvider are back to AccessToken, the instanceof/cast branches in V1 are removed, the checkRequestedAudiences(JsonWebToken) overload is merged back into the original method, and the whitespace-only change is reverted.

AbstractTokenExchangeProvider and V1 token exchange are no longer touched by this PR.

  1. Updated the PR description to match the current design.

  2. Added all the suggested tests.
    For the client policy test, I added a small test executor to tests/custom-providers that rejects only TOKEN_EXCHANGE_RESPONSE, and for the resource test, a resource(String...) method to TokenExchangeRequest.

  3. Addressed all minor points except the payload typ claim, which is intended:
    Keycloak tokens carry a typ claim in the payload (Bearer, ID, Refresh, ...), so the ID-JAG follows the same convention with its own value. The draft’s explicit type is the oauth-id-jag+jwt header, which is set as well. Happy to remove the payload claim if you prefer to keep the ID-JAG strictly to the draft.

Could you please take another look when you have time?

@tnorimat
Copy link
Copy Markdown
Contributor

@bucchi Hello,

I confirmed that you fixed the issues I commented.

I found that the document docs/guides/securing-apps/identity-assertion-jwt-authorization-grant.adoc is not updated. The PR implements the issuer side, but the guide still says:

  • line 34: "Not Yet Implemented (Issuer Side)"
  • line 58: "Configuring the Issuer": "Status: TBD"

It is good to update the guide. For example

  • the request (requested_token_type=...:id-jag, an ID token subject)
  • the Resource AS as a realm client with clientId == audience
  • consistent sub / pairwise configuration
  • lightweight-token mappers
  • the asymmetric signing requirement
  • the jwt-claim-enforcer caveat
  • that actor_token and resource are unsupported

What do you think about that?

@bucchi
bucchi commented Oct 26, 2026
Copy link
Copy Markdown
Contributor Author

Hi @tnorimat,
Thank you for checking. I agree the guide should be updated. To keep this PR focused on the implementation, I’d like to handle it in #54330, and I added the issuer side points you listed there.

  • the request (requested_token_type=...:id-jag, an ID token subject)
  • the Resource AS as a realm client with clientId == audience
  • consistent sub / pairwise configuration
  • lightweight-token mappers
  • the asymmetric signing requirement
  • the jwt-claim-enforcer caveat
  • that actor_token and resource are unsupported

@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 Thank you.
@mposolda @graziang I believe this PR is ready to be merged. It should sufficiently address a lot of comments raised during the reviews. What do you think?

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)
0