8000 Support issuing Identity Assertion JWT (ID-JAG) via token exchange by bkoragan · Pull Request #50288 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
83e85ff
Support issuing Identity Assertion JWT (ID-JAG) via token exchange
bkoragan Jun 24, 2026
ab25a18
Merge branch 'main' into issue-48818
bkoragan Jun 24, 2026
ce10e51
Validate ID-JAG subject_token type and audience
bkoragan Jun 24, 2026
547ea7c
Merge branch 'issue-48818' of https://github.com/bkoragan/keycloak in…
bkoragan Jun 24, 2026
50fe696
Potential fix for pull request finding
bkoragan Jun 24, 2026
b9f6d18
Sign ID-JAG with the realm default key and sync to the token-exchange…
bkoragan Jun 24, 2026
d304515
Merge branch 'issue-48818' of https://github.com/bkoragan/keycloak in…
bkoragan Jun 24, 2026
65b5882
Add per-client scope allow-listing for ID-JAG issuance
bkoragan Jun 24, 2026
62569ea
add guide for issuing Identity Assertion JWT (ID-JAG)
bkoragan Jun 24, 2026
ba47609
Merge branch 'main' into issue-48818
bkoragan Jun 24, 2026
601b6a2
Merge branch 'main' into issue-48818
bkoragan Jun 24, 2026
37adc49
Return a clear error when the ID-JAG subject_token is from another re…
bkoragan Jun 24, 2026
1fadba4
Merge branch 'issue-48818' of https://github.com/bkoragan/keycloak in…
bkoragan Jun 24, 2026
baa4164
Merge branch 'main' into issue-48818
bkoragan Jun 24, 2026
8762f9f
Potential fix for pull request finding
bkoragan Jun 24, 2026
1c081f8
Lint Fix formatting in ID-JAG advanced settinggs
bkoragan Jun 24, 2026
e4730d0
Merge branch 'issue-48818' of https://github.com/bkoragan/keycloak in…
bkoragan Jun 24, 2026
8f2bd6b
Merge branch 'main' into issue-48818
bkoragan Jun 24, 2026
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions core/src/main/java/org/keycloak/OAuth2Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ public interface OAuth2Constants {
// https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/
String IDENTITY_ASSERTION_JWT_HEADER_TYPE = "oauth-id-jag+jwt";

// requested_token_type / issued_token_type value for an ID-JAG (RFC 8693 token exchange).
String ID_JAG_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id-jag";

// Advertised in discovery (authorization_grant_profiles_supported) to signal ID-JAG issuance.
String ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag";


// https://tools.ietf.org/html/draft-ietf-oauth-assertions-01#page-5
String CLIENT_ASSERTION_TYPE = "client_assertion_type";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ public class OIDCConfigurationRepresentation {
@JsonProperty("grant_types_supported")
private List<String> grantTypesSupported;

// e.g. "urn:ietf:params:oauth:grant-profile:id-jag" to signal ID-JAG issuance support.
@JsonProperty("authorization_grant_profiles_supported")
private List<String> authorizationGrantProfilesSupported;

@JsonProperty("acr_values_supported")
private List<String> acrValuesSupported;

Expand Down Expand Up @@ -288,6 +292,14 @@ public void setGrantTypesSupported(List<String> grantTypesSupported) {
this.grantTypesSupported = grantTypesSupported;
}

public List<String> getAuthorizationGrantProfilesSupported() {
return authorizationGrantProfilesSupported;
}

public void setAuthorizationGrantProfilesSupported(List<String> authorizationGrantProfilesSupported) {
this.authorizationGrantProfilesSupported = authorizationGrantProfilesSupported;
}

public List<String> getAcrValuesSupported() {
return acrValuesSupported;
}
Expand Down
154 changes: 154 additions & 0 deletions docs/guides/securing-apps/identity-assertion-grant.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
<#import "/templates/guide.adoc" as tmpl>
<#import "/templates/links.adoc" as links>
<#import "/templates/features.adoc" as features>

<@tmpl.guide
title="Issuing Identity Assertion JWT Authorization Grants (ID-JAG)"
priority=135
summary="Guide for issuing an Identity Assertion JWT Authorization Grant (ID-JAG) so {project_name} can act as the enterprise identity provider in the Enterprise-Managed Authorization / Cross-App Access flow.">

<#include "/templates/experimental-feature.adoc">

This guide describes how {project_name} issues an *Identity Assertion JWT Authorization Grant (ID-JAG)*. With this feature {project_name} acts as the enterprise Identity Provider (IdP): a client that has already authenticated a user exchanges the user's ID token for a short-lived assertion that it can redeem for an access token at a different trust domain, such as a SaaS or Model Context Protocol (MCP) server, without sending the user through an interactive authorization step at that server.

This is the *issuer* side of the flow. The complementary *consumer* side, where a Resource Authorization Server validates an ID-JAG and returns an access token, is handled by the JWT Authorization Grant (see <@links.securingapps id="jwt-authorization-grant" />).

The ID-JAG builds on existing standards rather than defining new cryptography:

* link:https://datatracker.ietf.org/doc/html/rfc8693[OAuth 2.0 Token Exchange (RFC 8693)] - the assertion is requested through a token exchange.
* link:https://datatracker.ietf.org/doc/html/rfc7523[JWT Profile for OAuth 2.0 Client Authentication and Authorization Grants (RFC 7523)] - the assertion is a JWT authorization grant.
* link:https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/[Identity Assertion Authorization Grant] - the profile that constrains the above for cross-domain single sign-on. It is the single-enterprise specialization of <@links.securingapps id="oauth-identity-authorization-chaining-across-domains" />, and the mechanism behind MCP Enterprise-Managed Authorization (see <@links.securingapps id="mcp-authz-server" />).

== Enabling the feature

ID-JAG issuance is an experimental feature and must be explicitly enabled when starting {project_name}:

[source,bash]
----
bin/kc.[sh|bat] start --features=identity-assertion-jwt
----

When the feature is enabled, {project_name} advertises support in the realm OpenID Connect discovery document (`/realms/{realm}/.well-known/openid-configuration`) through the `authorization_grant_profiles_supported` field:

[source,json]
----
{
"authorization_grant_profiles_supported": [ "urn:ietf:params:oauth:grant-profile:id-jag" ]
}
----

NOTE: Token exchange must be available for the grant to be served. The fully supported Standard token exchange (V2) is enabled by default. See <@links.securingapps id="token-exchange" /> for more information.

== How it works

The client requests the assertion at the token endpoint as a token exchange whose `requested_token_type` is the ID-JAG token type. The processing that {project_name} performs is the following:

. The requesting client must be a *confidential* client. Public clients are rejected, because the assertion delegates the user's identity to a third-party authorization server and therefore requires client authentication.
. The client must have ID-JAG issuance enabled (see <<_configuration>>).
. The `subject_token` must be an ID token (`subject_token_type` is `urn:ietf:params:oauth:token-type:id_token`). It is validated against the realm and must be active. An access token presented as an ID token is rejected.
. The ID token must have been issued *to the requesting client* - a client can only exchange its own ID token, not one minted for a different client.
. The `audience` parameter is required and identifies the issuer of the Resource Authorization Server. It must be present in the client's allow-list, otherwise the request is denied. The value becomes the `aud` claim of the assertion.
. The `resource` parameter is optional. When present it identifies the resource server (for example an MCP server) and becomes the `resource` claim.
. When the client configures an allowed-scope list, every requested `scope` must be contained in it; otherwise an `invalid_scope` error is returned.
. {project_name} mints the assertion, signs it with the realm's default signature key, and returns it. The JWS header type is `oauth-id-jag+jwt`, so the Resource Authorization Server can recognize it. The assertion is validatable through the realm JWKS endpoint.

The assertion is short-lived (300 seconds) and never issues a refresh token.

NOTE: The `subject_token` must be an ID token issued by the *same* realm that performs the exchange. That realm is the enterprise IdP and the ID-JAG issuer. In a setup with two {project_name} realms, the realm that authenticated the user issues the ID-JAG, and the *other* realm consumes it through the JWT Authorization Grant (see <@links.securingapps id="jwt-authorization-grant" />) - not the other way around. An ID token issued by a different realm is rejected; {project_name} validates the assertion with the local realm keys and does not fetch a foreign issuer's keys here.

[[_configuration]]
== Configuration

Only confidential clients can issue an ID-JAG. Issuance is controlled per client through the following client attributes:

`id.jag.issuance.enabled`::
Set to `true` to allow the client to issue an ID-JAG. Disabled by default.

`id.jag.allowed.audiences`::
T 8BD0 he list of Resource Authorization Server issuer identifiers that this client may request an assertion for (the `audience` parameter, which becomes the `aud` claim). This is the trust gate: an empty list means the client cannot issue any assertion (deny by default). Multiple values are separated with `##`.

`id.jag.client.id`::
Optional. The `client_id` that the client is known by at the Resource Authorization Server, embedded as the `client_id` claim of the assertion. This can differ from the client's own client ID in {project_name}. When unset, the client's own client ID is used.

`id.jag.allowed.scopes`::
Optional. The scopes the client may request in an assertion. When set, a requested scope outside the list is rejected with `invalid_scope`. When unset, no restriction is applied and the Resource Authorization Server enforces its own scopes. Multiple values are separated with `##`.

These attributes can be set when creating or updating the client through the Admin REST API or a realm import. For example, in the client representation:

[source,json]
----
{
"clientId": "mcp-client",
"publicClient": false,
"directAccessGrantsEnabled": true,
"attributes": {
"id.jag.issuance.enabled": "true",
"id.jag.allowed.audiences": "https://auth.chat.example",
"id.jag.client.id": "mcp-client-at-resource-as",
"id.jag.allowed.scopes": "chat.read##chat.history"
}
}
----

== Examples

The following request exchanges the user's ID token for an ID-JAG. The client `mcp-client` authenticates with its secret, and is configured to allow issuance for the Resource Authorization Server `https://auth.chat.example`.

[source,bash]
----
POST /realms/demo/protocol/openid-connect/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Accept: application/json

client_id=mcp-client&
client_secret=XXXXX&
grant_type=urn:ietf:params:oauth:grant-type:token-exchange&
requested_token_type=urn:ietf:params:oauth:token-type:id-jag&
subject_token=eyJhbGci[...redacted...]&
subject_token_type=urn:ietf:params:oauth:token-type:id_token&
audience=https://auth.chat.example&
resource=https://mcp.chat.example/mcp&
scope=chat.read chat.history
----

If the request is valid, {project_name} returns the assertion in the `access_token` field. The `token_type` is `N_A` and the `issued_token_type` identifies the result as an ID-JAG.

[source,json]
----
{
"access_token": "eyJhbGci[...redacted...].eyJpc3Mi[...redacted...].J9l-ZhwP[...redacted...]",
"issued_token_type": "urn:ietf:params:oauth:token-type:id-jag",
"token_type": "N_A",
"expires_in": 300,
"scope": "chat.read chat.history"
}
----

The assertion is a JWT whose header declares the ID-JAG type:

[source,json]
----
{"a 10000 lg":"RS256", "kid":"2AOACLJmd5dQ8HPrDxwpkS-83yBhrzaLWSny9wmnYcY", "typ":"oauth-id-jag+jwt"}
----

The claims identify the issuing realm (`iss`), the user (`sub`), the Resource Authorization Server (`aud`), the resource server (`resource`), and the client's identity at that server (`client_id`):

[source,json]
----
{
"jti": "abcd1234-5678-efgh-ijkl-9012mnopqrst",
"iss": "https://keycloak.server/realms/demo",
"sub": "b3588c7e-14cb-46a9-9387-28adfd82f7a4",
"email": "user@example.com",
"aud": "https://auth.chat.example",
"resource": "https://mcp.chat.example/mcp",
"client_id": "mcp-client-at-resource-as",
"iat": 1764839065,
"exp": 1764839365,
"scope": "chat.read chat.history"
}
----

The client then redeems the assertion for an access token at the Resource Authorization Server using the JWT Authorization Grant. When that server is another {project_name} realm, see <@links.securingapps id="jwt-authorization-grant" /> for how it validates the assertion and returns an access token.

</@tmpl.guide>
1 change: 1 addition & 0 deletions docs/guides/securing-apps/specifications.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ This {section} presents a list of specifications and standards that {project_nam
|link:https://datatracker.ietf.org/doc/html/rfc8414[OAuth 2.0 Authorization Server Metadata (RFC 8414)]|Supported||
|link:https://datatracker.ietf.org/doc/html/rfc8628[OAuth 2.0 Device Authorization Grant (RFC 8628)]|Supported||
|link:https://datatracker.ietf.org/doc/html/rfc8693[OAuth 2.0 Token Exchange (RFC 8693)]|Supported (see comments)||Token exchange V2 only supports the internal to internal use-case, so the specification is only partially supported now. See <@links.securingapps id="token-exchange" /> for more information.
|link:https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/[Identity Assertion Authorization Grant] (Draft)|Experimental||Issuing side (ID-JAG). See <@links.securingapps id="identity-assertion-grant" />.
|link:https://datatracker.ietf.org/doc/html/rfc9101[The OAuth 2.0 Authorization Framework: JWT-Secured Authorization Request (JAR) (RFC 9101)]|Supported||
|link:https://datatracker.ietf.org/doc/html/rfc9207[OAuth 2.0 Authorization Server Issuer Identification (RFC 9207)]|Supported||
|link:https://datatracker.ietf.org/doc/html/rfc9449[OAuth 2.0 Demonstrating Proof of Possession (DPoP) (RFC 9449)]|Supported||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,10 @@ adminEvents=Admin events
serviceAccountHelp=Allows you to authenticate this client to Keycloak and retrieve access token dedicated to this client. In terms of OAuth2 specification, this enables support of 'Client Credentials Grant' for this client.
standardTokenExchangeEnabledHelp=Enable Standard Token Exchange V2 for this client.
jwtAuthorizationGrantEnabledHelp=Enable JSON Web Token (JWT) Profile for OAuth 2.0 Authorization Grant.
identityAssertionGrantEnabledHelp=Allow this client to exchange an ID token for an Identity Assertion JWT (ID-JAG) that can be redeemed at a Resource Authorization Server.
identityAssertionGrantAllowedAudiencesHelp=Resource Authorization Server issuer identifiers this client may request an ID-JAG for (the requested audience, which becomes the assertion aud claim). Empty means no assertion can be issued.
identityAssertionGrantClientIdHelp=The client_id this client is known by at the Resource Authorization Server, embedded as the ID-JAG client_id claim. When empty, the client's own client ID is used.
identityAssertionGrantAllowedScopesHelp=Scopes this client may request in an ID-JAG. When set, a requested scope outside the list is rejected. When empty, no restriction is applied.
jwtAuthorizationGrantIdpHelp=Select Allowed Identity Providers that can be used to for JWT authorization grant validation
jwtAuthorizationGrantAudience=Custom audience mapping
jwtAuthorizationGrantAudienceHelp=Custom audience mapping for the JWT Authorization Grant. The key is the Identity Provider alias and the value is the custom audience enforced for this provider.
Expand Down Expand Up @@ -1185,6 +1189,10 @@ ldapRolesDnHelp=LDAP DN where roles of this tree are saved. For example, 'ou\=fi
serviceAccount=Service account roles
standardTokenExchangeEnabled=Standard Token Exchange
jwtAuthorizationGrantEnabled=JWT Authorization Grant
identityAssertionGrantEnabled=Identity Assertion JWT Authorization Grant
identityAssertionGrantAllowedAudiences=ID-JAG Allowed Audiences
identityAssertionGrantClientId=ID-JAG Client ID at Resource Server
identityAssertionGrantAllowedScopes=ID-JAG Allowed Scopes
jwtAuthorizationGrantIdp=Allowed Identity Providers for JWT Authorization Grant
externalTokenEnabled=Allow retrieve external tokens
externalTokenEnabledHelp=If enabled the client is allowed to retrieve external tokens from the Identity Provider
Expand Down
41 changes: 41 additions & 0 deletions js/apps/admin-ui/src/clients/add/CapabilityConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ export const CapabilityConfig = ({
),
false,
);
setValue(
convertAttributeNameToForm<FormFields>(
"attributes.id.jag.issuance.enabled",
),
false,
);
setValue(
convertAttributeNameToForm<FormFields>(
"attributes.ssf.enabled",
Expand Down Expand Up @@ -349,6 +355,41 @@ export const CapabilityConfig = ({
/>
</GridItem>
)}
{isFeatureEnabled(Feature.IdentityAssertionJwt) && (
<GridItem lg={8} sm={6}>
<Controller
name={convertAttributeNameToForm<
Required<ClientRepresentation["attributes"]>
>("attributes.id.jag.issuance.enabled")}
defaultValue={false}
control={control}
render={({ field }) => (
<InputGroup>
<InputGroupItem>
<Checkbox
data-testid="identity-assertion-grant-enabled"
label={t("identityAssertionGrantEnabled")}
id="kc-identity-assertion-grant-enabled"
name="identity-assertion-grant-enabled"
isChecked={
field.value.toString() === "true" &&
!clientAuthentication
}
>
isDisabled={clientAuthentication}
/>
</InputGroupItem>
<InputGroupItem>
<HelpItem
helpText={t("identityAssertionGrantEnabledHelp")}
fieldLabelId="identityAssertionGrantEnabled"
/>
</InputGroupItem>
</InputGroup>
)}
/>
</GridItem>
)}
{isFeatureEnabled(Feature.DeviceFlow) && (
<GridItem lg={8} sm={6}>
<Controller
Expand Down
58 changes: 58 additions & 0 deletions js/apps/admin-ui/src/clients/advanced/AdvancedSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ export const AdvancedSettings = ({
[],
);

const identityAssertionGrantEnabled =
watch(
convertAttributeNameToForm<FormFields>(
"attributes.id.jag.issuance.enabled",
),
)?.toString() === "true";

const validAcrLoAOptions = () =>
acrLoAMapClient.length > 0
? acrLoAMapClient.map((i: any) => i?.key).filter((i: any) => i !== "")
Expand Down Expand Up @@ -273,6 +280,57 @@ export const AdvancedSettings = ({
label={t("minimumACRValue")}
labelIcon={t("minimumACRValueHelp")}
/>
{isFeatureEnabled(Feature.IdentityAssertionJwt) &&
identityAssertionGrantEnabled && (
<>
<FormGroup
label={t("identityAssertionGrantAllowedAudiences")}
fieldId="identityAssertionGrantAllowedAudiences"
labelIcon={
<HelpItem
helpText={t("identityAssertionGrantAllowedAudiencesHelp")}
fieldLabelId="identityAssertionGrantAllowedAudiences"
/>
}
>
<MultiLineInput
id="identityAssertionGrantAllowedAudiences"
aria-label="identityAssertionGrantAllowedAudiences"
name={convertAttributeNameToForm(
"attributes.id.jag.allowed.audiences",
)}
stringify
/>
</FormGroup>
<TextControl
type="text"
name={convertAttributeNameToForm(
"attributes.id.jag.client.id",
)}
label={t("identityAssertionGrantClientId")}
labelIcon={t("identityAssertionGrantClientIdHelp")}
/>
<FormGroup
label={t("identityAssertionGrantAllowedScopes")}
fieldId="identityAssertionGrantAllowedScopes"
labelIcon={
<HelpItem
helpText={t("identityAssertionGrantAllowedScopesHelp")}
fieldLabelId="identityAssertionGrantAllowedScopes"
/>
}
>
<MultiLineInput
id="identityAssertionGrantAllowedScopes"
aria-label="identityAssertionGrantAllowedScopes"
name={convertAttributeNameToForm(
"attributes.id.jag.allowed.scopes",
)}
stringify
/>
</FormGroup>
</>
)}
</>
)}
<ActionGroup>
Expand Down
1 change: 1 addition & 0 deletions js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export enum Feature {
QuickTheme = "QUICK_THEME",
StandardTokenExchangeV2 = "TOKEN_EXCHANGE_STANDARD_V2",
JWTAuthorizationGrant = "JWT_AUTHORIZATION_GRANT",
IdentityAssertionJwt = "IDENTITY_ASSERTION_JWT",
Passkeys = "PASSKEYS",
ClientAuthFederated = "CLIENT_AUTH_FEDERATED",
Workflows = "WORKFLOWS",
Expand Down
Loading
0