In dem Boilerplate, mit dem ich Projekte starte, liegen zwischen dem Commit, der den ersten Auth-Anbieter integriert, und dem, der ihn durch den zweiten ersetzt, zwei Tage. Zwei Tage. Der erste brachte Middleware für geschützte Routen und einen Login-Screen; der zweite riss das komplett heraus und setzte ein anderes SDK mit eigenen Sign-in- und Sign-up-Seiten hin. Seitdem ist dieselbe Vorlage durch zwei weitere Anbieter gegangen und fast ein Jahr später bei handgeschriebener Middleware gelandet.
Das ist kein Pech. Es ist ein Muster, und ich habe es in mindestens vier Repos wiederholt.
Ich fange immer mit einem gemanagten Anbieter an, und immer aus demselben Grund
An Tag eins eines Projekts will ich kein Passwort-Hashing schreiben, keine E-Mail-Verifizierung und keinen OAuth-Flow mit Google. Ich installiere ein SDK, setze zwei Umgebungsvariablen, und an einem Nachmittag gibt es ein Login. Das ist an diesem Tag die richtige Entscheidung: Das Projekt weiß noch nicht, ob es in einem Monat überhaupt existiert.
Das Problem ist, dass dieses SDK nicht brav am Rand der Anwendung bleibt. Es kriecht ins Layout, in den User-Kontext, in jede Server Action, die wissen muss, wer da aufruft, und ins Datenbankschema, weil die user_id aus seiner Tabelle am Ende in allen deinen Tabellen als Fremdschlüssel steht.
Die vier Migrationen und was jede ausgelöst hat
- Anbieter eins zu Anbieter zwei, in zwei Tagen. Der erste funktionierte, aber das Preismodell und die Kopplung an sein eigenes Dashboard passten nicht. Keine einzige Zeile Auth hat den Wechsel überlebt.
- Anbieter zwei, sechs Monate Koexistenz. Hier gab es alles: Notizen im README über sein seltsames Verhalten mit Suspense, Refactors der Middleware, um die geschützten Domains aus einer Config-Datei zu lesen, und einen Wechsel von
useProfilezuuseUser, um nicht länger meine eigene Schicht über seiner zu pflegen. - Anbieter zwei zu Anbieter drei. Ein ganzer PR mit dem Namen "migrate from" plus dem Namen des Vorgängers. Dreißig Dateien angefasst.
stack.tsund die Catch-all-Routeapp/handler/[...stack]/page.tsxverschwinden,auth.tsim Client undapp/api/auth/[...path]/route.tsim Server kommen dazu. - Anbieter drei zu eigener Middleware. Der Commit sagt wörtlich "replace neonAuth with custom auth middleware and update session handling". Vom gemanagten SDK behalte ich den Client; den Rest kontrolliere ich selbst.
Was immer kaputtgeht: die URLs
In der zweiten Migration gibt es zwei Commits am selben Tag, direkt hintereinander. Der erste hinterlässt die Konfigurationsdatei so:
export const stackServerApp = new StackServerApp({
tokenStore: "nextjs-cookie",
urls: {
signIn: "/ass",
signUp: "/asdds",
},
});
Das kommt dabei heraus, wenn du von Hand ausprobierst, wohin was umleitet, und dein Testgetippe stehen lässt. Der zweite Commit setzt beide wieder auf /auth. Es ist lächerlich und es ist genau der Fehler, den du bei jeder Auth-Migration abbekommst: Der Anbieter weiß nicht, wo dein Login-Screen wohnt, also gibt es in seiner Config eine deklarierte URL, die mit einer echten Router-Route übereinstimmen muss, und kein Typsystem prüft das. Er leitet auf einen 404 um, und das Symptom ist nicht "URL falsch", sondern "das Login funktioniert nicht".
Die andere Hälfte dessen, was kaputtgeht, ist die Form der Session. Beim Umstieg auf eigene Middleware gab getSession nicht mehr das SDK-Objekt zurück, sondern etwas Explizites:
type SessionData = NonNullable<Awaited<ReturnType<typeof auth.getSession>>["data"]>;
export type Session = SessionData | { session: null; user: null };
Dieses | { session: null; user: null } ist das Zugeständnis, das man machen muss. Das vorherige SDK gab ein nacktes Nullable zurück, und der halbe Code machte session?.user. Beim Normalisieren kompilierten alle Stellen nicht mehr, die davon ausgingen, dass die Session undefined sein könnte. Das ist der teure Teil der Migration, nicht das Login.
Was ich gewinne und was ich verliere, wenn ich es selbst schreibe
Was ich gewinne, ist konkret: Kontrolle über das Session-Objekt, darüber, wo das Cookie liegt, und darüber, was genau passiert, wenn es keine Session gibt. Und eine Sache, die ich nicht zu schätzen wusste, bis ich sie brauchte: Autorisierungsregeln an derselben Stelle unterbringen zu können.
In einem BI-Tool für ein großes Unternehmen wurde die Autorisierung am Ende eine Liste von E-Mail-Adressen direkt in der Middleware:
const authorizedEmails = [ /* ... */ ];
const hasFullAccess = userEmail && authorizedEmails.includes(userEmail);
Mit zwei verschiedenen Weiterleitungen: Wer nicht authentifiziert ist, geht zum Login, wer authentifiziert ist, aber keine Berechtigung hat, geht auf die Startseite. Mit einem gemanagten Anbieter machst du das im Ringkampf mit seinen Claims oder seinem Panel. Hier habe ich es in zehn Zeilen geschrieben. Ich habe auch ein console.log("User email:", userEmail) stehen lassen, das in einem späteren Commit wieder rausmusste, und in dieser App wurde die Auth dreimal in einem Monat neu geschrieben.
Was ich verliere, ist alles, was der Anbieter tat und du nicht gesehen hast: Token-Rotation, Invalidierung von Sessions, die Wiederherstellungs-Mail, das Rate Limit beim Login. Nichts davon ist unmöglich, aber es wird zu meinem Code, ohne Tests, die nicht ich geschrieben habe.
Mein Kriterium heute
Es lautet nicht "schreib deine eigene Auth". Es ist langweiliger: Geh ab Tag eins davon aus, dass du den Anbieter wechseln wirst. Was ich heute mache, bevor ich irgendetwas installiere:
- Genau eine Funktion
getSession()in meinem Code. Keine Komponente importiert das SDK. Den Rückgabetyp definiere ich, ich leite ihn nicht vom Anbieter ab. Das ist der Unterschied zwischen dreißig migrierten Dateien und einer. - Die
user_idmeiner Datenbank gehört mir. Die des Anbieters kommt in eine eigene Spalte. Sonst ist die Migration kein Bibliothekswechsel mehr, sondern eine Datenmigration. - Die Login-URLs in eine Konstante, nicht in die Anbieter-Config. Damit das
/asssich nirgends verstecken kann. - Autorisierung niemals beim Anbieter. Wer hereindarf, gehört dem Anbieter; was jemand tun darf, gehört meinem Code.
Vor Kurzem habe ich ein neues Projekt gestartet und wieder mit einem gemanagten Anbieter angefangen. Am ersten Tag kam man nicht hinein: Die Auth-Basis-URL zeigte auf einen Platzhalterwert aus dem Boilerplate. Der Zyklus beginnt von vorn, und diesmal weiß ich genau, wo es wehtun wird.
