Cross-App Access (XAA)

TL;DR — Two different things called “enterprise IdP integration”. OIDC federation is about user login — the user’s identity comes from your corporate IdP. XAA (Cross-App Access) is about agent identity — your IdP signs a JWT asserting the agent’s identity, AuthPlane validates it against the IdP’s JWKS and mints an MCP token without a consent screen. Central policy replaces per-user consent for enterprise-controlled agent fleets. Uses the JWT Bearer grant (RFC 7523) with the ID-JAG assertion type (oauth-id-jag+jwt) — an emerging IETF draft referenced by the MCP Authorization spec’s Enterprise-Managed Authorization profile.

The problem it solves

Per-user consent screens are fine for one user picking one integration. They break down when:

  • You have a fleet of enterprise agents (headless CI runners, background jobs, backend services acting as themselves) that can’t consent.
  • You want central policy control — “these agents can access these tools with these scopes” — determined by the enterprise IT team, not by each user clicking approve.
  • Compliance requires that agent access is enterprise-attested, not user-consented.

XAA moves consent from “user clicks approve” to “enterprise-issued ID-JAG proves agent identity → AuthPlane policy allows”.

The wire flow

1. Your enterprise IdP signs an ID-JAG for an agent:
     JWT header: {typ: "oauth-id-jag+jwt"}
     Payload: {iss: <IdP>, aud: <AuthPlane>, sub: <agent-identity>, exp, iat, jti}
     Signed with IdP's private key.

2. Agent → AuthPlane   POST /oauth/token
                          grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
                          assertion=<the ID-JAG>
                          client_id=$AGENT_CLIENT_ID
                          client_secret=$AGENT_SECRET
                          resource=https://mcp.example.com/mcp
                          scope=tools/read

3. AuthPlane validates:
     - Signature (against IdP's cached JWKS)
     - iss matches a registered trusted IdP
     - aud matches the IdP's registered audience
     - exp within max_assertion_age (default 5m)
     - jti not previously used (replay guard)

4. AuthPlane runs policy engine:
     - (IdP × client × scope × resource) tuple must match at least one policy
     - Deny by default

5. AuthPlane mints an MCP access token — no consent screen involved

6. Agent ← AuthPlane   { access_token, expires_in: 3600 }

Enterprises often need both at the same time:

  • OIDC federation (topologies/oidc-federated-login) — human users log into AuthPlane via their corporate IdP.
  • XAA — enterprise agents get MCP tokens from AuthPlane via ID-JAG.

They stack. Users log in via SSO; agents authenticate via XAA. Same corporate IdP, two different authentication paths at AuthPlane.

What an ID-JAG contains

The JWT your IdP signs. AuthPlane accepts one very specific typ: oauth-id-jag+jwt.

Header:

{
  "alg": "RS256|ES256|PS256",
  "typ": "oauth-id-jag+jwt",
  "kid": "<idp-signing-key-id>"
}

Payload:

{
  "iss": "https://acme.okta.com",           // your IdP's issuer
  "aud": "https://auth.example.com",        // AuthPlane's audience
  "sub": "<agent-identity-in-your-IdP>",    // per your IdP convention
  "exp": 1708762500,
  "iat": 1708762200,
  "jti": "unique-assertion-id"
}

Extra claims (groups, department, custom) are accepted but ignored by policy today — future versions may make them policy inputs.

Policy engine

Policies describe which (IdP × client × scope × resource) combinations are allowed.

Policy: "Allow Acme agents"
  IdP: idp_abc123 (Acme Okta)
  Client IDs: [my-mcp-client]        (or empty = any client registered against this IdP)
  Scopes: [tools/echo, tools/search]  (or empty = client's default scopes)
  Resources: [https://mcp.example.com/mcp]  (or empty = any resource)

Multiple policies can apply; if ANY allows, the request proceeds. Deny by default — an ID-JAG that matches no policy is rejected with access_denied.

Scope narrowing at issue time: issued_scope = intersection(requested_scope, policy.scopes). Explicit least-privilege.

Subject mapping

Two modes:

  • auto_map (default) — external subjects accepted as-is. Token’s sub = {iss}:{sub} (e.g., https://acme.okta.com:alice@acme.com).
  • strict — only explicitly mapped subjects allowed. Unmapped subjects → access_denied.

Explicit mappings translate external subjects to your own local user IDs:

Mapping: idp_abc123 / alice@acme.com  →  usr_local_alice

Useful when you want AuthPlane audit rows tied to your own user IDs, not the IdP’s opaque strings.

Replay guard

Every ID-JAG’s jti is stored on first use (in assertion_jti_store). A second use → invalid_grant "assertion jti already used". Assertions are single-use.

The assertion_jti table is one of the targets of authserver purge — schedule externally per Operate: Backup, upgrade, purge.

Metrics

  • authplane_xaa_policy_evaluation_total{decision="allow|deny"} — every policy check
  • authplane_xaa_idp_operations_total — IdP registry CRUD + JWKS refresh
  • authplane_xaa_subject_resolutions_total — subject mapping resolutions

Alert on rate(authplane_xaa_policy_evaluation_total{decision="deny"}[5m]) > 0 in normal ops — either policy misconfig or unauthorized attempts.

Test playground

xaa.dev — public XAA test IdP built on Okta. Two-minute setup with ngrok, no enterprise tenant required. Full walkthrough in authserver/docs/how-to/test-xaa-with-xaa-dev.md.