README.md

April 8, 2026 · View on GitHub

French Toast JWT

french-toast-jwt

Client-to-client delegation for OAuth 2.0 — re-sign, attenuate, verify.

Why?

When an application or agent delegates a task to a sub agent, or sub task, it needs to pass along its authorization. Today, that means either handing off the full token or implementing multiple token exchange steps — and it's complex to track who delegated what along the way.

french-toast-jwt solves this. A client takes the original JWT, re-signs it with its own key, adds claims. The result is a verifiable delegation chain, each hop's identity and constraints are preserved, and the whole thing traces back to the original issuer.

Every token is DPoP-bound so only the intended presenter can use it.

Example: An agent delegates a task to a sub-agent.

sequenceDiagram
    participant AS as Authorization Server
    participant Agent as Agent
    participant Sub as Sub-Agent
    participant API as API

    AS->>Agent: Token A (scope: read write delete)
    Note over Agent: Needs sub-agent to do a read-only task

    Agent->>Sub: Token B + DPoP
    Note right of Agent: ft.delegate(tokenA, {<br/>scope: "read"<br/>})

    Sub->>API: Token B + DPoP
    Note over API: Verifies chain inside-out

    API->>API: ft.verify([tokenB, tokenA])
    Note right of API: Token A ok (AS key, scope: read write delete)<br/>Token B ok (Agent key, scope: read)<br/>DPoP ok (Sub-Agent holds bound key)

The sub-agent only gets read. The API sees the full delegation chain — who delegated what to whom.

graph LR
    TB["Token B<br/><small>iss: agent</small><br/><small>scope: read</small><br/><small>ft_parent: sha256(Token A)</small>"]
    TA["Token A<br/><small>iss: AS</small><br/><small>scope: read write delete</small>"]

    TB -- "hash binding" --> TA

    style TA fill:#e8f5e9,stroke:#388e3c
    style TB fill:#fff3e0,stroke:#f57c00

The library is claim-agnostic. It doesn't know about scopes, roles, or authorization_details. You add whatever claims you want; the verifier decides what they mean.

Keys are discovered automatically via Authorization Server Metadata and Client ID Metadata Documents.

See SPEC.md for the full specification.

Install

npm install github:ciamshrek/french-toast-jwt

Usage

Delegate

import * as ft from 'french-toast-jwt';

// Agent received tokenA from the AS.
// Delegate to sub-agent with narrower claims.
const tokenB = await ft.delegate(tokenA, {
  privateKey: agentPrivateKey,
  issuer: 'https://agent.example.com/client_id.json',
  audience: 'https://api.example.com',
  extraClaims: { scope: 'read' },
  nextHopPublicKey: subAgentPublicKey,
});

Verify

The chain is passed as an array (outermost first, root last) or as an &-delimited string. Verification runs inside-out — if the root token is invalid, nothing else is checked.

import * as ft from 'french-toast-jwt';

// API verifies the full chain.
const result = await ft.verify([tokenB, tokenA], {
  dpopProof,
  method: 'GET',
  url: 'https://api.example.com/resources',
});

// result.chain — decoded tokens from outermost to root

Chain Constraints

Each token can restrict who can delegate further and how deep the chain can go, via headers that can only shrink:

const tokenB = await ft.delegate(tokenA, {
  privateKey: agentPrivateKey,
  issuer: 'https://agent.example.com/client_id.json',
  audience: 'https://api.example.com',
  extraClaims: { scope: 'read' },
  // Only these issuers can appear downstream (ft_iss header)
  allowedIssuers: [
    'https://sub-agent-1.example.com/client_id.json',
    'https://sub-agent-2.example.com/client_id.json',
  ],
  // Allow at most 2 more delegations after this one (ft_dep header)
  maxDepth: 2,
});

Both are optional. If absent, issuers are unrestricted and depth is unbound. Each downstream hop can further reduce them but never expand.

DPoP

Each hop presents its token with a DPoP proof per RFC 9449. Standard DPoP — nothing custom.

import * as ft from 'french-toast-jwt';

const proof = await ft.createDPoPProof({
  method: 'GET',
  url: 'https://api.example.com/resources',
  accessToken: chainString,  // the full & -delimited chain
  privateKey: presenterPrivateKey,
  publicKey: presenterPublicKey,
});

HTTP

The full chain is transmitted in the Authorization header, tokens joined by &:

GET /resources HTTP/1.1
Host: api.example.com
Authorization: DPoP <tokenB>&<tokenA>
DPoP: <dpop-proof>

Examples

npx tsx examples/example1.ts   # Scope reduction at each hop
npx tsx examples/example2.ts   # Scope reduction + authorization_details

Both examples show the full flow with DPoP at every hop, metadata discovery logging, and raw token output.

Stack

  • jose — JWT signing, verification, JWKS, JWK thumbprints, DPoP

License

MIT