Support issuing Identity Assertion JWT (ID-JAG) via token exchange - #50288
Support issuing Identity Assertion JWT (ID-JAG) via token exchange#50288bkoragan wants to merge 18 commits into
Conversation
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>
There was a problem hiding this comment.
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_idclaim. - 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. |
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>
…to issue-48818
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>
… 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>
…to issue-48818
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>
|
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 For example: @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 |
|
@bkoragan could you rebase this on main again? I currently see conflicts: |
|
I could execute your demo on macos with a slightly updated script: 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. |
…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>
…to issue-48818
@thomasdarimont Incorporated the ID-JAG config changes for console. Please review: |
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>
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>
…to issue-48818
|
It’s a good point raised by @thomasdarimont, since I was expecting cross-trust-domain support. 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 and @embesozzi for the feedback. My understanding is that the primary use case would be the following:
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? |
Thanks @thomasdarimont @embesozzi @VinodAnandan . let me walk through how I'm thinking about it.
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 ==================== ==================== 2. Configure domaina (issuer / enterprise IdP) ==================== ==================== 3. Configure domainb (resource AS / receiver) + link user ==================== ==================== 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 STEP 4.2 Token exchange @ domaina -> ID-JAG [ISSUER SIDE / PR #50288] REQUEST POST http://localhost:8080/realms/domaina/protocol/openid-connect/token STEP 4.3 jwt-bearer grant @ domainb -> access token [RECEIVER SIDE / #49005] REQUEST POST http://localhost:8080/realms/domainb/protocol/openid-connect/token ==================== 5. Verify ==================== STEP 5.1 Decode the minted ID-JAG ID-JAG header: STEP 5.2 Introspect the domainb access token @ domainb REQUEST POST http://localhost:8080/realms/domainb/protocol/openid-connect/token/introspect ==================== RESULT ==================== Please correct if I'm missing specific case/scenario here. |
|
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. |
|
@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. |
@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):
|
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.
Closes #48818
Sequence Flow:
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 %