{"slug": "okta-saml-and-keycloak-for-id-jag-cross-app-access", "title": "Okta SAML and Keycloak for ID-JAG Cross App Access", "summary": "Okta SAML and Keycloak can interoperate via the Identity Assertion JWT Authorization Grant (ID-JAG) draft to enable cross-app access, according to a demo by Christian Posta. The flow converts enterprise SAML assertions into scoped OAuth tokens for external APIs without user-facing consent screens. The demo uses Okta as the enterprise IdP and Keycloak as the resource authorization server, following the ID-JAG SAML profile.", "body_md": "# Okta SAML and Keycloak for ID-JAG Cross App Access\n\nA working Okta SAML/SSO → ID-JAG → Keycloak access-token path for Cross-App Access\n\nThe [Identity Assertion JWT Authorization Grant (ID-JAG)](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/) draft, which is the core of [Cross-App Access (XAA)](https://xaa.dev), standardizes how enterprises broker access to APIs and MCP resources across identity boundaries (think “calling from enterprise apps to SaaS APIs and MCP servers”).\n\nEnterprises already use their Enterprise IdP for SSO, but SSO only identifies the user. It says nothing about that user’s service-specific permissions on GitHub, Asana, Figma, and so on. You might say “we already use SSO on those apps, why is that not enough” … 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.\n\nThe flow looks like this:\n\n- The user signs into enterprise app/agent via SSO (OIDC ID Token or SAML assertion), so the app knows who they are.\n- 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\n**ID-JAG** via[OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693). This is where the enterprise decides whether to allow the access, what scopes to grant, and how to manage the authorization lifecycle. - The app presents the ID-JAG to the resource authorization server as a\n[JWT authorization grant](https://datatracker.ietf.org/doc/html/rfc7523)and receives a provider-scoped access token. - The app uses that access token to call the resource (MCP tools, APIs, etc.).\n\nThe user never sees an OAuth consent screen at the SaaS app. Access is pre-decided by the enterprise IdP and controlled by admin policy.\n\nI recently dug into a working example with Okta as the enterprise IdP and Keycloak as the resource/service IdP. 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 primarily uses 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 in the draft, and redeem that grant at a *different* identity domain: Keycloak standing in for the resource authorization server.\n\nEverything here is reproducible from [christian-posta/okta-saml-idjag](https://github.com/christian-posta/okta-saml-idjag).\n\nTL;DR here is a demo video of this blog:\n\n## Actors in this demo\n\nWorth naming explicitly up front, because “app” and “agent” blur quickly:\n\n| Role | In this demo | What it does |\n|---|---|---|\nUser | Okta test user (`mcpuser@…` ) | Authenticates via SAML SSO |\nRequesting app | Local demo app (`localhost:4141` ) | Holds the SAML session, drives both token exchanges, calls the resource |\nEnterprise IdP | Okta (Integrator / XAA early access) | Issues SAML assertions and ID-JAGs under admin policy |\nAI Agent (OAuth client) | Okta “AI Agent” registration | The `private_key_jwt` client that actually calls Okta’s token endpoint |\nResource AS | Keycloak realm `idjag-resource` | Trusts Okta’s ID-JAG, mints its own access token |\nResource client | `idjag-demo-client` | Client that presents the ID-JAG at Keycloak (`client_id` claim must match) |\n\nThe 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.\n\n## The end-to-end path (including the SAML hop)\n\nOIDC-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`\n\nfirst, then exchange that `refresh token`\n\nfor the ID-JAG.\n\n```\nsequenceDiagram\n    actor User\n    participant App as Requesting app\n    participant Okta as Okta (enterprise IdP)\n    participant KC as Keycloak (resource AS)\n    participant API as Resource API\n\n    User->>App: Login\n    App->>Okta: SAML AuthnRequest\n    Okta-->>App: SAML Assertion\n\n    Note over App,Okta: Leg 1 — SAML → refresh token\n    App->>Okta: Token Exchange<br/>subject_token=SAML (saml2)<br/>requested=refresh_token\n    Okta-->>App: refresh_token\n\n    Note over App,Okta: Leg 2 — refresh → ID-JAG\n    App->>Okta: Token Exchange<br/>subject_token=refresh_token<br/>requested=id-jag<br/>aud=Keycloak issuer\n    Okta-->>App: ID-JAG (aud=Keycloak)\n\n    Note over App,KC: Cross trust domain\n    App->>KC: JWT Bearer Grant<br/>assertion=ID-JAG\n    KC-->>App: access_token (Keycloak-issued)\n\n    App->>API: Bearer access_token\n    API-->>App: resource response\n```\n\nWhy 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](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-assertion-authz-grant-04.html#name-saml-20-subject-token-inter) 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.\n\n## Setting up Okta for SAML and XAA\n\nOkta has early access to XAA in its enterprise product and in free [Integrator accounts](https://developer.okta.com). We build a demo app that logs a user in with SAML:\n\n### The three Okta objects that have to link\n\nOkta needs **three objects** plus I use a local signing key (instead of client secrets). Here’s a flow diagram of the pieces:\n\n| Okta object | Role | Key fields |\n|---|---|---|\nSAML app | App the user signs into. Its assertion is the subject of leg 1. | ACS, Audience, NameID=email |\nAI Agent | OAuth 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 |\nResource app (OIDC) | Defines the resource being accessed; Resource Server issuer URL becomes ID-JAG `aud` . | Enable XAA + Issuer URL = Keycloak realm issuer |\nLocal RSA keypair | Signs the agent’s `client_assertion` . Public half registered on the agent. | e.g. `kid=okta-xaa-1` |\n\nFull click-path: [docs/okta-setup.md](https://github.com/christian-posta/okta-saml-idjag/blob/main/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.\n\n### SAML app and login\n\nCreate the Okta SAML app, wire ACS / audience, assign users/groups:\n\nOnce that is wired into the demo app, login should land on a dashboard:\n\n### Leg 1 and leg 2 at Okta’s token endpoint\n\nFrom the [SAML interoperability section](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-assertion-authz-grant-04.html#name-saml-20-subject-token-inter), exchange the SAML assertion for a refresh token first:\n\n```\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\nPOST /oauth2/token HTTP/1.1\nHost: acme.idp.example\nContent-Type: application/x-www-form-urlencoded\n\ngrant_type=urn:ietf:params:oauth:grant-type:token-exchange\n&requested_token_type=urn:ietf:params:oauth:token-type:refresh_token\n&scope=openid+offline_access+email\n&subject_token=PHNhbWxwOkFzc2VydGlvbiB4bWxuczp...c2FtbDppc3N1ZXI+PC9zYW1sOkFzc2VydGlvbj4=\n&subject_token_type=urn:ietf:params:oauth:token-type:saml2\n&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer\n&client_assertion=eyJhbGciOiJSUzI1NiIsImtpZCI6IjIyIn0...\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\nHTTP/1.1 200 OK\nContent-Type: application/json\nCache-Control: no-store\n\n{\n  \"issued_token_type\": \"urn:ietf:params:oauth:token-type:refresh_token\",\n  \"access_token\": \"vF9dft4qmTcXkZ26zL8b6u\",\n  \"token_type\": \"N_A\",\n  \"scope\": \"openid offline_access email\",\n  \"expires_in\": 1209600\n}\n```\n\nThen follow the [refresh → ID-JAG exchange](https://www.ietf.org/archive/id/draft-ietf-oauth-identity-assertion-authz-grant-04.html#name-example-token-exchange-using):\n\n```\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\nPOST /oauth2/token HTTP/1.1\nHost: acme.idp.example\nContent-Type: application/x-www-form-urlencoded\n\ngrant_type=urn:ietf:params:oauth:grant-type:token-exchange\n&requested_token_type=urn:ietf:params:oauth:token-type:id-jag\n&audience=https://keycloak.example/realms/idjag-resource\n&scope=demo:read+demo:write\n&subject_token=tGzv3JOkF0XG5Qx2TlKWIA\n&subject_token_type=urn:ietf:params:oauth:token-type:refresh_token\n&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer\n&client_assertion=eyJhbGciOiJSUzI1NiIsImtpZCI6IjIyIn0...\n```\n\nA few Okta-specific notes on that second call:\n\n- Authenticate as the\n**AI Agent** with`private_key_jwt`\n\n(`iss`\n\n/`sub`\n\n= agent`client_id`\n\n,`aud`\n\n= Okta token endpoint). - Set\n`audience`\n\nto the Keycloak realm issuer (the Resource app’s XAA issuer URL). - Do\n**not** send a`resource`\n\nparameter — Okta rejects it here (`'resource' is invalid or not supported`\n\n), even though some draft examples include it.\n\nIn the demo UI, **Get ID-JAG** runs both legs:\n\n### What the ID-JAG actually carries\n\nAn annotated shape of what Okta minted in this run (values shortened):\n\n```\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n// header\n{\n  \"alg\": \"RS256\",\n  \"kid\": \"…\",\n  \"typ\": \"oauth-id-jag+jwt\"   // marks this as an ID-JAG, not a normal access token\n}\n\n// claims\n{\n  \"iss\": \"https://integrator-….okta.com\",                 // enterprise IdP\n  \"aud\": \"https://…/realms/idjag-resource\",               // Keycloak realm issuer\n  \"client_id\": \"idjag-demo-client\",                       // client that will redeem at Keycloak\n  \"sub\": \"00u…\",                                          // Okta user id\n  \"sub_profile\": \"user\",                                  // entity profile, \"user\" (or \"ai_agent\")\n  \"email\": \"mcpuser@example.org\",                         // from SAML NameID / profile\n  \"act\": { \"sub\": \"wlp…\", \"sub_profile\": \"ai_agent\" },    // the AI Agent that did the exchange\n  \"scope\": \"demo:read demo:write\",                        // scopes for the *resource* domain\n  \"exp\": …                                                // short-lived (~300s on Okta)\n}\n```\n\nRead that carefully:\n\n`aud`\n\nis**Keycloak**, not Okta — the grant is audience-restricted to the resource AS.`client_id`\n\nis the client that will present the grant at Keycloak (`idjag-demo-client`\n\n), not the Okta AI Agent client id.`scope`\n\nvalues are resource-domain scopes, decided by Okta policy / resource connection.`act`\n\nattributes the exchange to the AI Agent acting on the user’s behalf.`sub`\n\nOkta user id`sub_profile`\n\nfrom the new[Entity Profiles draft](https://www.ietf.org/archive/id/draft-mora-oauth-entity-profiles-01.html#name-entity-profiles)- This is a\n**grant**, not an access token. It cannot call the API directly; it only exists to be redeemed.\n\n## Exchange the ID-JAG across an identity boundary\n\nFor the resource side, [we run Keycloak](https://github.com/christian-posta/okta-saml-idjag/blob/main/docker-compose.yaml) with the [JWT Authorization Grant](https://www.keycloak.org/2026/01/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](https://github.com/keycloak/keycloak/issues/48818) the enterprise-IdP side (issuing ID-JAGs); here it only plays resource AS.\n\nSetup details: [docs/keycloak-setup.md](https://github.com/christian-posta/okta-saml-idjag/blob/main/docs/keycloak-setup.md). The three pieces that matter:\n\n**Identity Provider** of type`jwt-authorization-grant`\n\n, trusting Okta’s`iss`\n\n+ org JWKS.**Confidential client**`idjag-demo-client`\n\nwith JWT authorization grant enabled, allowed IdPs including that Okta IdP.**Pre-provisioned user** linked by federated identity`(okta-idjag, Okta sub)`\n\n— this grant is non-interactive, so JIT / first-broker-login does not run.\n\nBack in the demo, **Exchange at Keycloak**:\n\nThe HTTP shape is RFC 7523 jwt-bearer (not another token exchange):\n\n```\n1\n2\n3\n4\n5\n6\n7\nPOST /realms/idjag-resource/protocol/openid-connect/token HTTP/1.1\nHost: keycloak.example\nAuthorization: Basic base64(idjag-demo-client:…)\nContent-Type: application/x-www-form-urlencoded\n\ngrant_type=urn:ietf:params:oauth:grant-type:jwt-bearer\n&assertion=eyJhbGciOiJSUzI1NiIsInR5cCI6Im9hdXRoLWlkLWphZytqd3QiLC…\n```\n\nKeycloak then:\n\n- Matches\n`iss`\n\n→ the Okta jwt-authorization-grant IdP - Verifies the signature against Okta’s JWKS\n- Checks\n`aud`\n\nequals this realm’s issuer - Checks ID-JAG\n`client_id`\n\nequals the authenticating client - Resolves the user by\n`(okta-idjag, sub)`\n\n- Issues its\n**own** access token\n\nThat 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.\n\n**Gotchas that ate the most time**\n\n- The token-exchange client must be the\n**AI Agent**... we use`private_key_jwt`\n\nto identify the client (instead of client secret). If you try to use a normal OIDC Web/API, the Okta flow tends to land in On-Behalf-Of and fail with`actor_token missing`\n\n. - Okta rejects a\n`resource`\n\nparameter on this token exchange even when draft examples show one. `audience`\n\nmust be a**distinct, reachable** resource AS issuer (RFC 8414).- SAML\n`subject_token`\n\nis**standard base64**, not base64url. - Keycloak must advertise an\n`https`\n\nissuer (e.g. set`KC_HOSTNAME`\n\nto 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\n`sub`\n\n, or redemption fails with`User not found`\n\n. - ID-JAG lifetime on Okta is short (~300s) ... redeem promptly.\n\n## Recreate This Demo\n\nThis path works end-to-end today:\n\n**Okta (SAML enterprise IdP + XAA) → ID-JAG → Keycloak (resource AS JWT authorization grant) → access token → API call**\n\nThat 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.\n\nIf you want to reproduce: [github.com/christian-posta/okta-saml-idjag](https://github.com/christian-posta/okta-saml-idjag). Follow along / [connect on LinkedIn](https://linkedin.com/in/ceposta) if you are working through XAA / ID-JAG in your own stack.\n\n[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)by the author.", "url": "https://wpnews.pro/news/okta-saml-and-keycloak-for-id-jag-cross-app-access", "canonical_source": "https://blog.christianposta.com/okta-saml-and-keycloak-for-id-jag-cross-app-access/", "published_at": "2026-07-13 01:05:21+00:00", "updated_at": "2026-07-28 00:23:00.317561+00:00", "lang": "en", "topics": ["developer-tools", "ai-infrastructure"], "entities": ["Okta", "Keycloak", "Christian Posta", "Identity Assertion JWT Authorization Grant", "Cross-App Access", "SAML", "OAuth 2.0 Token Exchange", "JWT authorization grant"], "alternates": {"html": "https://wpnews.pro/news/okta-saml-and-keycloak-for-id-jag-cross-app-access", "markdown": "https://wpnews.pro/news/okta-saml-and-keycloak-for-id-jag-cross-app-access.md", "text": "https://wpnews.pro/news/okta-saml-and-keycloak-for-id-jag-cross-app-access.txt", "jsonld": "https://wpnews.pro/news/okta-saml-and-keycloak-for-id-jag-cross-app-access.jsonld"}}