Skip to main content

Worked example of IdP token authentication

Enterprise Tier

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.


Preview

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:

ItemValue in this example
Identity providerPingFederate, issuer https://idp.example.com
Signing keyshttps://idp.example.com/pf/JWKS
OAuth clientpayments-assist-nonprod, client-credentials grant
Audienceapi://agent-router-nonprod
Scopes the client is grantedaigw.invoke.nonprod and aigw.profile.payments-assist.nonprod
Environment claimdeployment_env, value nonprod
Organization (customer ID)acme
Projectpayments-assist
Gatewayslug 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_uri that 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.

ClaimRequired formSet in PingFederate by
issExactly the issuer registered in Step 3The base URL of the server
audContains the audience registered in Step 3The Audience Claim Value of the JWT access token manager the client is restricted to
scopeContains every required scope, as a space-delimited stringScope Claim Name scope with Space Delimit Scope Values on
client_idA stable identifier for the applicationClient ID Claim Name client_id
deployment_envThe environment the token was issued forAn 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.

  • requiredScopes is the entitlement check. The gateway reads scope and scp, 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 whose scope is present but empty is refused with missing_scope.
  • environmentClaim names 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 extractionHeader is set, so the token can be sent as Authorization: Bearer or in x-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:

  1. The gateway serves the project, and the project's membership accepts tokens (Step 5).
  2. 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.
  3. The token verifies against that policy's keys, audience, and expiry, carries every required scope, and carries the gateway's environment.
  4. A rule of that policy matches the token, and a jit rule 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"
}
}
StatuscodeStep to revisit
400missing_projectStep 6: the request carries a token and no x-tars-project header
400credentials_conflictStep 6: the request carries both a token and an x-api-key header
401unregistered_issuerStep 3: no policy on this gateway names the token's iss, which must match exactly
401wrong_audienceStep 1: the identity provider issues this client a different audience
401bad_signature, unknown_kidStep 3: the key set does not hold the key the token was signed with
401expiredStep 6: request a new token
403project_not_servedPrerequisites: the gateway does not serve the project the header names
403token_not_acceptedStep 5: the project accepts API keys only on this gateway
403issuer_not_acceptedStep 3: the issuer is registered on the gateway for another project, or this project's policy is disabled
403missing_scopeSteps 1 and 3: the token lacks a required scope, or its scope is empty
403environment_claim_missing, environment_mismatchSteps 1 and 2: the token has no deployment_env claim, or its value is not the gateway's environment
403environment_undeclaredStep 2: the gateway declares no environment
403no_matching_rule, rule_for_other_projectStep 4: no rule matches the token's client_id, or the matching jit rule names another project
403principal_cap_reachedStep 4: the rule has created as many applications as jitPrincipalCap allows
503keys_unavailableStep 3: the gateway has not fetched the key set yet. Retry