Worked example of IdP token authentication
This example takes one application from a client registered in the organization's own identity provider to a successful request on a data plane, with no Agent Router API key handed out. Every step is a management API call. The token path is described in Authenticate gateway requests with an external IdP; this page applies it to one PingFederate client and shows the token at each stage.
Authenticating with an identity-provider token is a preview from 0.7.0. Its limits are listed at the top of Authenticate gateway requests with an external IdP.
The example
One application, a payments assistant, runs in the non-production environment and calls one Agent Router project. The values used throughout:
| Item | Value in this example |
|---|---|
| Identity provider | PingFederate, issuer https://idp.example.com |
| Signing keys | https://idp.example.com/pf/JWKS |
| OAuth client | payments-assist-nonprod, client-credentials grant |
| Audience | api://agent-router-nonprod |
| Scopes the client is granted | aigw.invoke.nonprod and aigw.profile.payments-assist.nonprod |
| Environment claim | deployment_env, value nonprod |
| Organization (customer ID) | acme |
| Project | payments-assist |
| Gateway | slug gw-nonprod, environment nonprod |
The management API calls use https://management.example.com for the management API host and ${ADMIN_API_KEY} for a management key with the admin scope, sent as a Bearer token.
Prerequisites
- Every data plane serving the project runs 0.7.0 or later, and the management plane was upgraded before them (see Upgrading a data plane that carries a policy).
- The project exists and is attached to the gateway; see Serve a project from a data plane. The gateway's UUID is needed for two of the calls below and its slug for one.
- At least one model is assigned to the project.
- The identity provider's discovery document and key set are served from a public hostname. A
jwks_urithat resolves to an internal address is refused when the policy is written.
Step 1: shape the token in the identity provider
The gateway reads five things from the token. Each one is set on the identity provider's side, per client, before anything is configured in Agent Router.
| Claim | Required form | Set in PingFederate by |
|---|---|---|
iss | Exactly the issuer registered in Step 3 | The base URL of the server |
aud | Contains the audience registered in Step 3 | The Audience Claim Value of the JWT access token manager the client is restricted to |
scope | Contains every required scope, as a space-delimited string | Scope Claim Name scope with Space Delimit Scope Values on |
client_id | A stable identifier for the application | Client ID Claim Name client_id |
deployment_env | The environment the token was issued for | An attribute mapped to a fixed value on the environment's access token manager |
The audience is fixed per client in the identity provider, and the client cannot ask for a different one. PingFederate refuses a token request carrying resource= with invalid_target and ignores audience=; Keycloak ignores both and always issues the audience its audience mapper sets. A token with the wrong audience is fixed by the identity provider administrator, not by the application.
A token issued to this client, decoded:
{
"alg": "RS256",
"kid": "agent-router-rs-1_RS256"
}
{
"iss": "https://idp.example.com",
"aud": "api://agent-router-nonprod",
"sub": "payments-assist-nonprod",
"client_id": "payments-assist-nonprod",
"scope": "aigw.invoke.nonprod aigw.profile.payments-assist.nonprod",
"deployment_env": "nonprod",
"iat": 1791208798,
"exp": 1791209098,
"jti": "zAsnZbEAtjtEIl9ANgrslA"
}
The shape is what PingFederate 13.1 issues for a client-credentials grant. Keycloak issues the same claims with two differences that do not matter to the gateway: its iss ends in /realms/<realm>, and its sub is the service account's UUID rather than the client ID, so the identity rule in Step 4 names client_id, which both providers carry.
Step 2: declare the gateway's environment
The gateway compares the token's environment claim against the environment the gateway declares, so the gateway declares one first. Only a platform administrator can set it. The value matches ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ and is compared exactly, case included.
curl -sS -X PATCH "https://management.example.com/admin/v1/project-gateways/${GATEWAY_UUID}" \
-H "Authorization: Bearer ${ADMIN_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"environment": "nonprod"}'
Skip this step and leave environmentClaim out of Step 3 where a separate issuer or audience per environment already keeps production and non-production tokens apart.
Step 3: register the issuer for the project
A gateway auth policy registers one issuer for one project on one gateway. The call names the gateway by its slug.
curl -sS -X POST "https://management.example.com/admin/v1/gateway-auth-policies" \
-H "Authorization: Bearer ${ADMIN_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"customerId": "acme",
"projectId": "payments-assist",
"projectGatewayId": "gw-nonprod",
"name": "pingfederate-nonprod",
"mechanism": "jwt",
"issuer": "https://idp.example.com",
"jwksUri": "https://idp.example.com/pf/JWKS",
"audiences": ["api://agent-router-nonprod"],
"requiredScopes": ["aigw.invoke.nonprod", "aigw.profile.payments-assist.nonprod"],
"environmentClaim": "deployment_env"
}'
The response carries the policy's id, which Step 4 needs. A policy is enabled unless "enabled": false is sent.
requiredScopesis the entitlement check. The gateway readsscopeandscp, splits a string on spaces, and requires every listed scope to be present, compared case-sensitively. Scopes the token carries beyond the list are ignored, and their order does not matter. A token whosescopeis present but empty is refused withmissing_scope.environmentClaimnames the claim, not its value. The value the claim must carry is the gateway's environment from Step 2. An enabled policy naming an environment claim is refused while the gateway declares no environment.- No
extractionHeaderis set, so the token can be sent asAuthorization: Beareror inx-tars-idp-token.
Step 4: say which claim identifies the application
An identity mapping rule tells the gateway which claim names the application, and whether an application it has not seen before is admitted. This rule admits any client whose client_id starts with payments-, creating its identity on the first call, up to 20 applications:
curl -sS -X POST "https://management.example.com/admin/v1/gateway-auth-policies/${POLICY_ID}/rules" \
-H "Authorization: Bearer ${ADMIN_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"claimName": "client_id",
"claimValueTemplate": "payments-*",
"target": "jit",
"jitProjectId": "payments-assist",
"jitPrincipalCap": 20
}'
claimValueTemplate is a shell-style pattern: * matches any run of characters except /, ? matches one character, and an empty template matches every value. Only a claim with a string value matches. Rules are tried in the order they were created, and the first match applies. A pre_registered rule instead admits only applications registered in advance, and refuses an unknown one with identity_not_registered.
Step 5: let the project accept tokens
The project's membership of the gateway still accepts API keys only. Switching it to accept both keeps existing API-key callers working while the token path is proven:
curl -sS -X PATCH "https://management.example.com/admin/v1/project-gateways/${GATEWAY_UUID}/projects/payments-assist" \
-H "Authorization: Bearer ${ADMIN_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"acceptedCredentials": "ACCEPTED_CREDENTIALS_BOTH"}'
ACCEPTED_CREDENTIALS_TOKEN refuses API keys for the project from then on. Either value is refused unless the project holds an enabled policy on that gateway, which is why Step 3 comes first.
Step 6: get a token and call the gateway
The application obtains a token from the identity provider with the client-credentials grant. The test client in this example authenticates to PingFederate with a signed client assertion (private_key_jwt); a client secret works the same way where the identity provider allows it.
curl -sS -X POST "https://idp.example.com/as/token.oauth2" \
-d grant_type=client_credentials \
-d client_id=payments-assist-nonprod \
-d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
-d client_assertion="${CLIENT_ASSERTION}" \
--data-urlencode "scope=aigw.invoke.nonprod aigw.profile.payments-assist.nonprod"
The response's access_token is sent to the gateway with the project header, on the same base URL and endpoints an API-key caller uses:
curl -sS "https://gw-nonprod.example.com/v1/chat/completions" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "x-tars-project: payments-assist" \
-H "Content-Type: application/json" \
-d '{"model": "claude-sonnet-4-5", "messages": [{"role": "user", "content": "Hello"}]}'
A 200 is the end of the example. On that first call the gateway created the application's identity and one API key for it, named https://idp.example.com#client_id=payments-assist-nonprod. Later calls with a token for the same client reuse it, and request logs and usage are attributed to that key. The data plane removes the token and the project header before the request reaches the model provider.
How the token is bound to the project
The project is the one the x-tars-project header names, and the header is a request, not a grant. The data plane admits the token for that project only when all of these hold:
- The gateway serves the project, and the project's membership accepts tokens (Step 5).
- The project holds an enabled policy on this gateway for the token's issuer (Step 3). A policy for the same issuer held by another project does not count.
- The token verifies against that policy's keys, audience, and expiry, carries every required scope, and carries the gateway's environment.
- A rule of that policy matches the token, and a
jitrule matches only for the project it names (Step 4).
The scope in Step 3 is what turns "the organization trusts this identity provider" into "this application may use this project": a client the identity provider has not granted aigw.profile.payments-assist.nonprod is refused, even though its token is otherwise valid.
One gateway takes at most one enabled policy per issuer across every project it serves, so on one gateway an issuer serves exactly one project. A token that carries profile scopes for several projects reaches, on a given gateway, only the project that holds that issuer's policy there. The other profile scopes are ignored on that gateway.
When a step is wrong
Each refusal names its cause in the error body's code. The body follows the gateway's error format, and the X-Request-ID response header identifies the request:
{
"error": {
"message": "The IdP token lacks a scope the gateway requires.",
"type": "authorization_error",
"param": null,
"code": "missing_scope"
}
}
| Status | code | Step to revisit |
|---|---|---|
400 | missing_project | Step 6: the request carries a token and no x-tars-project header |
400 | credentials_conflict | Step 6: the request carries both a token and an x-api-key header |
401 | unregistered_issuer | Step 3: no policy on this gateway names the token's iss, which must match exactly |
401 | wrong_audience | Step 1: the identity provider issues this client a different audience |
401 | bad_signature, unknown_kid | Step 3: the key set does not hold the key the token was signed with |
401 | expired | Step 6: request a new token |
403 | project_not_served | Prerequisites: the gateway does not serve the project the header names |
403 | token_not_accepted | Step 5: the project accepts API keys only on this gateway |
403 | issuer_not_accepted | Step 3: the issuer is registered on the gateway for another project, or this project's policy is disabled |
403 | missing_scope | Steps 1 and 3: the token lacks a required scope, or its scope is empty |
403 | environment_claim_missing, environment_mismatch | Steps 1 and 2: the token has no deployment_env claim, or its value is not the gateway's environment |
403 | environment_undeclared | Step 2: the gateway declares no environment |
403 | no_matching_rule, rule_for_other_project | Step 4: no rule matches the token's client_id, or the matching jit rule names another project |
403 | principal_cap_reached | Step 4: the rule has created as many applications as jitPrincipalCap allows |
503 | keys_unavailable | Step 3: the gateway has not fetched the key set yet. Retry |
Where to go next