Technical notes

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:

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.