Zurück zum Blog
·von Patrick Hofmann

Der RFC verlangt ein Token, das niemand hält

Ich wollte RFC 8693 Token-Exchange sauber implementieren. An genau einer Stelle ging das nicht — nicht aus Faulheit, sondern weil der RFC einen Fall annimmt, den meine Architektur bewusst nicht hat: dass der Aufrufer das Token des Nutzers in der Hand hält. Im Agent-zu-Agent-on-behalf-of-human-Fall hat das niemand.

OpenApeAI AgentsSecurityInfrastructureBuilding in Public

Der Nest ist ein Control-Plane-Daemon, der lokal als eigener Unix-User läuft — _openape_nest, HOME=/var/openape/nest, gestartet von mir, nicht vom Agent. Er soll für einen Agent Token besorgen, mit denen der Agent gegen einen Service-Provider arbeitet. Auf meine Identität, aber ohne dass mein Token jemals in seine Reichweite kommt. Mein DDISA-Token liegt in ~/.config/apes/auth.json in meinem Home. Der Nest läuft als anderer User und kann es nicht lesen. Das ist kein Implementierungs-Detail, das ist der ganze Zweck dieser Trennung.

Für Token-Delegation gibt es einen Standard: RFC 8693, OAuth 2.0 Token Exchange. Ich habe mich hingesetzt, um das nach Spec zu bauen. An einer Stelle ging das nicht. Hier ist warum.

Was der RFC verlangt

RFC 8693 kennt zwei Token im Request: subject_token und actor_token. Das subject_token repräsentiert die Partei, für die das neue Token ausgestellt wird. Das actor_token repräsentiert die Partei, die handelt. Im Spec-Text ist subject_token required, actor_token optional.

Der kanonische Use-Case dahinter: ein Confidential Client hält bereits eine signierte User-Assertion — ein vorab ausgestelltes Token, das den Nutzer repräsentiert — und will daraus ein heruntergeskaltes Token für einen Downstream-Service tauschen. Der Aufrufer hat in diesem Modell beide Token. Er hält das Subject in der Hand und gibt es weiter.

Ein typischer Request sieht ungefähr so aus:

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<das Token, das den Nutzer repräsentiert>
&subject_token_type=urn:ietf:params:oauth:token-type:jwt
&audience=https://timetrack.openape.ai

Sauber, solange der, der den Request stellt, das subject_token tatsächlich besitzt.

Mein Aufrufer besitzt es nicht

Der Nest hält sein eigenes Token — er hat eine eigene DDISA-Agent-Identität. Was er nicht hält, ist meins. Nicht "es ist umständlich zu beschaffen", sondern: es liegt hinter einer Filesystem-Grenze, die ich absichtlich gezogen habe. Der subject_token-Slot, den der RFC verlangt, ist auf der Nest-Seite nicht leer, weil ich faul war. Er ist leer, weil dort nichts sein soll.

Die Information, die das subject_token transportieren würde — dieses Token handelt im Auftrag von Patrick — existiert trotzdem. Sie liegt nur woanders: in einem Delegation-Grant. Ein Standing Grant, in dem ich diesem Agent vorab erlaubt habe, in meinem Namen zu handeln. Der Grant ist server-seitig, vom IdP signiert, und enthält explizit, wer der Delegator ist. Der Agent kann ihn referenzieren, aber nicht fälschen und nicht ableiten.

Damit war die Wahl konkret: entweder ich lege mein Token dorthin, wo der Nest es lesen kann — und kippe die Isolation, die der ganze Aufbau trägt — oder ich akzeptiere, dass der RFC-vorgeschriebene subject_token in diesem Fall durch einen Pointer ersetzt wird.

Wie es jetzt aussieht

subject_token ist optional, wenn eine delegation_grant_id da ist. Der Delegator wird aus dem Grant abgeleitet, nicht aus einem mitgereichten Token.

if (!subjectToken) {
  if (!delegationGrantId) {
    throw badRequest("subject_token required");
  }
  const grant = await grants.get(delegationGrantId);
  // Der "Subject" ist hier kein Token, das durch den Agent reist,
  // sondern ein server-seitiger Pointer auf den Grant-Owner.
  delegator = grant.delegator;
}

Der Request, den der Nest schickt, trägt sein eigenes Token als actor_token und verweist auf den Grant statt auf ein Subject:

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&actor_token=<das eigene Token des Nest>
&actor_token_type=urn:ietf:params:oauth:token-type:jwt
&delegation_grant_id=grant_<id-des-delegation-grants>
&audience=https://timetrack.openape.ai

Der Effekt ist identisch zu dem, was der RFC erreichen will: ein Token, ausgestellt auf mich, zum Handeln durch den Agent, geskoped auf einen Ziel-SP. Was sich ändert, ist wo der Beweis "im Auftrag von Patrick" herkommt. Nicht aus einem Token, das der Agent durchreicht. Aus einem Grant, den der IdP hält.

Was weggefallen ist

Der subject_token. Und mit ihm die Annahme, dass der Agent jemals etwas in der Hand hat, das mich repräsentiert.

Das ist nicht strikt RFC mehr. Ich nenne es auch nicht so. Es ist eine bewusste Abweichung an genau einer Achse — subject_token optional bei vorhandener delegation_grant_id, Delegator aus grant.delegator — und alles andere bleibt RFC-konform. Wer beide Token hat, fährt weiter den Standard-Pfad. Der delegation-grant-only-Pfad ist eine Erweiterung für einen Fall, den der RFC nicht im Blick hatte.

Die Pointe

Der RFC ist nicht falsch. Er ist für Confidential Clients geschrieben, die eine vorab signierte User-Assertion halten — Aufrufer hat beide Token. Das ist eine vernünftige Welt. Sie ist nur nicht meine.

Agent-zu-Agent-on-behalf-of-human ist eine andere Form. Strikte RFC-Konformität hätte mich gezwungen, mein Token dorthin zu legen, wo der Agent es erreichen kann — exakt das, was die Architektur verhindern soll. Die Abweichung ist keine Abkürzung. Sie ist dieselbe Sicherheits-Eigenschaft, nur anders ausgedrückt: nicht "der Agent trägt das Subject vorsichtig durch", sondern "der Agent trägt das Subject gar nicht".

Eine Spec, die verlangt, dass das Token des Nutzers durch den Agent reist, ist eine Spec, die annimmt, dass du dem Agent vertraust. Ich vertraue ihm nicht. Der Grant trägt die Delegation. Der Agent trägt nichts.


Code: github.com/openape-ai/openape, MIT-lizenziert. Der Token-Exchange-Endpoint lebt im IdP, exchangeWithDelegation im CLI-Auth-Layer, der Nest nutzt beides über seine eigene DDISA-Identität.