Customize token exchange endpoint to issue ID-JAG - #49998
Conversation
| @@ -0,0 +1,334 @@ | |||
| /* | |||
There was a problem hiding this comment.
This header comments are no longer needed.
There was a problem hiding this comment.
Thank you for your comment.
I have just fixed as suggested.
| @@ -0,0 +1,77 @@ | |||
| /* | |||
There was a problem hiding this comment.
The header comments are no longer needed.
There was a problem hiding this comment.
I have just fixed as suggested for here too.
| @@ -0,0 +1,517 @@ | |||
| /* | |||
There was a problem hiding this comment.
The header comments are no longer needed.
There was a problem hiding this comment.
I have just fixed as suggested for here too.
| @JsonProperty("client_id") | ||
| protected String client_id; | ||
|
|
||
| public String getClient_id() { |
There was a problem hiding this comment.
Any reason we use getClient_id here and not getClientId? The json property should handle de/serialization already.
There was a problem hiding this comment.
Thank you for your comment.
I have just fixed as suggested.
| } | ||
| } | ||
|
|
||
| public AccessTokenResponseBuilder generateIDJag() { |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
I would not introduce a new type of token
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
do we need all these oauth clients?, can it be simplified?
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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().
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
This block seems unreachable. Since .orElseThrow(...) already guarantees a non-null result.
There was a problem hiding this comment.
I also fixed that as suggested.
There was a problem hiding this comment.
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 whenrequested_token_type=urn:ietf:params:oauth:token-type:id-jag. - Adds an
IDJAGtoken representation and a newTokenCategory.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. |
|
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. |
| 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); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
if(audienceParams.size() == 0)
might be
if (audienceParams.size() == 0)
There was a problem hiding this comment.
I fixed this as suggested.
ab908b3 to
35c0e7a
Compare
35c0e7a to
592bb88
Compare
592bb88 to
2d8dd22
Compare
| ClientModel targetClient = session.clients().getClientsStream(realm) | ||
| .filter(c -> audienceParameterString.equals(c.getAttribute(RESOURCE_AUTHORIZATION_SERVER_IDENTIFIER))) | ||
| .findFirst() |
There was a problem hiding this comment.
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.
2d8dd22 to
7d8a65c
Compare
|
Hi @bucchi - this is super cool feature!! looking forward to it! |
7d8a65c to
51856ad
Compare
There was a problem hiding this comment.
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 equalclient) 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-
audiencerejection 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 assertinvalid_requestso 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);
51856ad to
d5be6fa
Compare
There was a problem hiding this comment.
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
AbstractPairwiseSubMapperderivessubfrom 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.");
d5be6fa to
073070a
Compare
There was a problem hiding this comment.
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 inaud, 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
falselets 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 fromtokenExchange()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 {
073070a to
889a974
Compare
There was a problem hiding this comment.
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 inaud, 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
algheader makesgetAlgorithm()return null, so.name()throws an uncaughtNullPointerExceptionand returns a 500 instead ofinvalid_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);
889a974 to
e1943fe
Compare
There was a problem hiding this comment.
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 inheritedvalidateAudiencealso 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; resolvetoken.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>
e1943fe to
6836bfd
Compare
There was a problem hiding this comment.
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_tokenstill acceptsactor_token_typeby 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'sissuedFor. Inherited audience validation permits cross-client exchange when the requester is inaud, 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
AbstractPairwiseSubMapperderivessubfrom the requester's sector rather than the resource authorization server that is placed inaud. 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 forrequested_subject,requested_issuer, andsubject_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
OIDCIDTokenMapperis allowed to return anIDToken, so a custom mapper that returns a replacement token causes this cast to throwClassCastExceptionand turns ID-JAG issuance into a 500. Avoid relying on the mapper preserving theIDJAGruntime 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.");
| } | ||
|
|
||
| AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId()); | ||
| if (clientSession == null || !AuthenticationManager.isClientSessionValid(realm, client, userSession, clientSession)) { |
There was a problem hiding this comment.
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?
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.