Enterprise-Managed Auth (XAA)

TL;DR — Your corporate IdP (Okta, Entra ID, Auth0) signs an “ID-JAG” JWT asserting agent identity. AuthPlane validates it against the IdP’s JWKS and mints an MCP token — skipping per-user consent for policy-approved agent×scope×resource combinations. Requires xaa.enabled: true, one or more trusted IdP registrations, at least one policy, and MCP clients registered with the jwt-bearer grant. YAML-only at v0.1.x (env-var overrides not exposed).

When XAA fits

Use XAA when:

  • Enterprise wants central control over which agents can call which tools, at which scope levels.
  • Per-user consent screens don’t fit the environment (headless agents, CI runners, no browser).
  • Your IdP can be extended to sign a custom JWT type (ID-JAG) — Okta, Entra ID, Auth0, and any OIDC provider that supports custom token types.

When NOT to use XAA: you have interactive users who can consent in a browser — use plain OIDC federation instead. XAA replaces user consent with enterprise policy; that’s the whole point.

Prerequisites

  • AuthPlane running with xaa.enabled: true
  • A trusted IdP that can sign ID-JAG assertions (JWT with typ: oauth-id-jag+jwt)
  • Admin API key (AUTHPLANE_ADMIN_API_KEY)

Enable XAA

xaa:
  enabled: true
  token_expiry: 1h              # issued access-token lifetime
  max_assertion_age: 5m         # max age of an ID-JAG at time of exchange
  require_resource: false       # require ?resource=... on token requests
  subject_mode: auto_map        # "auto_map" or "strict"
  jwks_cache_ttl: 1h            # how long IdP JWKS keys are cached

At v0.1.x, all XAA config is YAML-only — no env-var overrides.

Step 1 — Register a trusted IdP

Tell AuthPlane which IdPs it should trust for XAA assertions. The IdP’s issuer + JWKS URI + audience.

curl -s -X POST http://localhost:9001/admin/idps \
  -H "Authorization: Bearer $ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Corp Okta",
    "issuer": "https://acme.okta.com",
    "jwks_uri": "https://acme.okta.com/.well-known/jwks.json",
    "audience": "https://auth.example.com"
  }'
  • audience — the value the IdP will put in the ID-JAG’s aud claim. Defaults to your AS’s issuer URL if omitted.
  • jwks_uri — auto-discovered from {issuer}/.well-known/openid-configuration if omitted (SSRF-protected fetch).

Response includes id: "idp_..." — the ID you’ll reference in policies. JWKS keys are cached for xaa.jwks_cache_ttl; force refresh with POST /admin/idps/{id}/refresh-keys.

There’s no CLI subcommand for XAA IdPs — the curl above is the canonical way to register a trusted IdP. Every other XAA resource (policies, subject mappings) is admin-REST-only too.

Step 2 — Create an authorization policy

Policies control which (IdP × client × scope × resource) combinations AuthPlane will mint tokens for.

curl -s -X POST http://localhost:9001/admin/xaa/policies \
  -H "Authorization: Bearer $ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Allow Acme agents",
    "idp_id": "idp_abc123",
    "client_ids": ["my-mcp-client"],
    "scopes": ["tools/echo", "tools/search"],
    "resources": ["https://mcp.example.com/mcp"]
  }'
FieldRequiredSemantics
idp_idyesWhich trusted IdP this policy applies to
client_idsnoRestrict to specific MCP client IDs. Empty = all clients registered with this IdP
scopesnoMax allowed scopes. Issued token’s scope = intersection(requested, this list). Empty = client’s default scopes
resourcesnoAllowed target resources. Empty = all resources

Deny by default — a request that matches no policy is rejected. Multiple policies can apply; if any allows, request proceeds.

Step 3 — Subject mapping (optional)

Controls how the external IdP’s sub claim maps to a local user identity.

Two modes (set via xaa.subject_mode in YAML):

  • auto_map (default) — external subjects accepted as-is. The issued token’s sub claim uses the format {issuer}:{idp_subject} (e.g., https://acme.okta.com:alice@acme.com).
  • strict — only explicitly mapped subjects are allowed. Unmapped subjects → access_denied.

Create explicit mappings:

curl -s -X POST http://localhost:9001/admin/xaa/subject-mappings \
  -H "Authorization: Bearer $ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "idp_id": "idp_abc123",
    "idp_subject": "alice@acme.com",
    "local_user_id": "usr_local_alice"
  }'

When a mapping exists (in either mode), the token’s sub uses local_user_id instead of the federated format — useful when you want AuthPlane audit rows tied to your own user IDs, not the IdP’s opaque subject strings.

Step 4 — Register an MCP client with jwt-bearer grant

The MCP client must have urn:ietf:params:oauth:grant-type:jwt-bearer in its grant_types:

curl -s -X POST http://localhost:9000/oauth/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "enterprise-agent",
    "grant_types": ["urn:ietf:params:oauth:grant-type:jwt-bearer"],
    "token_endpoint_auth_method": "client_secret_post"
  }'

Response includes the client_id and client_secret to use on token requests.

Confidential clients (client_secret_post or client_secret_basic) are required — public clients can’t do XAA.

Step 5 — Exchange an ID-JAG for an access token

The IdP signs an ID-JAG assertion. The MCP client presents it to AuthPlane:

curl -s -X POST http://localhost:9000/oauth/token \
  -d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
  -d "assertion=$ID_JAG_JWT" \
  -d "client_id=$CLIENT_ID" \
  -d "client_secret=$CLIENT_SECRET" \
  -d "scope=tools/echo" \
  -d "resource=https://mcp.example.com/mcp"

Response:

{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "tools/echo"
}

The issued token is a standard RFC 9068 AT-JWT — validate it the same way as any other AuthPlane token in your MCP server. DPoP works the same way (send a DPoP header on the /oauth/token call to get a DPoP-bound token).

ID-JAG assertion format

The JWT your IdP signs must have:

Header:

{ "alg": "RS256|ES256|PS256", "typ": "oauth-id-jag+jwt", "kid": "..." }

Payload:

{
  "iss": "<idp issuer>",                 // must match a registered trusted IdP
  "aud": "<audience>",                    // must match the IdP's registered audience
  "sub": "<user subject>",                // per your IdP's convention
  "exp": <unix>,                          // must be within max_assertion_age
  "iat": <unix>,
  "jti": "<unique>"                       // replay-guarded — single use
}

Extra claims (groups, department, custom claims) are ignored by AuthPlane at v0.1.x — they can influence policy decisions in a future release. Your IdP can include whatever’s convenient.

Testing with xaa.dev

If you don’t have an enterprise IdP handy, use xaa.dev — the public XAA playground built on Okta. Full walkthrough with ngrok in authserver/docs/how-to/test-xaa-with-xaa-dev.md. Two-minute setup: register idp.xaa.dev as a trusted IdP, run AuthPlane behind ngrok, request an ID-JAG from xaa.dev’s playground UI, present it to your /oauth/token.

Metrics

Once XAA is in prod:

  • 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

rate(authplane_xaa_policy_evaluation_total{decision="deny"}[5m]) spiking = policy misconfig or unauthorized attempts.

Troubleshooting

SymptomCauseFix
400 invalid_grant "assertion validation failed"Signature invalid (wrong JWKS key), or iss/aud/exp mismatchVerify the IdP is registered; check assertion claims against expected values
400 invalid_grant "assertion too old"iat > xaa.max_assertion_age agoBump max_assertion_age (default 5m) or fix clock skew between IdP and AS
400 access_deniedNo policy allows this (IdP × client × scope × resource)Create/update policy; check authplane_xaa_policy_evaluation_total{decision="deny"} metric with labels
400 access_denied in strict subject modeSubject not in the mapping tableAdd explicit subject-mapping row
400 invalid_grant "jti already used"Assertion replayedClient bug: each assertion is single-use per JTI
400 unauthorized_clientClient’s grant_types doesn’t include jwt-bearerUpdate the client’s registration