Module 6: Scoped credentials (Delegation)#
You are in the CPEX tutorial. This module needs the IdP.
Cookbook recipe: Recipe 1: User acting through an agent (on-behalf-of).
Goal: mint a narrow, downstream-scoped credential for a call with a real OAuth 2.0 token exchange (RFC 8693), instead of forwarding the caller’s full token.
The problem#
When your agent calls a downstream API, handing it the caller’s original token is over-broad: that token works everywhere, for everything the caller can do. You want a token minted for this one downstream call, scoped to a single audience, so a leak is contained. Token exchange does that, and CPEX makes it a policy step rather than integration code.
Build it#
Add a delegator plugin and a delegate(...) step. From policies/m06.yaml:
plugins:
- name: keycloak
kind: identity/jwt
hooks: [identity.resolve]
capabilities: [perform_http]
config:
claim_mapper: standard
trusted_issuers:
- issuer: http://localhost:8081/realms/cpex-tutorial
audiences: [cpex-tutorial]
algorithms: [RS256]
decoding_key:
kind: jwks_url
url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs
insecure_http: true
leeway_seconds: 60
- name: workday-oauth
kind: delegator/oauth
hooks: [token.delegate]
# The delegator needs to read the caller's inbound token (to exchange
# it) and write the minted downstream token. Declaring these here lets
# the route surface them to the enforcement boundary; without them the
# inbound token is filtered out before the exchange and it fails.
# perform_http lets it call the token endpoint.
capabilities: [read_inbound_credentials, write_delegated_tokens, perform_http]
config:
token_endpoint: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/token
client_id: cpex-gateway
client_secret_source:
kind: literal
secret: gateway-dev-secret
insecure_http: true # localhost speaks http; never in production
routes:
- tool: get_compensation
authentication:
- keycloak
authorization:
pre_invocation:
- "require(role.hr)"
- "delegate(workday-oauth, target: workday-api, audience: workday-api)"
- "require(delegation.granted)"The delegate(...) step exchanges the caller’s token for one scoped to the workday-api audience, against the real Keycloak token endpoint. require(delegation.granted) then proceeds only if the exchange succeeded.
Two things are load-bearing:
- The plugin declares
read_inbound_credentialsandwrite_delegated_tokens. Without them, the caller’s inbound token is filtered out before the exchange runs, and delegation fails with an empty token. Capabilities scope what each step may touch. - The plugin also declares
perform_http. The exchange is an outbound call to the token endpoint, made through the transport the host installed (module 1), and only plugins that declareperform_httpmay use it. Both plugins here declare it:keycloakto fetch the JWKS,workday-oauthto call the token endpoint. - The gateway client in Keycloak must be allowed to exchange for the target audience. In the tutorial realm,
cpex-gatewaysetsstandard.token.exchange.audiences: workday-api,github-apiand carries audience mappers for those clients (seeidp/realm-export.json).
Run it#
cargo run -p cpex-tutorial --example m06_delegation▸ alice (hr) → get_compensation (delegate mints a workday-api token, then allow)
✓ ALLOWED { ... }
▸ evan (engineer) → get_compensation (denied at require(role.hr), no delegation)
✗ DENIED [routes.tool:get_compensation.pre_invocation[0]] access deniedalice’s call runs a real token exchange and gets a workday-api-scoped token before the backend call. evan never reaches delegation, because require(role.hr) stops him first. Delegation is cheap to skip when it is not needed.
Try it#
- Drop the credential capabilities. Remove
read_inbound_credentialsandwrite_delegated_tokensfrom theworkday-oauthplugin’scapabilities:list (keepperform_http) and re-run. Expect: alice is denied withdelegation.bad_request(empty token), because the inbound credential was filtered out. - Wrong audience. Change
audience:to a client the gateway may not target and re-run. Expect: the exchange is rejected by Keycloak and the step denies. - Narrow the grant (advanced). In
policies/m06.yaml, add apermissions:argument to thedelegate(...)step so it readsdelegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation]). The exchange then requests only that scope. For Keycloak to actually issue it, theread_compensationclient scope must exist on the realm and be assigned to theworkday-apiclient (seeidp/README.md); without that realm setup the exchange returns the default scope. This is how you narrow the downstream grant to exactly what the operation needs.
Checkpoint#
Why does the plugin need capabilities to succeed?
Where does the scoping happen, CPEX or Keycloak?
Go deeper#
- Delegation for token exchange, capability reduction, and downstream verification.
- Module 12: Delegation subjects for the other principals a minted token can speak for.
Next#
Module 7: Information flow: carry security state across a session to block write-down.