Post

Okta SAML and Keycloak for ID-JAG Cross App Access

A working Okta SAML → ID-JAG → Keycloak access-token path for Cross-App Access, including the two-leg SAML exchange, Okta AI Agent wiring, and resource-side JWT bearer grant.

Okta SAML and Keycloak for ID-JAG Cross App Access

The Identity Assertion JWT Authorization Grant (ID-JAG) draft, which is the core of Cross-App Access (XAA), standardizes how enterprises broker access to APIs and MCP resources across identity boundaries (think “SaaS” APIs and MCP servers).

Enterprises already use their Enterprise IdP for SSO, but SSO only identifies the user. It says nothing about that user’s permissions on GitHub, Asana, Figma, and so on. You might say “we already use SSO on those apps” … and you do, for login (usually to some UI). Those apps still need scoped OAuth access tokens for API access, not just an identity assertion. ID-JAG is the bridge: convert enterprise user identity into those scoped tokens, under IdP policy.

The flow looks like this:

  1. The user signs into the requesting app via SSO (OIDC ID Token or SAML assertion), so the app knows who they are.
  2. When the app needs to call an external resource (SaaS API, MCP server, etc.), it exchanges that identity assertion at the enterprise IdP for an intermediate ID-JAG via OAuth 2.0 Token Exchange. This is where the enterprise decides whether to allow the access, what scopes to grant, and how to manage the authorization lifecycle.
  3. The app presents the ID-JAG to the resource authorization server as a JWT authorization grant and receives a provider-scoped access token.
  4. The app uses that access token to call the resource (MCP tools, APIs, etc.).

The user never sees an OAuth consent screen at the SaaS app. Access is pre-decided by the enterprise IdP and controlled by admin policy.

I recently dug into a working example with Okta and Keycloak. Since ID-JAG is an open OAuth draft / spec, the most important part of the story is interoperability. And to take it one step further: although the happy path in the draft is OIDC for user identity assertions, enterprises still standardize around SAML. So let’s build an Okta SAML-based requesting app, follow the ID-JAG SAML profile, and redeem that grant at a different identity domain — Keycloak standing in for the resource authorization server.

Everything here is reproducible from christian-posta/okta-saml-idjag.

Actors in this demo

Worth naming explicitly up front, because “app” and “agent” blur quickly:

RoleIn this demoWhat it does
UserOkta test user (mcpuser@…)Authenticates via SAML SSO
Requesting appLocal demo app (localhost:4141)Holds the SAML session, drives both token exchanges, calls the resource
Enterprise IdPOkta (Integrator / XAA early access)Issues SAML assertions and ID-JAGs under admin policy
AI Agent (OAuth client)Okta “AI Agent” registrationThe private_key_jwt client that actually calls Okta’s token endpoint
Resource ASKeycloak realm idjag-resourceTrusts Okta’s ID-JAG, mints its own access token
Resource clientidjag-demo-clientClient that presents the ID-JAG at Keycloak (client_id claim must match)

The requesting app and the Okta AI Agent are related but not the same object. The user signs into the SAML app; the AI Agent is the OAuth client allowed to exchange that identity for an ID-JAG.

The end-to-end path (including the SAML hop)

OIDC-shaped XAA is often drawn as one token exchange at the IdP, then a JWT bearer grant at the resource. With SAML, the draft adds a pre-step: turn the SAML assertion into a refresh token first, then exchange that refresh token for the ID-JAG.

sequenceDiagram
    actor User
    participant App as Requesting app
    participant Okta as Okta (enterprise IdP)
    participant KC as Keycloak (resource AS)
    participant API as Resource API

    User->>App: Login
    App->>Okta: SAML AuthnRequest
    Okta-->>App: SAML Assertion

    Note over App,Okta: Leg 1 — SAML → refresh token
    App->>Okta: Token Exchange<br/>subject_token=SAML (saml2)<br/>requested=refresh_token
    Okta-->>App: refresh_token

    Note over App,Okta: Leg 2 — refresh → ID-JAG
    App->>Okta: Token Exchange<br/>subject_token=refresh_token<br/>requested=id-jag<br/>aud=Keycloak issuer
    Okta-->>App: ID-JAG (aud=Keycloak)

    Note over App,KC: Cross trust domain
    App->>KC: JWT Bearer Grant<br/>assertion=ID-JAG
    KC-->>App: access_token (Keycloak-issued)

    App->>API: Bearer access_token
    API-->>App: resource response

Why the extra leg? A SAML assertion proves the IdP authenticated the user, but it is not a durable OAuth subject the way an ID token or refresh token is. The SAML interoperability section profiles assertion → refresh, then refresh → ID-JAG. With OIDC you can often go ID Token → ID-JAG in one exchange; with SAML you pay the two-leg tax. That is intentional in the draft, not an Okta quirk.

Setting up Okta for SAML and XAA

Okta has early access to XAA in its enterprise product and in free Integrator accounts. We build a demo app that logs a user in with SAML:

Demo login screen

Okta needs three objects plus I use a local signing key (instead of client secrets). Here’s a flow diagram of the pieces:

1
2
3
4
5
6
7
8
9
10
11
12
13
  user logs in ─SAML SSO▶  SAML app  (requesting / SSO app)
                                  │
                                  │ Delegation: "User sign-on" caller
                                  ▼
                           AI Agent  (OAuth client for token exchange)
                                  │    Credentials: your public JWK (RS256)
                                  │    → agent client_id
                                  │
                                  │ Resource connection (Application)
                                  ▼
                           Resource app (OIDC)
                           Resource Server tab: Enable XAA + Issuer URL
                           → becomes the ID-JAG `aud` (Keycloak realm)
Okta objectRoleKey fields
SAML appApp the user signs into. Its assertion is the subject of leg 1.ACS, Audience, NameID=email
AI AgentOAuth client that authenticates the token exchange via private_key_jwt. Not a normal OIDC app.Credentials (public key), Delegation → SAML app, Resource connection → resource app
Resource app (OIDC)Defines the resource being accessed; Resource Server issuer URL becomes ID-JAG aud.Enable XAA + Issuer URL = Keycloak realm issuer
Local RSA keypairSigns the agent’s client_assertion. Public half registered on the agent.e.g. kid=okta-xaa-1

Full click-path: docs/okta-setup.md. One constraint that matters early: the resource issuer must be a real, reachable, distinct authorization server (not Okta’s own org AS). Okta validates RFC 8414 metadata when you save the Resource app, so Keycloak (exposed publicly, e.g. via ngrok) needs to be up first.

SAML app and login

Create the Okta SAML app, wire ACS / audience, assign users/groups:

Okta SAML app settings

Okta SAML ACS and audience

Once that is wired into the demo app, login should land on a dashboard:

Demo app dashboard after SAML login

Leg 1 and leg 2 at Okta’s token endpoint

From the SAML interoperability section, exchange the SAML assertion for a refresh token first:

1
2
3
4
5
6
7
8
9
10
11
POST /oauth2/token HTTP/1.1
Host: acme.idp.example
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&requested_token_type=urn:ietf:params:oauth:token-type:refresh_token
&scope=openid+offline_access+email
&subject_token=PHNhbWxwOkFzc2VydGlvbiB4bWxuczp...c2FtbDppc3N1ZXI+PC9zYW1sOkFzc2VydGlvbj4=
&subject_token_type=urn:ietf:params:oauth:token-type:saml2
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJhbGciOiJSUzI1NiIsImtpZCI6IjIyIn0...
1
2
3
4
5
6
7
8
9
10
11
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store

{
  "issued_token_type": "urn:ietf:params:oauth:token-type:refresh_token",
  "access_token": "vF9dft4qmTcXkZ26zL8b6u",
  "token_type": "N_A",
  "scope": "openid offline_access email",
  "expires_in": 1209600
}

Then follow the refresh → ID-JAG exchange:

1
2
3
4
5
6
7
8
9
10
11
12
POST /oauth2/token HTTP/1.1
Host: acme.idp.example
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&requested_token_type=urn:ietf:params:oauth:token-type:id-jag
&audience=https://keycloak.example/realms/idjag-resource
&scope=demo:read+demo:write
&subject_token=tGzv3JOkF0XG5Qx2TlKWIA
&subject_token_type=urn:ietf:params:oauth:token-type:refresh_token
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJhbGciOiJSUzI1NiIsImtpZCI6IjIyIn0...

A few Okta-specific notes on that second call:

  • Authenticate as the AI Agent with private_key_jwt (iss/sub = agent client_id, aud = Okta token endpoint).
  • Set audience to the Keycloak realm issuer (the Resource app’s XAA issuer URL).
  • Do not send a resource parameter — Okta rejects it here ('resource' is invalid or not supported), even though some draft examples include it.

In the demo UI, Get ID-JAG runs both legs:

Leg 1: SAML assertion exchanged for refresh token

Leg 2: refresh token exchanged for ID-JAG

ID-JAG result minted by Okta

What the ID-JAG actually carries

An annotated shape of what Okta minted in this run (values shortened):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// header
{
  "alg": "RS256",
  "kid": "…",
  "typ": "oauth-id-jag+jwt"   // marks this as an ID-JAG, not a normal access token
}

// claims
{
  "iss": "https://integrator-….okta.com",                 // enterprise IdP
  "aud": "https://…/realms/idjag-resource",               // Keycloak realm issuer
  "client_id": "idjag-demo-client",                       // client that will redeem at Keycloak
  "sub": "00u…",                                          // Okta user id
  "email": "mcpuser@example.org",                         // from SAML NameID / profile
  "act": { "sub": "wlp…", "sub_profile": "ai_agent" },    // the AI Agent that did the exchange
  "scope": "demo:read demo:write",                        // scopes for the *resource* domain
  "exp":                                                 // short-lived (~300s on Okta)
}

Read that carefully:

  • aud is Keycloak, not Okta — the grant is audience-restricted to the resource AS.
  • client_id is the client that will present the grant at Keycloak (idjag-demo-client), not the Okta AI Agent client id.
  • scope values are resource-domain scopes, decided by Okta policy / resource connection.
  • act attributes the exchange to the AI Agent acting on the user’s behalf.
  • This is a grant, not an access token. It cannot call the API directly; it only exists to be redeemed.

Exchange the ID-JAG across an identity boundary

For the resource side, we run Keycloak with the JWT Authorization Grant feature (GA in Keycloak 26.6). That is the RFC 7523 grant that consumes an ID-JAG. Keycloak is also working on the enterprise-IdP side (issuing ID-JAGs); here it only plays resource AS.

Setup details: docs/keycloak-setup.md. The three pieces that matter:

  1. Identity Provider of type jwt-authorization-grant, trusting Okta’s iss + org JWKS.
  2. Confidential client idjag-demo-client with JWT authorization grant enabled, allowed IdPs including that Okta IdP.
  3. Pre-provisioned user linked by federated identity (okta-idjag, Okta sub) — this grant is non-interactive, so JIT / first-broker-login does not run.

Keycloak client with JWT authorization grant

Back in the demo, Exchange at Keycloak:

Demo UI: exchange ID-JAG at Keycloak

The HTTP shape is RFC 7523 jwt-bearer (not another token exchange):

1
2
3
4
5
6
7
POST /realms/idjag-resource/protocol/openid-connect/token HTTP/1.1
Host: keycloak.example
Authorization: Basic base64(idjag-demo-client:…)
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&assertion=eyJhbGciOiJSUzI1NiIsInR5cCI6Im9hdXRoLWlkLWphZytqd3QiLC… 

Keycloak then:

  1. Matches iss → the Okta jwt-authorization-grant IdP
  2. Verifies the signature against Okta’s JWKS
  3. Checks aud equals this realm’s issuer
  4. Checks ID-JAG client_id equals the authenticating client
  5. Resolves the user by (okta-idjag, sub)
  6. Issues its own access token

JWT assertion grant request to Keycloak

Access token issued by Keycloak

Calling the resource with the Keycloak access token

That last hop is the point of XAA: Okta brokered cross-domain API access using the same SSO trust relationship, without a consent screen at Keycloak / the resource.

Gotchas that ate the most time
  • The token-exchange client must be the AI Agent with private_key_jwt. A normal OIDC Web/API app tends to land in On-Behalf-Of and fail with actor_token missing.
  • audience must be a distinct, reachable resource AS issuer (RFC 8414). Never Okta's own org AS — that yields invalid_target.
  • Okta rejects a resource parameter on this exchange even when draft examples show one.
  • SAML subject_token is standard base64, not base64url.
  • Keycloak must advertise an https issuer (e.g. set KC_HOSTNAME to the ngrok URL) or Okta will not accept it as the XAA resource.
  • Pre-provision the Keycloak user with federated identity keyed on the Okta sub, or redemption fails with User not found.
  • ID-JAG lifetime on Okta is short (~300s) — redeem promptly.

Recreate This Demo

This path works end-to-end today:

Okta (SAML enterprise IdP + XAA) → ID-JAG → Keycloak (resource AS JWT authorization grant) → access token → API call

That is the interoperability story that matters for ID-JAG: different vendors, different trust domains, SAML on the enterprise side, open-source AS on the resource side.

If you want to reproduce: github.com/christian-posta/okta-saml-idjag. Follow along / connect on LinkedIn if you are working through XAA / ID-JAG in your own stack.

This post is licensed under CC BY 4.0 by the author.

Books I've Written