Skip to content

Support issuing Identity Assertion JWT (ID-JAG) via token exchange - #50288

Closed
bkoragan wants to merge 18 commits into
keycloak:mainfrom
bkoragan:issue-48818
Closed

Support issuing Identity Assertion JWT (ID-JAG) via token exchange#50288
bkoragan wants to merge 18 commits into
keycloak:mainfrom
bkoragan:issue-48818

Conversation

@bkoragan

@bkoragan bkoragan commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Adds the issuer side of the MCP Enterprise-Managed Authorization /Cross-App Access flow, so Keycloak (as the enterprise IdP) can mint an ID-JAG through an RFC 8693 token exchange. The complementary consumer side (redeeming an ID-JAG via the jwt-bearer grant) already exists.

  • New IdentityAssertionGrantTokenExchangeProvider, selected foe requested_token_type=urn:ietf:params:oauth:token-type:id-jag and gated by the experimental identity-assertion-jwt feature
  • Takes an ID token as subject_token; returns a short-lived JWT with the oauth-id-jag+jwt header and iss/sub/aud/resource/client_id/scope/jti claims; response uses token_type=N_A and issued_token_type=id-jag
  • Per-client policy: issuance toggle, allowed-audience allow-list (deny by default), and a downstream client_id for the JAG client_id claim
  • Advertises urn:ietf:params:oauth:grant-profile:id-jag in the OIDC discovery document (authorization_grant_profiles_supported)
  • Added integration tests aligns wiht the MCP conformance wire contract : (ref: feat: add conformance tests for SEP-990 modelcontextprotocol/conformance#110)

Closes #48818

Sequence Flow:

image

Manual Test execution validation steps locally:
keycloak % BASE=http://localhost:8080
keycloak % REALM=master
keycloak % adm() { curl -s $BASE/realms/master/protocol/openid-connect/token
-d grant_type=password -d client_id=admin-cli
-d username=admin -d password=admin | jq -r .access_token; }
keycloak % [ -n "$(adm)" ] && [ "$(adm)" != "null" ] && echo "admin token OK" || echo "check admin/admin creds"

admin token OK

keycloak % CID=$(curl -s -H "Authorization: Bearer $(adm)"
"$BASE/admin/realms/$REALM/clients?clientId=mcp-client" | jq -r '.[0].id // empty')
[ -n "$CID" ] && curl -s -X DELETE -H "Authorization: Bearer $(adm)"
"$BASE/admin/realms/$REALM/clients/$CID"
keycloak % echo $CID
f7dd35c5-f39d-4b3c-b85f-23a8c75af15c
keycloak %
keycloak %
curl -s -o /dev/null -w "create client: %{http_code}\n"
-X POST "$BASE/admin/realms/$REALM/clients"
-H "Authorization: Bearer $(adm)" -H "Content-Type: application/json" -d '{
"clientId": "mcp-client",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"secret": "secret",
"directAccessGrantsEnabled": true,
"standardFlowEnabled": false,
"attributes": {
"id.jag.issuance.enabled": "true",
"id.jag.allowed.audiences": "https://auth.chat.example",
"id.jag.client.id": "mcp-client-at-resource-as"
}
}'
create client: 201

keycloak %
keycloak %
CID=$(curl -s -H "Authorization: Bearer $(adm)"
"$BASE/admin/realms/$REALM/clients?clientId=mcp-client" | jq -r '.[0].id')
SECRET=$(curl -s -H "Authorization: Bearer $(adm)"
"$BASE/admin/realms/$REALM/clients/$CID/client-secret" | jq -r .value)
echo "client secret: $SECRET"
client secret: secret

keycloak % ID_TOKEN=$(curl -s $BASE/realms/$REALM/protocol/openid-connect/token
-d grant_type=password -d username=admin -d password=admin
-d client_id=mcp-client -d client_secret=$SECRET
-d scope="openid email" | jq -r .id_token)

keycloak % [ -n "$ID_TOKEN" ] && [ "$ID_TOKEN" != "null" ] && echo "ID token OK" || echo "login failed"
ID token OK

keycloak % RESP=$(curl -s -u mcp-client:$SECRET $BASE/realms/$REALM/protocol/openid-connect/token
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange
-d requested_token_type=urn:ietf:params:oauth:token-type:id-jag
-d subject_token=$ID_TOKEN
-d subject_token_type=urn:ietf:params:oauth:token-type:id_token
-d audience=https://auth.chat.example
-d resource=https://mcp.chat.example/mcp
-d scope="chat.read chat.history")

keycloak % echo "$RESP" | jq .

{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAib2F1dGgtaWQtamFnK2p3dCIsImtpZCIgOiAiQVNEYWRfb05YekEzSzYtRzRWdGN2UzBaVTV5eWRyVFR0UGVDWThsbS1HdyJ9.eyJleHAiOjE3ODIyNjY5MDksImlhdCI6MTc4MjI2NjYwOSwianRpIjoiNmNmMmZjYjAtZjUwNC00NzIzLWJmOWEtMzc3OTRkOGY2NGU5IiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9tYXN0ZXIiLCJhdWQiOiJodHRwczovL2F1dGguY2hhdC5leGFtcGxlIiwic3ViIjoiZmY2N2U3NTctNWY0MS00ZmMzLWFkNjQtZTA3Y2EwNTc2NWNjIiwicmVzb3VyY2UiOiJodHRwczovL21jcC5jaGF0LmV4YW1wbGUvbWNwIiwic2NvcGUiOiJjaGF0LnJlYWQgY2hhdC5oaXN0b3J5IiwiY2xpZW50X2lkIjoibWNwLWNsaWVudC1hdC1yZXNvdXJjZS1hcyJ9.D4yEovrDsmr0Tdn6wYWsgmGcZQGDhDatQ_hPvaPTnCBgVuRXFPDglwICtNdFGOQouoM6LUd6-GUFT2qL3q5jZSWyvIvTCbA4nk7PfX4mM-D5b8Z1fZDTloMf8TiNxq1Zp2ZCkSEj6MIUc53eC72PSxtoAjd-JN97oXVIp2SvboqpuTdZl81z2bXWqMVEPyPg6-PWejHKe7FAePhumEMJK_9dYVLHRoY22g_8eGhn947w3ZONS_JqsS6FH3jwyfQ_iiBpTTW5zop16GmXaCgHLVGBActyf67F-9ra2-bg452sXpmqWDiPbiSlaES1hEYNzRy6kcBbRBTymybDGLrScA",
"expires_in": 300,
"refresh_expires_in": 0,
"token_type": "N_A",
"not-before-policy": 0,
"scope": "chat.read chat.history",
"issued_token_type": "urn:ietf:params:oauth:token-type:id-jag"
}

keycloak % JAG=$(echo "$RESP" | jq -r .access_token)
jwt "$JAG" 1 # HEADER → "typ": "oauth-id-jag+jwt"
jwt "$JAG" 2 # PAYLOAD → iss=.../realms/master, aud=https://auth.chat.example,
# resource=https://mcp.chat.example/mcp,
# client_id="mcp-client-at-resource-as" (the downstream id),
# sub, jti, iat, exp, scope
{
"alg": "RS256",
"typ": "oauth-id-jag+jwt",
"kid": "ASDad_oNXzA3K6-G4VtcvS0ZU5yydrTTtPeCY8lm-Gw"
}
{
"exp": 1782266909,
"iat": 1782266609,
"jti": "6cf2fcb0-f504-4723-bf9a-37794d8f64e9",
"iss": "http://localhost:8080/realms/master",
"aud": "https://auth.chat.example",
"sub": "ff67e757-5f41-4fc3-ad64-e07ca05765cc",
"resource": "https://mcp.chat.example/mcp",
"scope": "chat.read chat.history",
"client_id": "mcp-client-at-resource-as"
}

keycloak % curl -s $BASE/realms/$REALM/.well-known/openid-configuration
| jq '.authorization_grant_profiles_supported'
[
"urn:ietf:params:oauth:grant-profile:id-jag"
]

## audience not allow-listed → 403 access_denied
keycloak %
curl -s -o /dev/null -w "not-allowed audience: %{http_code}\n" -u mcp-client:$SECRET
$BASE/realms/$REALM/protocol/openid-connect/token
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange
-d requested_token_type=urn:ietf:params:oauth:token-type:id-jag
-d subject_token=$ID_TOKEN -d subject_token_type=urn:ietf:params:oauth:token-type:id_token
-d audience=https://evil.example
not-allowed audience: 403

# wrong subject_token_type → 400 invalid_request
keycloak %
curl -s -u mcp-client:$SECRET $BASE/realms/$REALM/protocol/openid-connect/token
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange
-d requested_token_type=urn:ietf:params:oauth:token-type:id-jag
-d subject_token=$ID_TOKEN -d subject_token_type=urn:ietf:params:oauth:token-type:access_token
-d audience=https://auth.chat.example | jq '{error, error_description}'
{
"error": "invalid_request",
"error_description": "Parameter 'subject_token_type' must be urn:ietf:params:oauth:token-type:id_token"
}

keycloak %

Adds the issuer side of the MCP Enterprise-Managed Authorization /Cross-App Access flow, so Keycloak (as the enterprise IdP) can mint an ID-JAG through an RFC 8693 token exchange. The complementary consumer side (redeeming an ID-JAG via the jwt-bearer grant) already exists.

- New IdentityAssertionGrantTokenExchangeProvider, selected foe  requested_token_type=urn:ietf:params:oauth:token-type:id-jag and gated by the experimental identity-assertion-jwt feature
- Takes an ID token as subject_token; returns a short-lived JWT with the  oauth-id-jag+jwt header and iss/sub/aud/resource/client_id/scope/jti claims; response uses token_type=N_A and issued_token_type=id-jag
- Per-client policy: issuance toggle, allowed-audience allow-list (deny  by default), and a downstream client_id for the JAG client_id claim
- Advertises urn:ietf:params:oauth:grant-profile:id-jag in the OIDC  discovery document (authorization_grant_profiles_supported)
- Adds integration tests mirroring the MCP conformance wire contract : (ref: modelcontextprotocol/conformance#110)

Closes keycloak#48818

Signed-off-by: Bapuji Koraganti <bapuk.2008@gmail.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.

Pull request overview

Adds issuer-side support for minting Identity Assertion JWTs (ID-JAG) in Keycloak via RFC 8693 token exchange, gated behind the experimental identity-assertion-jwt feature, and updates discovery + test tooling to exercise the MCP cross-app access wire contract.

Changes:

  • Introduces a new token exchange provider/factory that issues short-lived ID-JAG JWTs when requested_token_type=urn:ietf:params:oauth:token-type:id-jag.
  • Adds per-client configuration attributes for enabling issuance, allow-listing audiences, and setting the downstream client_id claim.
  • Extends OIDC discovery and adds integration tests + test utilities (notably support for RFC 8707 resource).

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/TokenExchangeRequest.java Adds support for sending resource parameters in token exchange requests.
tests/base/src/test/java/org/keycloak/tests/oauth/tokenexchange/IdentityAssertionGrantTokenExchangeTest.java Adds integration tests covering ID-JAG issuance, policy gating, and discovery advertisement.
services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.TokenExchangeProviderFactory Registers the new ID-JAG token exchange provider factory.
services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/IdentityAssertionGrantTokenExchangeProviderFactory.java Adds an environment-dependent factory gated by the experimental feature flag.
services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/IdentityAssertionGrantTokenExchangeProvider.java Implements ID-JAG issuance via token exchange, including audience allow-list enforcement and JWT minting.
services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java Advertises ID-JAG grant profile in discovery when the feature is enabled.
services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java Adds per-client getters/setters for ID-JAG issuance configuration.
server-spi-private/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java Defines new client attribute keys for ID-JAG issuance policy.
core/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java Adds authorization_grant_profiles_supported to the discovery representation model.
core/src/main/java/org/keycloak/OAuth2Constants.java Adds constants for the ID-JAG token type and discovery grant profile URN.

bkoragan and others added 3 commits June 23, 2026 22:31
Signed-off-by: Bapuji Koraganti <34816445+bkoragan@users.noreply.github.com>
Incorporate the Copilot comments:

1. typ check — after verifying the subject token, reject it unless typ=ID, so an access token can't be passed off as an ID token.
2. issued-for check — reject unless the subject token's azp/issuedFor equals the requesting client, so a client can only exchange its own ID token (matches the conformance IdP's audience: client_id check). Added a SUBJECT_TOKEN_CLIENT_ID audit detail too.

Signed-off-by: Bapuji Koraganti <bapuk.2008@gmail.com>
Copilot AI review requested due to automatic review settings June 24, 2026 02:45

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 10 out of 10 changed files in this pull request and generated 2 comments.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Bapuji Koraganti <34816445+bkoragan@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 24, 2026 02:51

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 10 out of 10 changed files in this pull request and generated 1 comment.

bkoragan added 2 commits June 23, 2026 23:07
… SPI

Addressing review comments on the ID-JAG issuer:

- Sign the ID-JAG with the realm's default signature algorithm/key  instead of TokenCategory.ACCESS. The ACCESS (& ID) categories resolve  a per-client signed-response-alg override from the requesting client, which is inappropriate for a cross-domain assertion that an external resource AS validates via the realm JWKS. The realm default is predictable, published in the JWKS, and independent of the calling client. Falls back to RS256 when the realm has no default.

- Drop getVersion() from the provider to match the updated TokenExchangeProvider SPI (the method was removed from the interface).

Signed-off-by: Bapuji Koraganti <bapuk.2008@gmail.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.

Pull request overview

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

bkoragan added 2 commits June 23, 2026 23:33
Let clients restrict which scopes may be requested in an ID-JAG via a new id.jag.allowed.scopes attribute. When configured, a requested scope outside the list is rejected with invalid_scope; an empty list means no restriction(the resource AS still enforces its own scopes).

- New OIDCConfigAttributes.ID_JAG_ALLOWED_SCOPES and wrapper accessors
- Enforced in IdentityAssertionGrantTokenExchangeProvider before minting
- Tests for an allowed scope (flows into the scope claim) and a rejected one

Signed-off-by: Bapuji Koraganti <bapuk.2008@gmail.com>
Document the ID-JAG issuer feature as a new securing-apps guide, following the JWT Authorization Grant guide pattern: enabling the expermiental feature, how the token exchange is processed, the per-client configuration attirbutes, and request/assertion/response examples. Also list the Identity Assertion Authorization Grant draft in the specifications guide.

Signed-off-by: Bapuji Koraganti <bapuk.2008@gmail.com>
Copilot AI review requested due to automatic review settings June 24, 2026 03:43

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 12 out of 12 changed files in this pull request and generated 3 comments.

@thomasdarimont

thomasdarimont commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Ignore this...

Since the ID_JAG is still a draft, I think it would make sense to add an experimental feature flag for this in org.keycloak.common.Profile.Feature.

For example: org.keycloak.common.Profile.Feature#ID_JAG. You can then let your provider factories implement EnvironmentDependentProviderFactory and override the isSupported method.

    @Override
    public boolean isSupported(Config.Scope config) {
        return Profile.isFeatureEnabled(Profile.Feature.ID_JAG);
    }

@thomasdarimont

Copy link
Copy Markdown
Contributor

Since the ID_JAG is still a draft, I think it would make sense to add an experimental feature flag for this in org.keycloak.common.Profile.Feature.

For example: org.keycloak.common.Profile.Feature#ID_JAG. You can then let your provider factories implement EnvironmentDependentProviderFactory and override the isSupported method.

    @Override
    public boolean isSupported(Config.Scope config) {
        return Profile.isFeatureEnabled(Profile.Feature.ID_JAG);
    }

Never mind, I just noticed that you are already using the existing IDENTITY_ASSERTION_JWT experimental feature flag.

@thomasdarimont

thomasdarimont commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

@bkoragan could you rebase this on main again? I currently see conflicts:


both modified:   services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.TokenExchangeProviderFactory

@thomasdarimont

thomasdarimont commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

I could execute your demo on macos with a slightly updated script: idjag-test.sh https://gist.github.com/thomasdarimont/05ff048dbc6f066f8e1aa7c0aba74a59 (also added the scopes that are mentioned in the docs/guides/securing-apps/identity-assertion-grant.adoc example and used URIs for the resources and audience)

This gives me the following output:

$ ./idjag-test.sh
== getting admin token ==
admin token OK

== (re)creating client 'mcp-client' ==
deleted existing client (f060c627-14a5-418d-8b60-45d1ad4cd756)
create client: 201
client secret: secret

== getting ID token ==
ID token OK

== exchanging ID token for an ID-JAG ==
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAib2F1dGgtaWQtamFnK2p3dCIsImtpZCIgOiAib0RnSHd0U0xZZkxrSF9MX050Z1NfNGRnYzNhUDNERTVjU2RsdHU1Ukt0cyJ9.eyJleHAiOjE3ODIyOTgwMzcsImlhdCI6MTc4MjI5NzczNywianRpIjoiMWMzMDc0ZTMtMTBiNy00ZGVhLWJkNDgtZWJjZjRhMDcwMzIzIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgxL3JlYWxtcy9tYXN0ZXIiLCJhdWQiOiJodHRwczovL2F1dGguY2hhdC5leGFtcGxlIiwic3ViIjoiZTJmODVlYTItYTUzYS00ZjdmLWI1YmUtODYzZTgwNzc4OTk1IiwicmVzb3VyY2UiOiJodHRwczovL21jcC5jaGF0LmV4YW1wbGUvbWNwIiwic2NvcGUiOiJjaGF0LnJlYWQgY2hhdC5oaXN0b3J5IiwiY2xpZW50X2lkIjoibWNwLWNsaWVudC1hdC1yZXNvdXJjZS1hcyJ9.P8e-_SQ-C6j1YHyx4Ut1RlURuTI415FR80OwceUNNjGQoSEq9KlduqFq-mrKfTwPlrIm888wKnyjcmGkbA2JHs9SPsk9AHhdvbBBUmGIbvDo0WlqzQprz3SFq7scN3aVmSPG5mJdtRJ2El7yS46L5eqI40lcgxqD2Zkf74ZlKC5KPBuhGfBek01zHhXoFQBHsfOTU9xNtlVj-Fv2YU4l_MGmwSwA4VryuuGGCJE0SPO2n9nkV4oM61okVnSqpDmdbcQUHp0vFJ_VtuPFQNpa1-IfAu6IN-sKYr_5G5WSDn1qb4T2CgBWxROg2rr_ommG2LapoUPYVz_zUtbd2Cfwfg",                                                                                        
  "expires_in": 300,
  "refresh_expires_in": 0,
  "token_type": "N_A",
  "not-before-policy": 0,
  "scope": "chat.read chat.history",
  "issued_token_type": "urn:ietf:params:oauth:token-type:id-jag"
}

-- ID-JAG header --
{
  "alg": "RS256",
  "typ": "oauth-id-jag+jwt",
  "kid": "oDgHwtSLYfLkH_L_NtgS_4dgc3aP3DE5cSdltu5RKts"
}
-- ID-JAG payload --
{
  "exp": 1782298037,
  "iat": 1782297737,
  "jti": "1c3074e3-10b7-4dea-bd48-ebcf4a070323",
  "iss": "http://localhost:8081/realms/master",
  "aud": "https://auth.chat.example",
  "sub": "e2f85ea2-a53a-4f7f-b5be-863e80778995",
  "resource": "https://mcp.chat.example/mcp",
  "scope": "chat.read chat.history",
  "client_id": "mcp-client-at-resource-as"
}

== advertised grant profiles ==
[
  "urn:ietf:params:oauth:grant-profile:id-jag"
]

== negative: non-allow-listed audience (expect 403) ==
not-allowed audience: 403

== negative: wrong subject_token_type (expect 400 invalid_request) ==
{
  "error": "invalid_request",
  "error_description": "Parameter 'subject_token_type' must be urn:ietf:params:oauth:token-type:id_token"
}

== negative: non-allow-listed scope (expect 400 invalid_scope) ==
{
  "error": "access_denied",
  "error_description": "Audience is not allowed for this client"
}

Done.

bkoragan added 2 commits June 24, 2026 08:48
…alm + Admin consloe change to support ID-AJG feature

Incorporate the review comments:
1. The subject_token must be an ID token issued by the realm performing the exchange (the enterprise IdP that also mints the ID-JAG). A token from a different issuer was already rejected, but with a generic message. Added an explicit issuer check so the cause is clear, and document the two-realm issuer/consumer topology in the guide.
2.Surface the Identity Assertion JWT (ID-JAG) issuer settings in the console, similar to the standard-token-exchange and jwt-authorization-grant toggles:
- Capability config: "Identity Assertion JWT Authorization Grant" checkbox (id.jag.issuance.enabled), gated on the experimental feature and disabled for public clients.
- Advanced settings: allowed audiences, downstream client_id and allowed scopes, shown only when issuance is enabled.

Signed-off-by: Bapuji Koraganti <bapuk.2008@gmail.com>
@bkoragan
bkoragan requested a review from a team as a code owner June 24, 2026 12:48
@bkoragan

bkoragan commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

The four ID-JAG settings are currently only configurable via client attributes (REST API / realm import), with no Admin Console support.
Suggest splitting them across two existing locations, consistent with how the other token-exchange grants are already modeled:

@thomasdarimont Incorporated the ID-JAG config changes for console. Please review:
image
image

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 16 out of 16 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings June 24, 2026 13:37

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 16 out of 16 changed files in this pull request and generated 1 comment.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Bapuji Koraganti <34816445+bkoragan@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 24, 2026 14:15

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 16 out of 16 changed files in this pull request and generated no new comments.

bkoragan and others added 3 commits June 24, 2026 10:58
Collapse a multi-line t() help-text call to a single line in AdvancedSettings.tsx so the admin-ui lint passes.

Signed-off-by: Bapuji Koraganti <bapuk.2008@gmail.com>
Copilot AI review requested due to automatic review settings June 24, 2026 15:08

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 16 out of 16 changed files in this pull request and generated no new comments.

@embesozzi

Copy link
Copy Markdown

It’s a good point raised by @thomasdarimont, since I was expecting cross-trust-domain support.
The relevant spec is OAuth Identity and Authorization Chaining Across Domains, which already defines this kind of cross-domain hop. This feature was introduced by Yutaka Obuchi in #49005

1466eb4

Nevertheless, I’m not sure whether it makes sense to include this in this PR since in this case we handle both cases in the same domain and cross domain as well.

On the allowed-scopes/audiences point: Hitachi@d73d796 solves the idea of "multiple audiences, different policy each" problem differently, each resource is its own client and access is granted via role/scope mapping instead of a flat allow-list.

@VinodAnandan

VinodAnandan commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Thanks, @thomasdarimont and @embesozzi for the feedback.

My understanding is that the primary use case would be the following:

  1. A Keycloak-issued ID token is exchanged into an ID-JAG token by MCP client in Keycloak.
  2. The MCP client then uses this ID-JAG token when interacting with a cloud-based MCP resource server (for example, Atlassian, Datadog, Slack, etc.).
  3. The MCP client send this the ID-JAG token to "MCP cloud authorization server", which acts as the ID-JAG token consumer and has an established trust relationship with Keycloak.
  4. After validating the token, the cloud authorization server issues an access token to the MCP client.
  5. The MCP client then uses this access token to make API calls to the MCP cloud resource server/APIs.

In other words, the cloud resource server ultimately relies on an access token issued by its own trusted "authorization server", while the original Keycloak-issued identity is conveyed through the ID-JAG token exchange process.

Please correct me if I have misunderstood any part of the flow. However, if my understanding is correct, I would expect this to represent the majority of the anticipated use cases. Is that a fair assumption?

@bkoragan

bkoragan commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

It’s a good point raised by @thomasdarimont, since I was expecting cross-trust-domain support. The relevant spec is OAuth Identity and Authorization Chaining Across Domains, which already defines this kind of cross-domain hop. This feature was introduced by Yutaka Obuchi in #49005

1466eb4

Nevertheless, I’m not sure whether it makes sense to include this in this PR since in this case we handle both cases in the same domain and cross domain as well.

On the allowed-scopes/audiences point: Hitachi@d73d796 solves the idea of "multiple audiences, different policy each" problem differently, each resource is its own client and access is granted via role/scope mapping instead of a flat allow-list.

Thanks @thomasdarimont @embesozzi @VinodAnandan . let me walk through how I'm thinking about it.
Here's the flow I have in mind (MCP cross-app access):

  1. The user logs in to Keycloak, and the MCP client gets a Keycloak ID token.
  2. (this PR) The client exchanges that ID token for an ID-JAG.
  3. The client hands the ID-JAG to a cloud authorization server (for ex, say Atlassian, Datadog, or Slack) that already trusts Keycloak.
  4. That cloud AS checks the ID-JAG against Keycloak's JWKS and gives back its own access token.
  5. The client uses that access token to call the cloud APIs.

on cross-domain: the thing consuming the ID-JAG in steps 3–4 is a third party in a different trust domain - not Keycloak. So all this PR really has to do is step 2: mint a clean, portable ID-JAG (signed with the realm key, verifiable via the realm JWKS, carrying iss / aud=the cloud AS / sub / client_id). The cross-domain hop is baked in - the Keycloak identity travels into the external AS's domain inside that assertion. That's also why I kept the same-issuer check: Keycloak should only mint for identities it actually authenticated, not re-mint someone else's token.

On the allowed audiences/scopes: since the audience is an external cloud AS, the allow-list is really just a guardrail on the client - "which cloud ASes (and scopes) is this client allowed to mint ID-JAGs for." The per-resource-client + role approach from d73d796 is nice, but it can't apply here because those audiences aren't Keycloak clients. That model makes sense on the receiver side (#49005), where the resources really are Keycloak clients. So I think they're solving two different problems..

I couldn't spin up a real cloud AS(for ex, Atlassian) for a test, so I used a second Keycloak realm as a stand-in consumer (through the #49005 jwt-bearer grant). It validates the ID-JAG against the issuer's JWKS exactly the way an external AS would, and the full chain works end to end:

Here are the execution evidence logs:

`==================== 1. Authenticate kcadm ====================
Logging into http://localhost:8080 as user admin of realm master
21:04:15 authenticated as 'admin'
21:04:15 keycloak version : 999.0.0-SNAPSHOT
21:04:16 reset existing realm 'domaina'
21:04:17 reset existing realm 'domainb'

==================== 2. Configure domaina (issuer / enterprise IdP) ====================
Created new realm with id 'domaina'
Created new user with id '42b08e8f-7047-4492-a43a-618e1d8361b7'
Created new client with id '7e8ad064-dca6-4b34-8300-afaa76f6c31c'
21:04:19 domaina ready: realm + user 'testuser' + confidential client 'clienta'

==================== 3. Configure domainb (resource AS / receiver) + link user ====================
Created new realm with id 'domainb'
Created new instance with id 'domaina'
Created new client with id '54a4373b-9682-4101-817a-b97030c116e9'
Created new model with id 'c4524dbe-9927-43d3-b143-a9014f5dc3c6'
Created new user with id '6421e3f8-254a-4d20-9715-5397f750170c'
21:04:23 domainb ready: realm + IdP 'domaina' + client 'clientb'
21:04:23 linked domainb user 6421e3f8-254a-4d20-9715-5397f750170c -> domaina user 42b08e8f-7047-4492-a43a-618e1d8361b7 via IdP 'domaina'

==================== 4. Run the cross-domain chain ====================

STEP 4.1 Password grant @ domaina -> ID token

REQUEST POST http://localhost:8080/realms/domaina/protocol/openid-connect/token
grant_type=password
scope=openid
client_id=clienta
client_secret=***
username=testuser
password=***
RESPONSE HTTP 200
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6... (1285 chars)",
"expires_in": 300,
"refresh_expires_in": 1800,
"refresh_token": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6... (666 chars)",
"token_type": "Bearer",
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6... (1029 chars)",
"not-before-policy": 0,
"session_state": "3cRDRyC67H40cJFCBU7PHmnp",
"scope": "openid profile email"
}

STEP 4.2 Token exchange @ domaina -> ID-JAG [ISSUER SIDE / PR #50288]

REQUEST POST http://localhost:8080/realms/domaina/protocol/openid-connect/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
client_id=clienta
client_secret=***
requested_token_type=urn:ietf:params:oauth:token-type:id-jag
subject_token=eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwi... (1029 chars)
subject_token_type=urn:ietf:params:oauth:token-type:id_token
audience=http://localhost:8080/realms/domainb
RESPONSE HTTP 200
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAib2F1dGgtaWQtamFn... (822 chars)",
"expires_in": 300,
"refresh_expires_in": 0,
"token_type": "N_A",
"not-before-policy": 0,
"issued_token_type": "urn:ietf:params:oauth:token-type:id-jag"
}

STEP 4.3 jwt-bearer grant @ domainb -> access token [RECEIVER SIDE / #49005]

REQUEST POST http://localhost:8080/realms/domainb/protocol/openid-connect/token
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
client_id=clientb
client_secret=***
assertion=eyJhbGciOiJSUzI1NiIsInR5cCIgOiAib2F1dGgt... (822 chars)
RESPONSE HTTP 200
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6... (1247 chars)",
"expires_in": 300,
"refresh_expires_in": 0,
"token_type": "Bearer",
"not-before-policy": 0,
"scope": "profile email"
}

==================== 5. Verify ====================

STEP 5.1 Decode the minted ID-JAG

ID-JAG header:
{
"alg": "RS256",
"typ": "oauth-id-jag+jwt",
"kid": "Ftqg4DCf0MxdoL39K9c0tHxaFGwOSns2EJWx9deJIgk"
}
ID-JAG claims:
{
"exp": 1782349763,
"iat": 1782349463,
"jti": "0dee19c6-6c23-4894-ba63-11a8b32bfa50",
"iss": "http://localhost:8080/realms/domaina",
"aud": "http://localhost:8080/realms/domainb",
"sub": "42b08e8f-7047-4492-a43a-618e1d8361b7",
"client_id": "clientb",
"email": "test@localhost"
}
assertions:
[PASS] ID-JAG typ = oauth-id-jag+jwt
[PASS] ID-JAG iss (domaina) = http://localhost:8080/realms/domaina
[PASS] ID-JAG aud (domainb) = http://localhost:8080/realms/domainb
[PASS] ID-JAG client_id = clientb
[PASS] issued_token_type = urn:ietf:params:oauth:token-type:id-jag
[PASS] domainb refresh_token = none

STEP 5.2 Introspect the domainb access token @ domainb

REQUEST POST http://localhost:8080/realms/domainb/protocol/openid-connect/token/introspect
token=eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwi... (1247 chars)
client_id=clientb
client_secret=***
RESPONSE HTTP 200
{
"exp": 1782349763,
"iat": 1782349463,
"jti": "trrtag:ff3dd8ce-bde8-c2d7-adfb-991ee25d8047",
"iss": "http://localhost:8080/realms/domainb",
"aud": [
"clientb",
"account"
],
"sub": "6421e3f8-254a-4d20-9715-5397f750170c",
"typ": "Bearer",
"azp": "clientb",
"acr": "1",
"realm_access": {
"roles": [
"offline_access",
"default-roles-domainb",
"uma_authorization"
]
},
"resource_access": {
"account": {
"roles": [
"manage-account",
"manage-account-links",
"view-profile"
]
}
},
"scope": "profile email",
"email_verified": true,
"name": "Test User",
"preferred_username": "testuser",
"given_name": "Test",
"family_name": "User",
"email": "test@localhost",
"client_id": "clientb",
"username": "testuser",
"token_type": "Bearer",
"active": true
}
assertions:
[PASS] introspect active = true
[PASS] introspect username = testuser
[PASS] introspect client_id = clientb
[PASS] introspect iss = http://localhost:8080/realms/domainb

==================== RESULT ====================
checks passed: 10 failed: 0
SUCCESSFUL — an ID-JAG minted by domaina was consumed cross-domain by domainb,
validated against domaina's JWKS, and exchanged for a domainb access token.`

Please correct if I'm missing specific case/scenario here.

@VinodAnandan

VinodAnandan commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Apologies in advance, everyone. I haven’t had a chance to test this PR locally yet, but I plan to do so shortly.

@bkoragan, could you please include details about how Realm B/Domain B validates the JWT issued by Realm A/Domain A? As @thomasdarimont indicated, I believe this should be similar to the approach used for federated client authentication, where the key material is retrieved based on the token issuer (Realm A/Domain A) and then used to verify the JWT signature.

@mposolda

Copy link
Copy Markdown
Contributor

@bkoragan There is already a PR in progress for this. Sending another PR for the same thing increases confusions as existing PR is still under review and it is work in progress. If you are interested in contributing to IDJAG, please synchronize with Hitachi (@tnorimat and @bucchi ) and check where you can help. Based on the comment from @bucchi #48818 (comment) , it is clear that you probably did not made this.

Closing the PR as we don't need multiple PRs for the same thing.

@mposolda mposolda closed this Jun 25, 2026
@bkoragan

Copy link
Copy Markdown
Contributor Author

Apologies in advance, everyone. I haven’t had a chance to test this PR locally yet, but I plan to do so shortly.

@bkoragan, could you please include details about how Realm B/Domain B validates the JWT issued by Realm A/Domain A? As @thomasdarimont indicated, I believe this should be similar to the approach used for federated client authentication, where the key material is retrieved based on the token issuer (Realm A/Domain A) and then used to verify the JWT signature.

@VinodAnandan yes, thats exactly how it works and in the thread above I attached log walks through it end to end.

In the evidence attached(above thread):

  1. Section 2, stands up Domain A (the issuer) with a user and a confidential client.
  2. Section 3 stands up Domain B and configured it to trust Domain A by adding an identity provider domaina configured with Domain A's issuer and its' keys/JWKS URL, signature validation on. That's the trust relationship.
  3. in step 4.1, client login at Domain A and gets a normal Keycloak ID token.
  4. step 4.2, Domain A mints the ID-JAG (with this PR change). The token exchange returns the ID-JAG (issued_token_type = …id-jag, token_type = N_A), signed with Domain A's realm key.
  5. step 5.1 - the decoded ID-JAG shows the three things Domain B uses to validate:
  • iss = …/realms/domaina -> who issued it
  • kid = Ftqg4DCf…, alg = RS256 -> which key signed it
  • aud = …/realms/domainb -> who it's for
  1. step 4.3 - Domain B validates and accepts it. When the client presents the ID-JAG to Domain B's token endpoint, Domain B reads iss, finds the trusted IdP whose issuer matches (domaina), fetches Domain A's public keys from that JWKS URL, picks the one matching kid, verifies the signature, then checks expiry/audience and maps to the local user.. and returns an access token. (if Domain B cound not resolve the issuer or signature not match, it would fail).
  2. step 5.2, its Domain B's token. if we introspecting at Domain B shows active = true, iss = …/realms/domainb, a token Domain B issued itself, carrying the original Domain A identity.

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)

6 participants