Nel boilerplate che uso per avviare i progetti, il commit che integra il primo provider di auth e quello che lo sostituisce con il secondo sono separati da due giorni. Due giorni. Il primo metteva middleware per le rotte protette e una schermata di login; il secondo lo strappava via tutto e ci metteva un altro SDK con le sue pagine di sign-in e sign-up. Da allora quello stesso template è passato per altri due provider ed è finito, quasi un anno dopo, con un middleware scritto a mano.
Non è sfortuna. È un pattern, e l'ho ripetuto in almeno quattro repo.
Comincio sempre con un provider gestito, e sempre per lo stesso motivo
Il giorno uno di un progetto non voglio scrivere hash di password, verifica email, né il flusso OAuth con Google. Installo un SDK, metto due variabili d'ambiente, e in un pomeriggio c'è il login. È la decisione giusta quel giorno: il progetto non sa ancora se esisterà tra un mese.
Il problema è che quell'SDK non resta fermo al bordo dell'applicazione. Si infila nel layout, nel contesto utente, in ogni server action che ha bisogno di sapere chi sta chiamando, e nello schema del database perché lo user_id della loro tabella è quello che finisce per essere chiave esterna in tutte le tue.
Le quattro migrazioni e cosa ha innescato ciascuna
- Provider uno verso provider due, in due giorni. Il primo funzionava, ma il modello di prezzi e l'accoppiamento con la sua dashboard non tornavano. Nessuna riga di auth è sopravvissuta al cambio.
- Provider due, sei mesi di convivenza. Qui c'è stato di tutto: note nel README sul suo comportamento strano con Suspense, refactor del middleware per leggere i domini protetti da un file di config, e un passaggio da
useProfileauseUserper smettere di mantenere il mio strato sopra il loro. - Provider due verso provider tre. Un'intera PR chiamata "migrate from" più il nome del precedente. Trenta file toccati. Spariscono
stack.tse la rotta catch-allapp/handler/[...stack]/page.tsx, e compaionoauth.tssul client eapp/api/auth/[...path]/route.tssul server. - Provider tre verso middleware mio. Il commit dice letteralmente "replace neonAuth with custom auth middleware and update session handling". Dell'SDK gestito mi tengo il client; il resto lo controllo io.
Quello che si rompe sempre: le URL
Nella seconda migrazione ci sono due commit lo stesso giorno, uno dietro l'altro. Il primo lascia il file di configurazione così:
export const stackServerApp = new StackServerApp({
tokenStore: "nextjs-cookie",
urls: {
signIn: "/ass",
signUp: "/asdds",
},
});
È quello che viene fuori quando stai provando a mano dove reindirizza ogni cosa e ti lasci la digitazione di prova dentro. Il secondo commit le rimette entrambe a /auth. È ridicolo ed è esattamente la falla che ti mangerai in ogni migrazione di auth: il provider non sa dove vive la tua schermata di login, quindi c'è una URL dichiarata nella sua config che deve coincidere con una rotta vera del router, e non ci sono tipi che lo controllino. Reindirizza a un 404 e il sintomo non è "URL sbagliata": è "il login non funziona".
L'altra metà di quello che si rompe è la forma della sessione. Nel passaggio al middleware mio, getSession è passato dal restituire l'oggetto dell'SDK al restituire qualcosa di esplicito:
type SessionData = NonNullable<Awaited<ReturnType<typeof auth.getSession>>["data"]>;
export type Session = SessionData | { session: null; user: null };
Quel | { session: null; user: null } è la concessione che bisogna fare. L'SDK precedente restituiva un nullable nudo e metà del codice faceva session?.user. Normalizzandolo, tutti i punti che davano per scontato che la sessione potesse essere undefined hanno smesso di compilare. Quella è la parte cara della migrazione, non il login.
Cosa guadagno e cosa perdo scrivendolo io
Quello che guadagno è concreto: controllo sull'oggetto di sessione, su dove si salva il cookie e su cosa succede esattamente quando non c'è sessione. E una cosa che non avevo valutato finché non mi è servita: poter mettere regole di autorizzazione nello stesso posto.
In uno strumento di BI per un'azienda grande, l'autorizzazione è finita per essere una lista di indirizzi email nel middleware stesso:
const authorizedEmails = [ /* ... */ ];
const hasFullAccess = userEmail && authorizedEmails.includes(userEmail);
Con due reindirizzamenti diversi: il non autenticato va al login, l'autenticato senza permesso va alla home. Quello con un provider gestito si fa litigando con le sue claim o con il suo pannello. Lì l'ho scritto in dieci righe. Ho anche lasciato un console.log("User email:", userEmail) che è stato tolto in un commit successivo, e in quell'app l'auth è stata riscritta tre volte in un mese.
Quello che perdo è tutto ciò che il provider faceva e non vedevi: rotazione dei token, invalidazione delle sessioni, l'email di recupero, il rate limit del login. Niente di tutto questo è impossibile, ma diventa codice mio, senza test che non abbia scritto io.
Il mio criterio adesso
Non è "scriviti la tua auth". È più noioso: dai per scontato dal giorno uno che cambierai provider. Quello che faccio oggi prima di installare qualsiasi cosa:
- Una sola funzione
getSession()nel mio codice. Nessun componente importa l'SDK. Il tipo di ritorno lo definisco io, non lo deduco dal provider. È la differenza tra migrare trenta file e migrarne uno. - Lo
user_iddel mio database è mio. Quello del provider va in una colonna a parte. Altrimenti la migrazione smette di essere un cambio di libreria e diventa una migrazione di dati. - Le URL di login in una costante, non nella config del provider. Che il
/assnon abbia dove nascondersi. - Autorizzazione mai dentro il provider. Chi può entrare è del provider; cosa può fare è del mio codice.
Poco fa ho avviato un progetto nuovo e sono ripartito da un provider gestito. Il primo giorno non si riusciva a entrare: la URL base di auth puntava a un valore di riempimento del boilerplate. Il ciclo ricomincia, e stavolta so esattamente dove farà male.
