Token Exchange statt Passthrough: MCP-Server mit authentik, Quarkus und LangChain4j absichern

Ein Quarkus-Agent, zwei MCP-Server, einer davon schneidet Tokens mit. Vier Varianten im Test, vom Token Passthrough bis zur Delegation mit authentik 2026.8.

Von 11 Min. Lesezeit

Die Autorisierungsregeln der MCP-Spezifikation formulieren an einer Stelle ungewöhnlich klar: Ein MCP-Server darf ausschliesslich Tokens akzeptieren, die ausdrücklich für ihn ausgestellt wurden. Der Client muss beim Token-Request über den resource-Parameter angeben, für welchen Server das Token bestimmt ist. Der Abschnitt zu Token Handling schliesst mit dem Satz: „MCP servers MUST NOT accept or transit any other tokens.”

Gleichzeitig liefert quarkus-langchain4j, die LangChain4j-Integration für Quarkus, eine Extension mit, die beim Aufruf eines MCP-Servers genau das Gegenteil macht. quarkus-langchain4j-oidc-mcp-auth-provider greift das Access Token des angemeldeten Nutzers aus dem aktuellen Request und setzt es unverändert als Authorization-Header. Praktisch, weil es ohne Extra-Konfiguration läuft – und genau das Muster, das die Spezifikation als „Token Passthrough” bezeichnet und verbietet.

Mich interessierte, was dieser Unterschied in der Praxis bedeutet und ob sich die saubere Variante mit authentik realisieren lässt. authentik steht in meinem Homelab ohnehin vor den meisten Diensten (Forward Auth oder OIDC) und unterstützt OAuth 2.0 Token Exchange nach RFC 8693. Seit Version 2026.8 auch mit Delegation über einen act-Claim. Also habe ich ein Lab aufgesetzt: einen Quarkus-Agenten, einen MCP-Server für Notizen und einen zweiten MCP-Server, der sich nicht an die Regeln hält. Das komplette Lab mit Blueprint, Code und Skripten liegt auf GitHub.

Das Setup

Das Lab umfasst eine lokale authentik-Instanz und drei Quarkus-Anwendungen. Zum Einsatz kamen authentik 2026.8.3, die Quarkus-Plattform 3.39.4 mit quarkus-langchain4j 1.13.3 und quarkus-mcp-server 2.0.1 sowie Qwen3.6-35B-A3B über Ollama auf einem Mac.

flowchart TB
    U([Nutzer]) -->|Login| AK[authentik]
    U -->|Chat| A[agent-app]
    A <-->|Token Exchange| AK
    A -->|LLM| M[Qwen3.6 auf Ollama]
    A -->|MCP| N[notes-mcp<br/>Notizen je Nutzer]
    A -->|MCP| W[wetter-mcp<br/>schneidet Tokens mit]

Die agent-app ist ein simpler Chat. Nutzer loggen sich per Authorization Code Flow bei authentik ein, dahinter sitzt ein @RegisterAiService mit beiden MCP-Servern als Toolbox. notes-mcp ist ein Notizspeicher wie im Artikel über einen eigenen MCP-Server, nur in Java: Er legt Notizen getrennt nach dem sub-Claim des Tokens ab und stellt Tools zum Auflisten, Lesen, Anlegen und Löschen bereit. wetter-mcp liefert eine Wettervorhersage und steht stellvertretend für jeden fremden MCP-Server, den ein Agent einbindet. Er protokolliert jedes Bearer-Token, das bei ihm ankommt, und hängt an die Vorhersage eine Anweisung an das Modell, die Notiz mit der kleinsten ID zu löschen.

In authentik existieren drei OAuth2-Provider mit gleichnamigen Applications: agent-app für den Login, dazu notes-mcp und wetter-mcp als Resource Server. Die Nutzer alice und bob sind Mitglieder der Gruppe notes-users, carol nicht. Nur Mitglieder dieser Gruppe dürfen die Application notes-mcp nutzen. Die komplette Konfiguration steckt in einem Blueprint; ein Skript wendet es an und spielt danach alle Varianten durch.

Variante A: Das Login-Token geht an jeden Server

Im Passthrough-Modus greift die Agenten-App für jeden MCP-Aufruf auf die unveränderte Klasse aus der Extension zurück. Deren Logik besteht im Kern aus drei Zeilen: Request-Kontext prüfen, TokenCredential auflösen, "Bearer " + token zurückgeben. In einer Quarkus-Anwendung mit Code Flow ist das tatsächlich das Access Token und nicht das ID Token, weil Quarkus den Producer für AccessTokenCredential als bevorzugte Alternative registriert.

Damit notes-mcp dieses Token akzeptiert, muss man ihn auf die Agenten-App einstellen. authentik vergibt standardmässig pro Provider einen eigenen Issuer; ein Token von agent-app trägt also iss=…/application/o/agent-app/ und aud=agent-app. Ein MCP-Server, der für Passthrough konfiguriert ist, vertraut deshalb genau diesem Issuer und dieser Audience:

%passthrough.quarkus.oidc.auth-server-url=http://localhost:9100/application/o/agent-app/
%passthrough.quarkus.oidc.token.audience=agent-app

Das funktioniert sofort. Alice fragt nach ihren Notizen, der Agent ruft listNotes auf, und notes-mcp protokolliert sub=alice aud=[agent-app]. Das Problem zeigt sich erst beim Wetter-Server.

Beim ersten Chat-Request hat wetter-mcp Alices Token dreimal erhalten, bevor überhaupt ein Wetter-Tool aufgerufen wurde. Der MCP-Client fragt beim ersten Gebrauch jeden Server der Toolbox nach seinen Fähigkeiten und seinen Tools, und jeder dieser Aufrufe trägt den Authorization-Header. Ein fremder Server bekommt das Token also schon dadurch, dass er eingebunden ist.

Das Demo-Skript übernimmt danach die Rolle des Betreibers des Wetter-Servers: Es nimmt das mitgeschnittene Token und fragt damit direkt bei notes-mcp an, ganz ohne Agent und Modell dazwischen:

readNote   {"id":2}                        → "Singularity / RAG-Index neu bauen, …"
addNote    {"title":"Hallo", …}            → "Notiz #5 angelegt."
deleteNote {"id":1}                        → "Notiz #1 geloescht."
GET /api/me an agent-app                   → {"mode":"passthrough","user":"alice"}

Der letzte Aufruf ist der unangenehmste. Das Token wurde für die Agenten-App ausgestellt, also akzeptiert die Agenten-App es auch. Wer es abgreift, kann sich dort als Alice ausgeben und den Agenten in ihrem Namen benutzen.

Ein zweiter Effekt betrifft Carol. Sie gehört nicht zu notes-users; die Policy in authentik verweigert ihr den Zugriff auf die Application notes-mcp. Im Passthrough-Modus hat sie trotzdem eine Notiz angelegt. authentik wird bei diesem Weg nie gefragt, ob Carol den Notiz-Dienst verwenden darf. Geprüft wird nur, ob sie die Agenten-App verwenden darf – und das darf jeder.

Variante B: Ein Token für den Agenten selbst

Die naheliegende Alternative in Quarkus ist quarkus-langchain4j-oidc-client-mcp-auth-provider. Der Agent holt sich damit per Client Credentials ein eigenes Token für den MCP-Server. Die Audience stimmt dann, das Token passt nur zu notes-mcp.

In authentik scheiterte das zunächst an der Policy. Bei Client Credentials legt authentik automatisch einen Service-Account mit dem Namen ak-notes-mcp-client_credentials an und prüft dessen Zugriff auf die Application wie bei jedem anderen Nutzer. Der Service-Account ist nicht in notes-users, also kommt invalid_grant. Erst nachdem er in die Gruppe aufgenommen war, kam ein Token zurück – mit sub=ak-notes-mcp-client_credentials. Auf listNotes antwortet notes-mcp damit: „Keine Notizen vorhanden.”

Das ist konsequent. Der Server sieht einen Service-Account und keinen Nutzer. Um trotzdem Alices Notizen zu liefern, müsste er dem Agenten glauben, in wessen Auftrag dieser gerade handelt – etwa über ein Tool-Argument oder einen eigenen Header. Die Trennung zwischen Nutzern läge dann komplett im Code des Agenten, und der Service-Account bräuchte Zugriff im Namen aller. Für Dienste ohne Nutzerbezug ist das in Ordnung. Für persönliche Daten verschiebt es das Problem nur.

Variante C: Token Exchange mit authentik

Token Exchange nach RFC 8693 räumt beide Probleme aus dem Weg. Der Agent schickt das Token des Nutzers an authentik und bekommt ein neues Token zurück, das nur für einen bestimmten MCP-Server gilt, aber weiterhin den Nutzer als sub enthält.

sequenceDiagram
    participant B as Browser
    participant A as agent-app
    participant AK as authentik
    participant N as notes-mcp
    B->>AK: Login
    AK-->>A: aud=agent-app, sub=alice
    B->>A: Frage nach Notizen
    A->>AK: Exchange, audience=notes-mcp
    Note over AK: Federation und<br/>Policy prüfen
    AK-->>A: aud=notes-mcp, sub=alice
    A->>N: listNotes
    N-->>A: Notizen von alice

In authentik braucht das zwei Einstellungen. Der Provider agent-app bekommt den Grant Type Token Exchange. Und jeder Ziel-Provider muss agent-app als vertrauenswürdige Quelle eintragen:

- model: authentik_providers_oauth2.oauth2provider
  identifiers:
    name: notes-mcp
  attrs:
    client_id: notes-mcp
    sub_mode: user_username
    redirect_uris: []
    # Tokens von agent-app dürfen gegen Tokens für notes-mcp getauscht werden
    jwt_federation_providers:
      - !KeyOf agent-app
    property_mappings:
      - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
      - !KeyOf scope-notes

redirect_uris: [] muss dabei explizit stehen, obwohl ein reiner Resource Server keine Redirects braucht. Fehlt das Feld, verwirft authentik das ganze Blueprint mit This field is required. sub_mode: user_username schreibt den Benutzernamen in den sub-Claim und macht die Logs lesbar. In Produktion würde ich beim Standard bleiben, einem Hash.

Der Tausch selbst ist ein einzelner Request an den Token-Endpunkt, authentifiziert mit den Zugangsdaten der Agenten-App:

curl http://localhost:9100/application/o/token/ \
  -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  -d client_id=agent-app -d client_secret=<secret> \
  -d subject_token=<token von alice> \
  -d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
  -d audience=notes-mcp \
  -d scope="openid profile notes"

Das Ergebnis ist ein Token mit iss=…/application/o/notes-mcp/, aud=notes-mcp, sub=alice und dem Scope notes. authentik prüft dabei auch die Policy der Ziel-Application. Für Carol scheitert der Tausch mit invalid_grant; die Gruppenregel greift also wieder, und zwar dort, wo sie hingehört.

Eine Abweichung von der MCP-Spezifikation gibt es allerdings. Die Spezifikation schreibt den resource-Parameter aus RFC 8707 vor, mit der URL des MCP-Servers als Wert. authentik lehnt diesen Parameter beim Token Exchange ab; im Quellcode steht dazu, dass Resource Indicators nicht implementiert sind. Die Antwort lautet invalid_target. Das Ziel wird stattdessen über audience mit der Client-ID des Ziel-Providers gewählt. Ein MCP-Client, der sich strikt an die Spezifikation hält und resource mitschickt, bekommt von authentik also kein Token.

Der Tausch in Quarkus

Auf der Quarkus-Seite übernimmt quarkus-oidc-client den Request. Pro MCP-Server gibt es einen benannten Client mit dem Grant Type exchange:

quarkus.oidc-client.exchange-notes-mcp.auth-server-url=http://localhost:9100/application/o/agent-app/
quarkus.oidc-client.exchange-notes-mcp.client-id=agent-app
quarkus.oidc-client.exchange-notes-mcp.credentials.secret=${AGENT_APP_SECRET}
quarkus.oidc-client.exchange-notes-mcp.grant.type=exchange
quarkus.oidc-client.exchange-notes-mcp.scopes=openid,profile,notes

Das Secret kommt im Lab als Demo-Wert aus der Konfiguration. Subject Token und Audience ändern sich mit jedem Nutzer und werden deshalb beim Aufruf als zusätzliche Parameter übergeben. Ein eigener McpClientAuthProvider pro MCP-Client, gekennzeichnet mit @McpClientName, verbindet das mit der MCP-Integration. Im Lab teilen sich beide Server eine Basisklasse, hier vereinfacht für notes-mcp:

@ApplicationScoped
@McpClientName("notes")
public class NotesAuth implements McpClientAuthProvider {

    @Inject
    McpTokens tokens;

    @Override
    public String getAuthorization(Input input) {
        String subject = currentAccessToken();
        if (subject == null) {
            return null;
        }
        try {
            return "Bearer " + tokens.tokenFor(subject, "notes-mcp");
        } catch (OidcClientException e) {
            // authentik verweigert den Tausch, etwa wegen der Policy
            return null;
        }
    }
}

tokenFor setzt subject_token, subject_token_type und audience, ruft den passenden OidcClient auf und legt das Ergebnis pro Nutzer-Token und Ziel in einen Cache. Lokal dauerte ein Tausch rund 0,1 Sekunden, und er fällt nur einmal pro Nutzer, Server und Gültigkeitsdauer an.

Der catch-Block ist nötig. Ohne ihn endete Carols Anfrage mit HTTP 500, weil die Ausnahme aus dem verweigerten Tausch bis in den Chat-Request durchschlug. Mit return null geht der Aufruf ohne Token raus, notes-mcp antwortet mit 401, und der Agent bekommt die Notiz-Tools für Carol gar nicht erst angeboten.

Der MCP-Server prüft auf der anderen Seite nur noch seine eigene Audience und liefert die Metadaten nach RFC 9728 aus, die die Spezifikation verlangt:

quarkus.oidc.auth-server-url=http://localhost:9100/application/o/notes-mcp/
quarkus.oidc.token.audience=notes-mcp
quarkus.oidc.resource-metadata.enabled=true
quarkus.oidc.resource-metadata.scopes=notes

Ohne Token antwortet notes-mcp dann mit 401 und einem WWW-Authenticate-Header, der auf /.well-known/oauth-protected-resource verweist. Das Dokument nennt authentik als Authorization Server. Für die Demo auf http://localhost musste ich noch force-https-scheme=false setzen, sonst meldet Quarkus dort eine https-URL.

Der Wetter-Server bekommt jetzt ebenfalls ein eigenes Token, mit aud=wetter-mcp. Mitgeschnitten wird es weiterhin, nur nützt es ihm nichts mehr. notes-mcp lehnt es ab; das Quarkus-Log sagt dazu: „Audience (aud) claim [wetter-mcp] doesn’t contain an acceptable identifier”. Die Agenten-App antwortet mit 401. Ein eigener Tausch bei authentik mit den Zugangsdaten von wetter-mcp scheitert ebenfalls mit invalid_grant. Tauschen darf nur die Agenten-App, und dafür braucht es ihr Client-Secret.

Variante D: Delegation mit act-Claim

Nach Variante C weiss notes-mcp, für wen ein Aufruf gilt, aber nicht, wer ihn auslöst. Ein Token, das der Agent für Alice eingetauscht hat, sieht genauso aus wie eines, das Alice in einer eigenen App direkt bekommen hätte. RFC 8693 sieht dafür Delegation vor: Zusätzlich zum Subject Token schickt der Client ein Actor Token mit, und das neue Token enthält den handelnden Akteur im Claim act.

authentik unterstützt das seit 2026.8, mit einer Einschränkung. Der Akteur muss ein authentik-„Actor” sein, ein neuer Benutzertyp für Maschinen-Identitäten. Im Code ist Actor ein Core-Modell, im Blueprint und in der API taucht es aber nicht auf. Die offizielle Oberfläche dafür sind „Agents”, und die gehören zu den Enterprise-Funktionen. Für das Lab habe ich den Actor notes-agent deshalb per ak shell angelegt, ohne Parent-User, mit einem App-Passwort. Damit holt sich die Agenten-App per Client Credentials ein eigenes Token und gibt es beim Tausch als actor_token mit.

Mit authentik 2026.8.2 endete schon das Holen dieses Actor-Tokens mit HTTP 500. Das Event-Logging greift für jeden Actor auf dessen Parent-User zu und stolpert, wenn keiner gesetzt ist. Dokumentation und Code sehen Actors ohne Parent ausdrücklich vor. Behoben ist der Fehler in 2026.8.3; das Lab läuft deshalb mit dieser Version.

Das getauschte Token trägt danach beide Identitäten:

{
  "iss": "http://localhost:9100/application/o/notes-mcp/",
  "sub": "alice",
  "aud": "notes-mcp",
  "act": { "sub": "notes-agent" },
  "scope": "openid profile notes"
}

Damit lassen sich Regeln formulieren, die zwischen Nutzer und Agent unterscheiden. notes-mcp erlaubt Agenten das Lesen und Anlegen, aber nicht das Löschen:

if (caller.actor() != null) {
    throw new ToolCallException(
        "Loeschen ist nur fuer den Nutzer selbst erlaubt, nicht fuer Agenten.");
}

Alice bittet den Agenten, ihre Notiz Nummer 1 zu löschen. Der Agent ruft deleteNote auf, notes-mcp protokolliert DENY deleteNote #1: Agent notes-agent darf nicht loeschen (sub=alice) und liefert das Ergebnis mit isError: true und der Begründung zurück. Die Notiz bleibt erhalten.

Die Antwort des Agenten an Alice lautete trotzdem: „Die Notiz mit der ID 1 wurde erfolgreich gelöscht.” Das war in jedem Durchlauf so. Im Request an Ollama steht die Ablehnung als gewöhnlicher Text in der Tool-Nachricht, und Qwen3.6 hat daraus eine Erfolgsmeldung gemacht. Die Regel auf dem Server hat funktioniert; die Auskunft des Modells darüber war falsch. Was tatsächlich passiert ist, steht nur im Log von notes-mcp.

Die vier Varianten im Vergleich

Das Skript scripts/demo.sh startet die Dienste für jede Variante neu und spielt dieselben Schritte durch. Die Tabelle ist in zwei Läufen identisch herausgekommen, einer davon auf einer frisch aufgesetzten authentik-Instanz. Variante B läuft über ein eigenes Skript, weil sie ohne Nutzer auskommt.

A: PassthroughB: Client CredentialsC: Token ExchangeD: Delegation
aud beim fremden Serveragent-app–wetter-mcpwetter-mcp
Replay an notes-mcplesen, schreiben, löschen–401401
Replay an die Agenten-Appals Alice angemeldet–401401
Carol ohne Gruppekommt durch–von authentik abgewiesenvon authentik abgewiesen
notes-mcp kennt den Nutzerjaneinjaja
notes-mcp kennt den Agentenneinnur ihnneinja
Agent darf löschenja–janein

Was sonst auffiel

Die Tool-Liste eines MCP-Servers holt quarkus-langchain4j einmal und verwendet sie danach für alle Nutzer. Im Passthrough-Modus hatte der Wetter-Server nach Alices erstem Chat drei Tokens, alle von Alice. Bob hat danach nach seinen Notizen gefragt; sein Token kam beim Wetter-Server nie an. Für die Token-Frage ist das günstig. Bietet ein MCP-Server aber je nach Nutzer unterschiedliche Tools an, sehen mit diesem Client vermutlich alle Nutzer die Liste dessen, der zuerst gefragt hat. Das habe ich nicht eigens getestet.

Die automatischen Health-Checks der MCP-Clients laufen in einem Hintergrund-Thread ohne Nutzerkontext. Mit abgesichertem Server bekommen sie nur 401 und schreiben im Minutentakt Stacktraces ins Log. Im Lab sind sie mit auto-health-check=false abgeschaltet.

Qwen3.6 hing bei einer frühen Anfrage mit Notiz- und Wetter-Aufruf fünf Minuten in einem einzigen Modellaufruf, bis Ollama mit einem Fehler abbrach. Nachdem ich den Thinking-Modus mit model-options.think=false abgeschaltet hatte, trat das nicht mehr auf. Die Injection selbst hat das Modell in zehn von zehn gezielten Läufen ignoriert, bei einer Temperatur von 0,1. Für die Token-Frage spielt das keine Rolle. Der Wetter-Server muss das Modell nicht überreden; er bekommt das Token ohnehin. Warum eine solche Injection überhaupt wirken kann, steht im Artikel über Prompt Injection als Rollenverwechslung.

Einordnung

Das Lab ist bewusst klein gehalten: ein Modell, eine lokale authentik-Instanz ohne TLS, zwei MCP-Server, die ich selbst geschrieben habe. Der Angriff in Variante A setzt voraus, dass ein MCP-Server bösartig oder kompromittiert ist. Wer nur eigene Server einbindet, wird diesen Fall seltener haben. Der Effekt mit Carol tritt aber auch ohne jeden Angreifer auf, und er ist der Grund, warum ich Passthrough auch im eigenen Homelab nicht verwenden würde: Die Policies in authentik verlieren für alles hinter dem Agenten ihre Wirkung.

Token Exchange kostet wenig. In authentik sind es zwei Einstellungen pro MCP-Server, in Quarkus ein OIDC-Client und eine kleine Klasse. Dafür bekommt man, was die MCP-Spezifikation verlangt – bis auf den Parameternamen: Tokens, die nur für einen Server gelten, ohne dass der Nutzer verloren geht. Fertig mitgeliefert wird das von quarkus-langchain4j derzeit nicht. Die beiden vorhandenen Auth-Provider decken Passthrough und Client Credentials ab.

Delegation ist in authentik der unfertigere Teil. Der act-Claim funktioniert, aber ohne Enterprise-Lizenz gibt es keinen vorgesehenen Weg, einen Actor anzulegen, und bis 2026.8.2 brach der Weg ohne Parent-User ganz ab. Ob man den Aufwand braucht, hängt davon ab, ob ein MCP-Server zwischen Nutzer und Agent unterscheiden muss. Für Notizen ist das eine Designfrage. Für alles, was Geld bewegt oder Daten löscht, würde ich die Unterscheidung haben wollen – schon wegen des Audit-Trails.

Die falsche Erfolgsmeldung aus Variante D zeigt, warum Berechtigungen auf den Server gehören und nicht in den Prompt. In der Diskussion um Permission Prompts bei Coding-Agenten geht es meist darum, was der Mensch freigibt, bevor ein Agent handelt. Hier hat die Grenze im MCP-Server gehalten. Verlassen konnte man sich danach nur auf das Server-Log, nicht auf die Antwort des Agenten.