The RFC Wants a Token Nobody Holds
I wanted to implement RFC 8693 token exchange cleanly. At exactly one spot that didn't work — not out of laziness, but because the RFC assumes a case my architecture deliberately doesn't have: that the caller holds the user's token. In the agent-to-agent-on-behalf-of-human case, nobody does.
The Nest is a control-plane daemon that runs locally as its own Unix user — _openape_nest, HOME=/var/openape/nest, started by me, not by the agent. It's supposed to fetch tokens for an agent that the agent works with against a service provider. On my identity, but without my token ever coming into its reach. My DDISA token sits in ~/.config/apes/auth.json in my home. The Nest runs as a different user and can't read it. That's not an implementation detail, that's the entire purpose of this separation.
For token delegation there's a standard: RFC 8693, OAuth 2.0 Token Exchange. I sat down to build that to spec. At one spot that didn't work. Here's why.
What the RFC requires
RFC 8693 knows two tokens in the request: subject_token and actor_token. The subject_token represents the party for whom the new token is issued. The actor_token represents the party that acts. In the spec text subject_token is required, actor_token optional.
The canonical use case behind it: a confidential client already holds a signed user assertion — a pre-issued token representing the user — and wants to exchange it for a downscaled token for a downstream service. The caller in this model has both tokens. It holds the subject in hand and passes it on.
A typical request looks roughly like this:
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<the token representing the user>
&subject_token_type=urn:ietf:params:oauth:token-type:jwt
&audience=https://timetrack.openape.ai
Clean, as long as the one making the request actually owns the subject_token.
My caller doesn't own it
The Nest holds its own token — it has its own DDISA agent identity. What it doesn't hold is mine. Not "it's cumbersome to obtain", but: it lies behind a filesystem boundary I deliberately drew. The subject_token slot the RFC requires isn't empty on the Nest side because I was lazy. It's empty because nothing should be there.
The information the subject_token would transport — this token acts on behalf of Patrick — still exists. It just lives elsewhere: in a delegation grant. A standing grant in which I allowed this agent in advance to act in my name. The grant is server-side, signed by the IdP, and explicitly contains who the delegator is. The agent can reference it, but not forge it and not derive it.
So the choice was concrete: either I put my token where the Nest can read it — and topple the isolation the whole construction rests on — or I accept that the RFC-prescribed subject_token is replaced by a pointer in this case.
How it looks now
subject_token is optional when a delegation_grant_id is present. The delegator is derived from the grant, not from a token passed along.
if (!subjectToken) {
if (!delegationGrantId) {
throw badRequest("subject_token required");
}
const grant = await grants.get(delegationGrantId);
// The "subject" here is not a token traveling through the agent,
// but a server-side pointer to the grant owner.
delegator = grant.delegator;
}
The request the Nest sends carries its own token as actor_token and points to the grant instead of a subject:
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&actor_token=<the Nest's own token>
&actor_token_type=urn:ietf:params:oauth:token-type:jwt
&delegation_grant_id=grant_<id-of-the-delegation-grant>
&audience=https://timetrack.openape.ai
The effect is identical to what the RFC wants to achieve: a token, issued on me, for action through the agent, scoped to a target SP. What changes is where the proof "on behalf of Patrick" comes from. Not from a token the agent passes through. From a grant the IdP holds.
What fell away
The subject_token. And with it the assumption that the agent ever holds anything that represents me.
That's not strict RFC anymore. I also don't call it that. It's a deliberate deviation on exactly one axis — subject_token optional when delegation_grant_id is present, delegator from grant.delegator — and everything else stays RFC-conformant. Whoever has both tokens still drives the standard path. The delegation-grant-only path is an extension for a case the RFC didn't have in view.
The point
The RFC isn't wrong. It's written for confidential clients that hold a pre-signed user assertion — caller has both tokens. That's a reasonable world. It's just not mine.
Agent-to-agent-on-behalf-of-human is a different form. Strict RFC conformance would have forced me to put my token where the agent can reach it — exactly what the architecture is supposed to prevent. The deviation isn't a shortcut. It's the same security property, just expressed differently: not "the agent carries the subject carefully through", but "the agent doesn't carry the subject at all".
A spec that requires the user's token to travel through the agent is a spec that assumes you trust the agent. I don't trust it. The grant carries the delegation. The agent carries nothing.
Code: github.com/openape-ai/openape, MIT-licensed. The token-exchange endpoint lives in the IdP, exchangeWithDelegation in the CLI auth layer, the Nest uses both via its own DDISA identity.