In de boilerplate die ik gebruik om projecten te starten, liggen de commit die de eerste auth-provider integreert en de commit die hem vervangt door de tweede twee dagen uit elkaar. Twee dagen. De eerste zette middleware voor beschermde routes neer plus een loginscherm; de tweede rukte dat helemaal uit en zette er een andere SDK neer met zijn eigen sign-in- en sign-up-pagina's. Sindsdien is diezelfde template door nog twee providers gegaan en bijna een jaar later geëindigd met met de hand geschreven middleware.
Het is geen pech. Het is een patroon, en ik heb het in minstens vier repo's herhaald.
Ik begin altijd met een managed provider, en altijd om hetzelfde
Op dag één van een project wil ik geen wachtwoordhashing schrijven, geen e-mailverificatie, en geen OAuth-flow met Google. Ik installeer een SDK, zet twee omgevingsvariabelen, en in één middag is er een login. Dat is die dag de juiste beslissing: het project weet nog niet of het over een maand nog bestaat.
Het probleem is dat die SDK niet netjes aan de rand van de applicatie blijft zitten. Hij kruipt in de layout, in de gebruikerscontext, in elke server action die moet weten wie er aanroept, en in het databaseschema, want de user_id uit zijn tabel is degene die uiteindelijk foreign key wordt in al die van jou.
De vier migraties en wat elk ervan in gang zette
- Provider één naar provider twee, in twee dagen. De eerste werkte, maar het prijsmodel en de koppeling met zijn eigen dashboard pasten niet. Geen enkele regel auth overleefde de wissel.
- Provider twee, zes maanden samenleven. Hier kwam van alles langs: aantekeningen in de README over zijn vreemde gedrag met Suspense, refactors van de middleware om de beschermde domeinen uit een configbestand te lezen, en een wissel van
useProfilenaaruseUserom te stoppen met het onderhouden van mijn eigen laag bovenop de zijne. - Provider twee naar provider drie. Een hele PR met de naam "migrate from" plus de naam van de vorige. Dertig aangeraakte bestanden.
stack.tsen de catch-all-routeapp/handler/[...stack]/page.tsxverdwijnen, enauth.tsop de client enapp/api/auth/[...path]/route.tsop de server verschijnen. - Provider drie naar eigen middleware. De commit zegt letterlijk "replace neonAuth with custom auth middleware and update session handling". Van de managed SDK houd ik de client; de rest beheer ik zelf.
Wat er altijd breekt: de URL's
In de tweede migratie staan twee commits op dezelfde dag, direct achter elkaar. De eerste laat het configuratiebestand zo achter:
export const stackServerApp = new StackServerApp({
tokenStore: "nextjs-cookie",
urls: {
signIn: "/ass",
signUp: "/asdds",
},
});
Dat is wat eruit komt als je met de hand aan het uitproberen bent waar alles naartoe redirect en je testgetyp laat staan. De tweede commit zet beide terug op /auth. Het is belachelijk en het is precies de fout die je bij elke auth-migratie te verduren krijgt: de provider weet niet waar jouw loginscherm woont, dus er staat een URL in zijn config die moet overeenkomen met een echte route in de router, en er zijn geen types die dat controleren. Hij redirect naar een 404 en het symptoom is niet "verkeerde URL": het is "de login werkt niet".
De andere helft van wat er breekt is de vorm van de sessie. Bij de stap naar de eigen middleware ging getSession van het object van de SDK teruggeven naar iets expliciets teruggeven:
type SessionData = NonNullable<Awaited<ReturnType<typeof auth.getSession>>["data"]>;
export type Session = SessionData | { session: null; user: null };
Die | { session: null; user: null } is de concessie die je moet doen. De vorige SDK gaf een kale nullable terug en de helft van de code deed session?.user. Bij het normaliseren stopten alle plekken die aannamen dat de sessie undefined kon zijn met compileren. Dat is het dure deel van de migratie, niet de login.
Wat ik win en wat ik verlies door het zelf te schrijven
Wat ik win is concreet: controle over het sessieobject, over waar de cookie wordt opgeslagen en over wat er precies gebeurt als er geen sessie is. En iets wat ik niet had gewaardeerd tot ik het nodig had: autorisatieregels op dezelfde plek kunnen zetten.
In een BI-tool voor een groot bedrijf werd de autorisatie uiteindelijk een lijst met e-mailadressen in de middleware zelf:
const authorizedEmails = [ /* ... */ ];
const hasFullAccess = userEmail && authorizedEmails.includes(userEmail);
Met twee verschillende redirects: de niet-geauthenticeerde gaat naar de login, de geauthenticeerde zonder rechten gaat naar de voorpagina. Met een managed provider doe je dat door te vechten met zijn claims of zijn paneel. Daar schreef ik het in tien regels. Ik liet ook een console.log("User email:", userEmail) staan die in een latere commit weg moest, en die app heeft zijn auth drie keer in een maand herschreven.
Wat ik verlies is alles wat de provider deed en wat je niet zag: tokenrotatie, sessies invalideren, de herstelmail, de rate limit op de login. Niets daarvan is onmogelijk, maar het wordt mijn code, zonder tests die ik niet zelf heb geschreven.
Mijn criterium nu
Het is niet "schrijf je eigen auth". Het is saaier: ga er vanaf dag één van uit dat je van provider gaat wisselen. Wat ik vandaag doe voordat ik iets installeer:
- Eén enkele functie
getSession()in mijn code. Geen enkele component importeert de SDK. Het retourtype definieer ik zelf, ik leid het niet af van de provider. Dat is het verschil tussen dertig bestanden migreren en er één migreren. - De
user_idvan mijn database is van mij. Die van de provider gaat in een aparte kolom. Anders is de migratie geen library-wissel meer maar een datamigratie. - De login-URL's in een constante, niet in de config van de provider. Zodat die
/asszich nergens kan verstoppen. - Autorisatie nooit binnen de provider. Wie er binnen mag is van de provider; wat hij mag doen is van mijn code.
Kort geleden ben ik een nieuw project begonnen en weer met een managed provider gestart. De eerste dag kon je niet inloggen: de basis-URL van auth wees naar een placeholderwaarde uit de boilerplate. De cyclus begint opnieuw, en deze keer weet ik precies waar het pijn gaat doen.
