Zurück zum Blog

Die Frist, die den Schlüssel wegwirft: Timeouts um einen OAuth-Token-Tausch

Ein asyncio.timeout um einen OAuth-Token-Tausch klingt nach Vorsicht. Bei rotierenden Refresh-Tokens kann der Abbruch den einzigen gültigen Schlüssel verwerfen. Wie das passiert und wo die Frist hingehört.

5 Min. Lesezeitvon Daniel AlischKI-Agenten, Engineering, Integration

Ein Timeout ist die freundlichste Form von Ungeduld: Nach fünfzehn Sekunden geben wir auf und machen sauber weiter. Bei den meisten Aufrufen stimmt das. Bei einem Token-Tausch mit rotierenden Refresh-Tokens kann genau diese Freundlichkeit den Zugang dauerhaft zerstören, und das habe ich in AI Employee beinahe selbst eingebaut.

Die Ausgangslage

AI Employee spricht für einige Funktionen mit GitHub, und zwar mit einem OAuth-Zugang, den ein Mensch einmal erteilt hat. Der Zugriffstoken läuft nach kurzer Zeit ab und wird mit einem Refresh-Token erneuert. Für einen neuen Ablauf, der am Ende einer Besprechung den Stand der dort genannten Issues abfragt, wollte ich verhindern, dass ein hängender Aufruf den ganzen Abschluss blockiert. Die naheliegende Lösung: ein asyncio.timeout(15) um den Block, der sich den Zugang holt und danach arbeitet.

Im Test lief alles. Ein Gegenleser, ein zweiter Agent ohne meinen Verlauf, hat den Block trotzdem als Blocker markiert. Er hatte nicht meinen Code gelesen, sondern den Code darunter.

Was unter der Frist passiert

Die Funktion, die den Zugang liefert, prüft den gespeicherten Token. Ist er abgelaufen, holt sie sich eine Transaktionssperre, damit nicht zwei Läufe gleichzeitig erneuern, und schickt dann den Refresh-Token an den Anbieter. Erst wenn die Antwort da ist, speichert sie das neue Paar aus Zugriffs- und Refresh-Token.

Zwei Details machten meine Frist gefährlich:

  • Die Sperre wartet selbst bis zu dreißig Sekunden. Allein das Warten konnte meine fünfzehn Sekunden also aufbrauchen. Das ist der harmlose Teil, dann fällt der Lauf eben aus.
  • Bei Anbietern mit Rotation ist ein Refresh-Token nur einmal gültig. Sobald der Server die Anfrage verarbeitet hat, ist der alte Token verbraucht, und der einzige gültige Nachfolger steht in der Antwort.

Der zweite Punkt ist der teure. Läuft die Frist ab, nachdem die Anfrage beim Anbieter angekommen ist, aber bevor die Antwort gespeichert wurde, bricht Python die wartende Koroutine mit CancelledError ab. Der Server hat rotiert, der Client hat die Antwort verworfen. In der Datenbank liegt ein Refresh-Token, den es nicht mehr gibt. Der nächste Lauf versucht ihn, bekommt eine Ablehnung, und ab da hilft nur, dass ein Mensch den Zugang neu erteilt.

Das Tückische: Im Protokoll steht an dieser Stelle nur ein Timeout. Die eigentliche Ursache, der verbrannte Schlüssel, zeigt sich erst beim nächsten Lauf und sieht dort aus wie ein zurückgezogener Zugang.

Warum ein Abbruch hier kein Abbruch ist

Ein Timeout ist nur dann eine saubere Absage, wenn der abgebrochene Vorgang keine Wirkung hinterlässt, die man nicht mehr sehen kann. Ein Lesezugriff erfüllt das. Ein Schreibzugriff mit Antwort, die man zwingend braucht, erfüllt es nicht. Der Token-Tausch gehört zur zweiten Sorte: Er verändert Zustand auf der fremden Seite, und die Quittung dafür ist das einzige Exemplar.

Die Regel, die ich daraus mitnehme: Eine Frist gehört um Arbeit, die man wiederholen darf. Um einen Vorgang, der auf der Gegenseite etwas verbraucht, gehört keine abbrechbare Frist, sondern höchstens eine, die den Vorgang zu Ende laufen lässt und nur den Wartenden entlässt.

Wie der Umbau aussieht

Im Pull Request, der noch auf seine Abnahme wartet, steht der Zugang jetzt vor der Frist. Erst den Token holen, ohne äußere Frist; die Erneuerung hat ihre eigene Sperre und ihre eigenen HTTP-Zeitgrenzen. Danach erst die eigentliche Arbeit unter asyncio.timeout, denn die darf ruhig abbrechen und beim nächsten Lauf neu beginnen.

Wer den Tausch trotzdem unter eine Frist stellen muss, kann ihn mit asyncio.shield schützen. Dann bricht nur das Warten ab, der Tausch selbst läuft weiter und speichert sein Ergebnis:

python
import asyncio

async def zugang_holen(oauth):
    # Erneuerung als eigene Aufgabe: ein Abbruch des Wartenden
    # erreicht sie nicht, das neue Token-Paar wird trotzdem gespeichert.
    erneuerung = asyncio.ensure_future(oauth.get_valid_token("github"))
    try:
        async with asyncio.timeout(15):
            return await asyncio.shield(erneuerung)
    except TimeoutError:
        # Kein Zugang für diesen Lauf, aber auch kein verbrannter Schlüssel.
        return None

Wichtig ist dabei, die Aufgabe nicht wegzuwerfen, solange sie läuft. Sonst räumt das Programm sie beim Beenden womöglich doch noch ab. In einem langlebigen Dienst ist das unkritisch, in einem kurzen Skript sollte man am Ende auf sie warten.

Wie man solche Stellen findet

Der Fehler stand nicht in meiner Zeile, sondern drei Aufrufe tiefer. Deshalb suche ich seitdem bei jeder Frist und jedem Abbruchpfad gezielt nach drei Dingen in der aufgerufenen Kette: Sperren mit eigener Wartezeit, Netzaufrufe, die Zustand auf der Gegenseite ändern, und Speichervorgänge, die erst nach der Antwort kommen. Findet sich alles drei, ist die Frist an dieser Stelle falsch.

Dasselbe Muster gibt es außerhalb von OAuth: Bestellungen, Zahlungen, das Anlegen von Ressourcen bei einem Cloud-Anbieter. Überall dort, wo die Antwort eine Kennung enthält, die man sonst nie wieder bekommt, ist ein Abbruch keine Rücknahme.

Gefunden hat es übrigens nicht meine eigene Durchsicht, sondern ein Agent, der den Code ohne meine Annahmen gelesen hat. Ich hatte die Frist als Sicherheitsnetz gebaut und deshalb gar nicht erst gefragt, was sie abschneidet.

Fazit

Ein Timeout ist ein Versprechen, dass nach dem Abbruch alles so ist wie vorher. Beim Token-Tausch mit Rotation kann man dieses Versprechen nicht halten. Also gehört die Frist dorthin, wo Abbrechen wirklich nichts kostet, und der Schlüsseltausch darf zu Ende laufen, auch wenn niemand mehr auf ihn wartet.

Weiterlesen