Unser Login hing exakt 10 Sekunden lang. Unsere eigenen Security-Header waren schuld.
Ungefähr eine Woche lang sahen wiederkehrende Nutzer, die unser Portal öffneten, wie das Wort „Loading…" zehn Sekunden lang auf dem Bildschirm stand, bevor die Login-Seite erschien. Nicht manchmal. Nicht ungefähr. Zehn Sekunden, jedes Mal, und dann funktionierte alles einwandfrei. Erstbesucher sahen es nie. Nur Leute, die sich schon einmal angemeldet hatten.
Ein Bug, der eine zufällige Zeit braucht, ist ein Performance-Problem. Ein Bug, der exakt zehn Sekunden braucht, ist ein Geständnis. Nichts in einem gesunden Web-Request rundet sich auf eine glatte Zehnerpotenz. Diese Zahl ist nicht die Summe echter Arbeit; sie ist die Obergrenze eines Timeouts, und ein Timeout bedeutet, dass irgendetwas irgendwo geduldig auf etwas wartet, das niemals eintreffen wird.
Dies ist die Geschichte dessen, worauf es wartete, und warum das, worauf es wartete, von einem Security-Header blockiert wurde, auf den wir stolz waren.
Was das Portal beim Mounten tut
Unser Portal ist eine Single-Page-App. Wenn sie lädt, bevor sie irgendetwas anzeigt, versucht sie, eine einzige Frage zu beantworten: Bist du bereits angemeldet? Der höfliche Weg, das mit OIDC zu tun, ist eine stille Prüfung. Die App fragt den Identity Provider: „Wenn dieser Browser bereits eine Session hat, gib mir ein frisches Token, ohne den Nutzer zu behelligen." Unsere Client-Bibliothek, oidc-client-ts, stellt das als signinSilent() bereit, und wir riefen es beim Mounten aus einem renewSession()-Helfer auf.
Diese stille Prüfung kann auf zwei Arten ablaufen. Wenn die App ein Refresh-Token hält, führt die Bibliothek einen leisen Austausch über den Back-Channel durch, ohne UI, und du bist drin. Wenn sie kein Refresh-Token hält, greift die Bibliothek auf den älteren Mechanismus zurück: Sie öffnet ein verstecktes iframe, das auf den Authorize-Endpunkt des Identity Providers mit prompt=none zeigt, und wartet darauf, dass dieses iframe ein Ergebnis zurückmeldet. Der ganze Sinn des iframes ist, dass es unsichtbar ist. Du sollst es nie sehen, und in einem gesunden Setup siehst du es auch nie, weil es sich in Millisekunden auflöst.
Unseres löste sich überhaupt nie auf.
Die Mauer, die wir selbst gebaut haben
Das iframe lädt den Auth-Host. Und der Auth-Host liefert, wie jeder Host, den wir betreiben und der ernst genommen werden will, zwei Header aus, deren einzige Aufgabe es ist, zu sagen: „Du darfst mich nicht in ein Frame packen":
X-Frame-Options: DENYContent-Security-Policy: frame-ancestors 'none'
Das sind Clickjacking-Abwehrmaßnahmen, und sie sind korrekt. Ein Angreifer, der deine Login-Seite in ein iframe packen kann, kann sie unter einem Köder platzieren, einen Nutzer dazu bringen, echte Anmeldedaten in etwas scheinbar Harmloses einzutippen, und sie abgreifen. frame-ancestors 'none' ist die moderne Anweisung, dass kein Origin, nicht einmal unser eigenes, diese Seite einbetten darf. Wir haben es bewusst aktiviert. Es ist genau die Art von Sache, nach der ein Security-Review sucht und die es belohnt.
Als oidc-client-ts also sein verstecktes iframe gegen diesen Host öffnete, tat der Browser genau das, was wir ihm aufgetragen hatten: Er weigerte sich, die Seite in einem Frame zu rendern. Und jetzt kommt der grausame Teil. Ein abgelehntes Frame wirft keinen Fehler. Es gibt kein Error-Event, das die Bibliothek abfangen könnte, kein rejected Promise, keine Konsolenzeile. Das iframe sitzt einfach da, leer, endlos. Aus Sicht der Bibliothek ist noch nichts passiert, also tut sie das Einzige, was sie kann. Sie wartet auf ihren Timeout. Dieser Timeout, der Standardwert silentRequestTimeout, beträgt zehn Sekunden.
Zehn Sekunden lang starrt ein verstecktes iframe eine leere Wand an, dann gibt die Bibliothek auf, das Promise wird endlich rejected, die App zuckt mit den Schultern und leitet dich zur echten Login-Seite weiter, und alles funktioniert. Das Hängen war nie ein Fehlschlag. Es war ein Erfolg, der die landschaftlich reizvolle Route durch ein zum Scheitern verurteiltes iframe nahm.
Zwei korrekte Dinge, eine schlechte Naht
Was das wirklich schwer erkennbar machte, war, dass nichts kaputt war. Die Security-Header waren richtig. Der Silent-Renew-Fallback war richtig, ein legitimes und weit verbreitetes OIDC-Muster. Jede Komponente verhielt sich genau so, wie sie entworfen war, und genau so, wie es sich jeder Reviewer wünschen würde. Das zehnsekündige Hängen lebte in keiner einzelnen von ihnen. Es lebte im Raum zwischen ihnen, in der Annahme, die jede über die andere traf. Die SSO-Bibliothek nahm an, sie könne den Identity Provider in ein Frame packen. Der Identity Provider nahm an, dass niemand ihn jemals in ein Frame packen dürfe. Beide Annahmen waren vertretbar. Sie waren schlicht unvereinbar, und keine einzelne Datei enthielt den Widerspruch.
Warum es überhaupt zum iframe griff
Damit blieb noch eine Frage offen. Der schnelle Weg, der Refresh-Token-Austausch, hätte das iframe komplett übersprungen. Warum landeten wiederkehrende Nutzer auf dem langsamen Weg? Weil sie kein Refresh-Token zum Halten hatten. Und sie hatten kein Refresh-Token, weil unsere eigenen Portal-OAuth-Clients ohne AllowOfflineAccess provisioniert worden waren, das Flag, das einen Client dazu berechtigt, eins ausgestellt zu bekommen. Kein Offline-Zugriff, kein Refresh-Token, kein schneller Weg, und jeder wiederkehrende Nutzer wurde in das iframe abgedrängt, das niemals laden konnte.
Das war der eigentliche Defekt, und es war ein Datenproblem, das sich über jeden Tenant erstreckte, keine einzeilige Code-Änderung, die wir einmalig ausliefern konnten. Die Reparatur ist deshalb ein Reconcile-Service, der AllowOfflineAccess beim Start erneut auf die Portal-Clients jedes Tenants anwendet und so die gesamte Flotte beim nächsten Deploy korrigiert, ohne dass jemand einen Tenant von Hand anfassen muss. Refresh-Tokens flossen wieder, und der schnelle Weg erwachte von selbst zu neuem Leben.
Der Fix und die Lektion
Der Reconcile-Service behob die Ursache. Aber ein Login sollte nicht zehn Sekunden lang hängen, selbst wenn er am Ende auf dem langsamen Weg landet, also härteten wir auch die Naht ab. renewSession() inspiziert jetzt zuerst den gespeicherten Nutzer: Wenn kein Refresh-Token vorhanden ist, bricht es sofort ab und gibt umgehend nichts zurück, überspringt das iframe, von dem es bereits weiß, dass es zum Scheitern verurteilt ist, und schickt den Nutzer direkt zum interaktiven Login. Der Refresh-Token-Schnellweg bleibt unberührt. Und als Absicherung für jede Hintergrunderneuerung, die trotzdem ein iframe öffnet, kürzten wir den Timeout von zehn Sekunden auf fünf, sodass der schlimmste Fall nur noch halb so schlimm ist.
Die Lektion, die wir tatsächlich mitgenommen haben, handelt von einer Kategorie von Bug, nicht von diesem einen Fall. Ein Hängen ist ein Bug, auch wenn es keinen Fehler ausgibt, keine Exception, keine rote Zeile in einem Log. Der einzige Beweis, den es hinterlässt, ist verstrichene Zeit. Und wenn diese Zeit eine glatte, runde Zahl ist, mach dich nicht auf die Jagd nach langsamer Arbeit, die es zu optimieren gilt. Mach dich auf die Jagd nach einem Timeout, und finde dann das Ding am anderen Ende, das still, dauerhaft und niemals antworten wird. Unseres war ein iframe, das höflich an eine Tür klopfte, die wir absichtlich verriegelt hatten.
Wenn dir lieber wäre, dass dein Login-Flow bereits wüsste, dass sich ein abgeriegelter Auth-Host und ein Silent-Renew-iframe nicht vertragen, dann ist das eine Naht, in die wir bereits hineingelaufen sind, damit du es nie musst. Authagonal liefert die SSO-Verkabelung und die Security-Header als ein System aus, das gemeinsam getestet wurde, nicht als zwei korrekte Hälften, deren Unvereinbarkeit du bei zehn Sekunden pro Seitenaufruf entdecken darfst.