On the wire
Every JWT in the flow, what it carries, and who checks it. The examples are decoded from a real run against the local bank (npm run e2e), with keys shortened.
Enrolment
Key proof - one per enrolled key, signed by that key. Proves possession and commits to the bank's challenge.
| header | typ: oauth-attestation-instance-proof+jwt, alg: ES256, jwk (the public key) |
| claims | aud = the attester, challenge, jti, iat |
| checked by | the attester, against the key it is asked to enrol |
Enrolment request - POST /enrol to the attester:
{
"instance_public_jwk": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" },
"federation_public_jwk": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" },
"challenge": "<from POST /enrol/challenge>",
"user_authentication": "<the IdP's ID token>",
"key_proofs": { "instance": "<key proof>", "federation": "<key proof>" },
"evidence": { "type": "yubikey-piv", "instance": { "leaf": "<PEM>" }, "federation": { "leaf": "<PEM>" }, "f9": "<PEM>" },
"platform": "macos",
"agent_build": "claude-bank-connector/0.1.0"
}
evidence.type is yubikey-piv or secure-enclave-self-asserted; App Attest travels as top-level appattest_object and appattest_key_id, exactly as the iOS enrolment sends them. The ID token's nonce must equal base64url(SHA-256(challenge + "|" + inst-jkt + "|" + fed-jkt)).
Client authentication
Client Attestation - minted by the bank's attester, sent in OAuth-Client-Attestation.
header { "alg": "ES256", "typ": "oauth-client-attestation+jwt", "kid": "wJbhskTH…" }
payload {
"iss": "http://localhost:7200",
"sub": "claude-bank-connector",
"agent_id": "6d61979a7a04ac3251ab8c1ff46a15cc",
"iat": 1790285416,
"exp": 1790286316,
"cnf": { "jwk": { "kty": "EC", "crv": "P-256", "x": "Dkm8AS7E…", "y": "-987oSEK…" } },
"key_storage_evidence": "self-asserted",
"authorization_details": [
{ "type": "https://schemas.idpartners.com.au/agentic/account_information/v1", "actions": ["read"] }
],
"agent_build": "claude-bank-connector/e2e",
"uv_policy": { "reuse_seconds": 3600 }
}
| Claim | Meaning |
|---|---|
sub |
the agent type: the OAuth client_id PingFederate authenticates |
agent_id |
this instance; PingFederate copies it into tokens and builds act from it |
cnf.jwk |
the inst key: every PoP must verify with it |
key_storage, user_authentication |
only when the evidence supports them (App Attest, PIV); absent for self-asserted keys, as here |
key_storage_evidence |
what the bank verified: app-attest, yubikey-piv-attestation, or self-asserted |
authorization_details |
the bank's ceiling for this agent type |
uv_policy.reuse_seconds |
how long the passkey sign-in counts; after that the attester refuses to re-mint (user_verification_required) until bank_reverify |
exp |
fifteen minutes; the connector asks the attester to re-mint, proving possession with inst |
Attestation PoP - signed by inst, sent in OAuth-Client-Attestation-PoP with every PAR and token request.
| header | typ: oauth-client-attestation-pop+jwt, alg: ES256 |
| claims | aud = the bank's issuer (one string, at PAR and at the token endpoint), jti (single use), iat, challenge when the AS demands one |
| checked by | PingFederate's attestation filter, then bridged: PingFederate itself sees a private_key_jwt assertion typed client-authentication+jwt with aud = its issuer |
DPoP proof (RFC 9449) - signed by dpop, on every token request and resource call. typ: dpop+jwt, the public key in the jwk header, claims htm, htu, iat, jti, plus ath at resources and nonce when a server demands one. PAR carries dpop_jkt so the code is bound to the key before it exists.
Tokens
Access token for the bank's API:
header { "alg": "PS256", "kid": "A6XfGWVQ…", "pi.atm": "y82i", "typ": "at+jwt" }
payload {
"iss": "https://localhost:9131",
"aud": "http://localhost:7100",
"sub": "suite-user",
"client_id": "claude-bank-connector",
"agent_id": "6d61979a7a04ac3251ab8c1ff46a15cc",
"act": "{\"sub\":\"https://localhost:9131/federation/agents/6d61979a7a04ac3251ab8c1ff46a15cc\"}",
"scope": "accounts:read transactions:read",
"authorization_details": [],
"cnf": { "jkt": "G2jJ5-bTjvDcrXnp3Lhx4zhc5Fw4OWeapT0-R1PCiOQ" },
"iat": 1790285416,
"exp": 1790286016,
"jti": "cEjYkBuG7o0aYtUuNhgyCa"
}
Access token for the partner - the same agent, the same customer, the same DPoP key, a separate mandate: aud is http://localhost:7300, scope is offers:read, it lives five minutes, not ten, and it carries the entry the customer approved, purpose included:
"authorization_details": [
{
"type": "https://schemas.trustframeworks.com/agentic-banking/v1/retrieve_customer_offer",
"purpose": "https://w3id.org/dpv#PersonalisedBenefits",
"locations": ["http://localhost:7300"]
}
]
The bank token's entries are the balance and transaction types at http://localhost:7100, for dpv:AccountManagement.
Two deviations from a textbook RFC 9068 token, both PingFederate's:
actis a JSON string, not an object. PingFederate 13 rejects OGNL that referencesacton a mapping, so the chain is composed as text from the attestedagent_id.parseAct()inpackages/sharedreads either form.authorization_detailsis always present, empty when nothing was requested;pi.atmnames the token manager. The_agent_idmarker the attestation filter adds at PAR (MISSION.md) is stripped by the RAR processor and never reaches a token.
Refresh tokens are DPoP-bound and do not rotate (FAPI 2.0). Each refresh names its RFC 8707 resource.
Federation
The agent's Entity Configuration - signed by the agent's fed key, served by the bank at <entity>/.well-known/openid-federation.
header { "alg": "ES256", "typ": "entity-statement+jwt", "kid": "<fed thumbprint>" }
payload {
"iss": "https://localhost:9131/federation/agents/6d61979a…",
"sub": "https://localhost:9131/federation/agents/6d61979a…",
"jwks": { "keys": [ { "kty": "EC", "crv": "P-256", "kid": "<fed thumbprint>", "x": "…", "y": "…" } ] },
"authority_hints": ["https://localhost:9131"],
"trust_marks": [ { "trust_mark_type": "https://schemas.idpartners.com.au/trust-marks/bank-customer-agent/v1", "trust_mark": "<JWT>" } ],
"metadata": {
"federation_entity": { "organization_name": "Claude bank connector (customer agent)" },
"oauth_client": {
"client_name": "Claude bank connector",
"software_id": "claude-bank-connector",
"token_endpoint_auth_method": "attest_jwt_client_auth",
"grant_types": ["authorization_code", "refresh_token"],
"redirect_uris": ["http://127.0.0.1:53682/cb"]
}
},
"iat": 1790285416,
"exp": 1790371816
}
It lives a day; the connector republishes it (and renews the Trust Mark) before either lapses.
The bank publishes it only after checking iss = sub = the entity, typ, authority_hints, and the signature against the fed key the attester registered. The bytes are served verbatim.
The bank's Subordinate Statement about the agent - from https://localhost:9131/federation/fetch?sub=<entity>, signed by the bank's federation key. Its jwks is the agent's registered fed key, so a resolver can check that the Entity Configuration was signed by the key the bank vouches for. Its metadata_policy is the bank's lever over a configuration it did not write - whatever the agent publishes, these hold:
"metadata_policy": {
"oauth_client": {
"software_id": { "value": "claude-bank-connector" },
"token_endpoint_auth_method": { "value": "attest_jwt_client_auth" }
}
}
Trust Mark - from the bank's Trust Mark Issuer; the anchor lists that issuer for this mark type in trust_mark_issuers.
header { "alg": "ES256", "typ": "trust-mark+jwt", "kid": "LZmF18C7…" }
payload {
"iss": "http://localhost:7400",
"sub": "https://localhost:9131/federation/agents/6d61979a…",
"trust_mark_type": "https://schemas.idpartners.com.au/trust-marks/bank-customer-agent/v1",
"iat": 1790285416,
"exp": 1790371816
}
The issuer re-checks eligibility on every status request: the agent must still resolve to the anchor through the bank, and must not have been revoked by the issuer. A partner asks for status on every call.
Policy
Two AuthZEN evaluations reach the PDP (packages/pdp). Both answers put the reason code and the trace in context.reason_admin and a sentence in context.reason_user - names PingFederate's RAR processor treats as metadata, never merging them into a granted entry. MISSION.md has full examples.
From PingFederate's RAR processor, at the authorisation endpoint - one per requested entry:
{
"subject": { "type": "client", "id": "claude-bank-connector" },
"action": { "name": "grant" },
"resource": {
"type": "https://schemas.trustframeworks.com/agentic-banking/v1/retrieve_customer_offer",
"id": "https://schemas.trustframeworks.com/agentic-banking/v1/retrieve_customer_offer",
"properties": { "locations": ["http://localhost:7300"], "purpose": "https://w3id.org/dpv#PersonalisedBenefits" }
},
"context": { "principal_source": "none", "client_id": "claude-bank-connector", "actor": { "type": "agent", "id": "<agent_id>" } }
}
It carries the CLIENT-TOKEN header from bank/.secrets/pdp-client-token: only the bank's authorization server may ask about a mandate.
From the partner's PEP, on every call - after it has authenticated the DPoP-bound token:
{
"subject": { "type": "agent", "id": "https://localhost:9131/federation/agents/<agent_id>", "properties": { "on_behalf_of": "suite-user", "client_id": "claude-bank-connector" } },
"action": { "name": "https://schemas.trustframeworks.com/agentic-banking/v1/retrieve_customer_offer" },
"resource": { "type": "federation.entity", "id": "http://localhost:7300" },
"context": {
"checkpoint": "resource",
"purpose": "https://w3id.org/dpv#PersonalisedBenefits",
"granted_authorization_details": [ "…the token's entries…" ],
"act_chain": ["https://localhost:9131/federation/agents/<agent_id>"],
"resource_metadata_source": "federation",
"resource_metadata": { "…the partner's resolved oauth_resource…": "" },
"request": { "method": "GET", "path": "/offers" }
}
}
The anchor's Subordinate Statement about the partner is where that resource_metadata comes from - its vouched description and the metadata_policy that configures the PEP are in MISSION.md. go-oidfed adds two null members to the vouched block; resolvers read them as absent.