Ein BFF für viele Mandanten, und die eine Anfrage, die ohne Cookie eintrifft
Single-Page-Apps hat man beigebracht, ihre eigenen Tokens zu verwahren. Die App führt den OAuth-Flow im Browser aus, bekommt ein Access Token und meist auch ein Refresh Token zurück, hält sie irgendwo in JavaScript vor und hängt sie an jeden API-Aufruf an. Das ist gut dokumentiert, so zeigen es die meisten Tutorials, und es legt die langlebigste Anmeldeinformation genau an den einen Ort, den man nicht verteidigen kann: eine Laufzeitumgebung, die bereitwillig alles ausführt, was seinen Weg auf die Seite findet. Eine erfolgreiche Injection, eine kompromittierte Abhängigkeit irgendwo in eurem Build, und niemand muss mehr irgendwen phishen. Sie lesen das Token aus und verschwinden, und es funktioniert weiter, bis es abläuft.
Das Backend-for-Frontend-Muster bringt die Anmeldeinformation außer Reichweite. Der Browser spricht mit einem kleinen Server, der zu eurer App gehört, und dieser Server ist der vertrauliche OAuth-Client. Er führt den Code-Austausch durch, er hält die Access- und Refresh-Tokens, und er reicht dem Browser nichts als ein undurchsichtiges Cookie. Dies ist eine Beschreibung, wie wir unseren gebaut haben, und des einen Teils, der sich als wirklich interessant herausstellte: ihn mehr als einen Mandanten bedienen zu lassen.
Was dem Browser am Ende bleibt
Ein Cookie namens __Host-agbff, markiert als HttpOnly, SameSite=Lax, auf den gesamten Pfad ausgelegt, ohne Ablaufdatum, sodass es mit der Browser-Session stirbt. Sein Wert sind 256 Bit Zufall und sonst nichts. Es ist kein Token, es dekodiert sich zu nichts, und es von der Leitung zu stehlen ist bei einem __Host--Cookie über TLS schlicht nicht möglich.
Alles Echte liegt serverseitig, in einem Session-Datensatz in einem verteilten Cache: das Access Token, das Refresh Token, das ID Token, wann das Access Token abläuft und zu welchem Mandanten die Session gehört. Der Login selbst ist ein gewöhnlicher Authorization-Code-Flow mit PKCE, ausgeführt von einem vertraulichen Client, der sich mit seinem Secret am Token-Endpunkt authentifiziert. Das ID Token wird auf dem Rückweg ordentlich validiert: Aussteller, Zielgruppe, Signatur gegen die veröffentlichten Schlüssel, Gültigkeitsdauer, und dann ein Vergleich der Nonce in konstanter Zeit gegen den Wert, der vor der Weiterleitung hinterlegt wurde. Erst nach all dem existiert eine Session und wird ein Cookie gesetzt.
Ein Header, der nur auf Existenz geprüft wird, und sonst auf nichts
Der Browser ruft das BFF für seine eigenen Nutzerdaten auf und um die proxied API zu erreichen, und diese Aufrufe tragen einen eigenen Header, x-authagonal-bff. Sein Wert ist belanglos. Die reine Anwesenheit ist die gesamte Prüfung.
Das sieht faul aus, ist es aber nicht. Die gesamte Klasse der Cross-Site Request Forgery beruht auf einem Formular-Post, einem Image-Tag oder einer Navigation, die ein anderer Origin auslösen kann, während euer Cookie mitreist, und keines davon kann einen eigenen Header setzen. In dem Moment, in dem angreifendes JavaScript versucht, einen hinzuzufügen, ist es keine einfache Anfrage mehr, sondern eine mit Preflight, die eure CORS-Richtlinie ablehnt. Der Header ist kein zu erratendes Geheimnis, er ist der Beweis, dass die Anfrage von Code stammt und nicht von Markup.
Er ist bei den per Skript ausgelösten Aufrufen erforderlich und bewusst nicht bei jenen, die von Natur aus Navigationen auf oberster Ebene sind: einen Login starten, vom Identity Provider zurückkommen, einem Logout-Link folgen. Bei einer Browser-Navigation einen eigenen Header zu verlangen, würde die Navigation schlicht kaputtmachen.
Refresh, genau einmal
Jede proxied Anfrage kann feststellen, dass das Access Token kurz vor dem Ablauf steht, und eine ausgelastete Seite stellt mehrere Anfragen zugleich. Erneuert man sie alle naiv, bekommt man ein kleines Desaster: mehrere gleichzeitige Refreshes mit einem rotierenden Refresh Token, von denen jeder die anderen ungültig macht, sodass der Nutzer am Ende durch seinen eigenen Datenverkehr abgemeldet wird.
Deshalb ist der Refresh single-flight, pro Session. Die erste durchgehende Anfrage belegt ein Gate mit der Session-ID als Schlüssel, und alle anderen warten. Der feine Punkt ist, was die Wartenden tun, sobald sie an der Reihe sind: Sie lesen die Session erneut aus dem Store, bevor sie irgendetwas entscheiden, denn die Anfrage, die das Gate zuerst hielt, hat sehr wahrscheinlich bereits erneuert, und der Wert, den sie beim Anstellen in der Hand hatten, ist veraltet. Einmal erneuern, dann verwenden alle das Ergebnis.
Er ist außerdem rotationsbewusst im langweiligen, notwendigen Sinne. Gibt der Token-Endpunkt ein neues Refresh Token zurück, ersetzt es das gespeicherte. Schlägt der Refresh mit einem Token-Fehler fehl, wird die Session gelöscht und der Nutzer abgemeldet, denn ein Refresh Token, das abgelehnt wurde, wird beim nächsten Versuch nicht plötzlich akzeptiert, und es erneut zu versuchen ist nur ein langsamerer Weg, abgemeldet zu werden.
Ein BFF, viele Mandanten
Ein Single-Tenant-BFF löst beim Start alles aus der Konfiguration auf: eine Authority, eine Client-ID, ein Secret. Viele Mandanten aus einem einzigen Deployment zu bedienen, bricht das, denn nun muss das BFF herausfinden, zu welchem Aussteller ein Login gehört, und es muss das tun, bevor irgendeine Session existiert.
Die Auswahl geschieht am Login-Endpunkt, über einen Query-Parameter, den ihr benennen dürft, also kann es ?slug= oder ?org= sein oder wie auch immer euer Produkt einen Kunden bereits nennt. Ein Resolver macht aus diesem Schlüssel eine Mandanten-Konfiguration: Authority, Client-ID, Secret, Scopes. Löst er sich zu nichts auf, wird der Login auf der Stelle verweigert.
Dann muss der Schlüssel eine Rundreise zu einem Identity Provider überstehen, der nichts von ihm weiß, und wieder zurückkommen. Es gibt noch keine Session, die ihn halten könnte, also reist er im Correlation-Cookie mit, dem kurzlebigen verschlüsselten Cookie, das genau aus diesem Grund bereits den PKCE-Verifier, den State und die Nonce trägt. Fünfzehn Minuten, eines pro Login-Versuch. Auf dem Rückweg wird das Cookie entschlüsselt, der Mandant wird erneut aus dem darin enthaltenen Schlüssel aufgelöst, und der Code wird gegen den Token-Endpunkt dieses Mandanten eingetauscht. Erst dann wird der Mandantenschlüssel in die Session geschrieben, wo er zur dauerhaften Antwort wird.
Von da an löst jeder Pfad erneut aus der Session auf statt aus irgendetwas, das der Browser gesagt hat: Refresh, Logout, der Proxy. Und wenn ein Mandant sich nicht mehr auflösen lässt, weil er gelöscht oder deaktiviert wurde, wird die Session zerstört, statt stillschweigend auf irgendeinen Standard-Mandanten zurückzufallen. Ein nicht auflösbarer Mandant ist ein Logout, kein Achselzucken.
Die Anfrage, die ohne Cookie eintrifft
Beim Back-Channel-Logout zahlt sich dieses Design aus. Der Identity Provider schickt ein Logout-Token per POST direkt an euren Server, wenn eine Session anderswo endet. Kein Cookie. Keine Session. Überhaupt kein Browser beteiligt. In einem Single-Tenant-BFF ist das unspektakulär, da es nur einen Aussteller geben kann, von dem es stammt. In einem Multi-Tenant-BFF müsst ihr "für welchen Mandanten ist das?" beantworten, bevor ihr "ist das überhaupt echt?" beantworten könnt, und das einzige verfügbare Material ist das Token selbst, das ihr nicht verifiziert habt und dem ihr daher nicht trauen könnt.
Die Reihenfolge, die das sicher macht, sollte man präzise benennen. Lest den Aussteller-Claim aus dem unverifizierten Token und nutzt ihn für genau eine Sache: die Wahl, welche Mandanten-Konfiguration geladen wird. Verifiziert dann die Signatur des Tokens gegen die veröffentlichten Schlüssel dieses Mandanten und dessen Client-ID als Zielgruppe. Ein gefälschter Aussteller wählt den Mandanten, der er vorgibt zu sein, und scheitert dann an der Verifizierung gegen dessen Schlüssel, weil der Angreifer sie nicht hat. Nichts wurde jemals auf der Grundlage des nicht vertrauenswürdigen Claims akzeptiert. Der Claim hat nur entschieden, wer über ihn urteilen darf, und der Richter ist ein Vertrauensanker, den wir ohnehin schon hatten.
Danach kommen die Prüfungen, die ein Logout-Token zu einem Logout-Token machen: Es darf keine Nonce tragen, da es niemanden authentifiziert; es muss das Back-Channel-Logout-Ereignis tragen; und es muss entweder eine bestimmte Session oder ein Subject benennen. Eine Session-ID beendet eine Session. Ein Subject beendet jede Session, die dieser Nutzer hat, und das ist die Form, die wir aussenden, denn "melde mich überall ab" ist das, was die Leute tatsächlich meinen, wenn sie auf überall abmelden klicken. Deshalb führt der Session-Store einen Index nach Session-ID und einen weiteren nach Subject, statt nur nach Cookie-Wert.
Single-Tenant bleibt langweilig
All das liegt im Normalfall brach. Der Standard-Resolver gibt dieselbe Konfiguration zurück, egal welchen Schlüssel man ihm reicht, und sein Auflösen-nach-Aussteller gibt diese Konfiguration bedingungslos zurück, weil es nur eine gibt. Ein Single-Tenant-BFF verhält sich genau so, wie es sich vor der Existenz dieser Nahtstelle verhielt: kein Mandanten-Parameter, nichts Zusätzliches im Cookie, keine neuen Fehlerarten. Die Multi-Tenant-Maschinerie ist optional und unsichtbar, bis ihr einen Query-Parameter benennt.
Trenne Auswahl von Vertrauen
Wenn ihr etwas mandantenfähig macht, lohnt sich die Übung, jeden Eintrittspunkt aufzuzählen und zu fragen, was den Mandanten an jedem einzelnen ausweist. Browser-Navigationen tragen ein Cookie, das ihr ausgestellt habt und kontrolliert. Redirect-Callbacks tragen State, den ihr signiert habt. Server-zu-Server-Callbacks tragen nur, was der Aufrufer zu senden beschloss, und der Aufrufer könnte lügen.
Der unbequeme Eintrittspunkt, der ohne irgendetwas Vertrauenswürdiges, ist der, dessen Entwurf darüber entscheidet, ob das gesamte System solide ist, und es ist meist der, an den bis spät niemand denkt. Das Muster, das uns hindurchgebracht hat, lässt sich verallgemeinern: Trenne Auswahl von Vertrauen. Es ist in Ordnung, anhand nicht vertrauenswürdiger Daten zu routen, solange das Routing nur wählt, welcher Vertrauensanker die endgültige Entscheidung treffen darf, und die endgültige Entscheidung von etwas getroffen wird, das der Angreifer nicht fälschen kann.
Wenn ihr das lieber nicht alles selbst schreiben möchtet: Authagonal liefert das BFF für .NET und für Node mit, und das Portal stellt den vertraulichen Client bereit, den es braucht, Redirect-URI, Back-Channel-Logout-Endpunkt und alles Übrige, mit einem Klick.