02Architektura5 min

Cztery razy migrowałem dostawcę uwierzytelniania

Czterech zarządzanych dostawców w półtora roku i to samo miejsce docelowe za każdym razem: własny middleware. Co pęka przy każdym przejściu i na co patrzę teraz, zanim napiszę linijkę kodu auth.

Autor: Salva Castells

Zdjęcie okładkowe: Shixart1985, CC BY 2.0.
W tym wpisie

W boilerplacie, którego używam do zaczynania projektów, commit integrujący pierwszego dostawcę auth i ten, który zastępuje go drugim, dzielą dwa dni. Dwa dni. Pierwszy wstawiał middleware dla chronionych tras i ekran logowania; drugi wyrywał to w całości i wstawiał inny SDK z jego własnymi stronami sign-in i sign-up. Od tamtej pory ten sam szablon przeszedł przez dwóch kolejnych dostawców i skończył, prawie rok później, z middlewarem napisanym ręcznie.

To nie pech. To wzorzec i powtórzyłem go w co najmniej czterech repozytoriach.

Zawsze zaczynam od zarządzanego dostawcy i zawsze z tego samego powodu

Pierwszego dnia projektu nie chcę pisać hashowania haseł, weryfikacji maila ani flow OAuth z Google. Instaluję SDK, ustawiam dwie zmienne środowiskowe i w jedno popołudnie jest logowanie. Tego dnia to właściwa decyzja: projekt jeszcze nie wie, czy będzie istniał za miesiąc.

Problem w tym, że ten SDK nie siedzi grzecznie na brzegu aplikacji. Wchodzi w layout, w kontekst użytkownika, w każdą server action, która musi wiedzieć, kto woła, i w schemat bazy danych, bo user_id z jego tabeli kończy jako klucz obcy we wszystkich twoich.

Cztery migracje i co wywołało każdą z nich

  • Dostawca pierwszy na drugiego, w dwa dni. Pierwszy działał, ale model cenowy i sprzężenie z jego własnym panelem nie pasowały. Żadna linia kodu auth nie przetrwała zmiany.
  • Dostawca drugi, sześć miesięcy współżycia. Tu było wszystko: notatki w README o jego dziwnym zachowaniu z Suspense, refaktory middleware'u, żeby czytać chronione domeny z pliku konfiguracyjnego, i zmiana useProfile na useUser, żeby przestać utrzymywać własną warstwę na jego wierzchu.
  • Dostawca drugi na trzeciego. Cały PR nazwany "migrate from" plus nazwa poprzedniego. Trzydzieści dotkniętych plików. Znikają stack.ts i trasa catch-all app/handler/[...stack]/page.tsx, a pojawiają się auth.ts po stronie klienta i app/api/auth/[...path]/route.ts po stronie serwera.
  • Dostawca trzeci na własny middleware. Commit mówi dosłownie "replace neonAuth with custom auth middleware and update session handling". Z zarządzanego SDK zostaje mi klient; resztę kontroluję sam.

Co pęka zawsze: URL-e

Przy drugiej migracji są dwa commity tego samego dnia, jeden za drugim. Pierwszy zostawia plik konfiguracyjny w takim stanie:

export const stackServerApp = new StackServerApp({
  tokenStore: "nextjs-cookie",
  urls: {
    signIn: "/ass",
    signUp: "/asdds",
  },
});

Tak to wygląda, kiedy sprawdzasz ręcznie, dokąd co przekierowuje, i zostawiasz testowe klepnięcie w klawiaturę. Drugi commit przywraca /auth w obu miejscach. To śmieszne i to dokładnie ten błąd, który zjesz przy każdej migracji auth: dostawca nie wie, gdzie mieszka twój ekran logowania, więc w jego konfiguracji jest zadeklarowany URL, który musi zgadzać się z prawdziwą trasą routera, a nie ma typów, które by to sprawdziły. Przekierowuje na 404, a objawem nie jest "zły URL", tylko "logowanie nie działa".

Druga połowa tego, co pęka, to kształt sesji. Przy przejściu na własny middleware getSession przestał zwracać obiekt SDK i zaczął zwracać coś jawnego:

type SessionData = NonNullable<Awaited<ReturnType<typeof auth.getSession>>["data"]>;
export type Session = SessionData | { session: null; user: null };

To | { session: null; user: null } to ustępstwo, które trzeba zrobić. Poprzedni SDK zwracał goły nullable i połowa kodu robiła session?.user. Po znormalizowaniu wszystkie miejsca zakładające, że sesja może być undefined, przestały się kompilować. To jest droga część migracji, a nie logowanie.

Co zyskuję, a co tracę, pisząc to sam

Zysk jest konkretny: kontrola nad obiektem sesji, nad tym, gdzie trzymana jest cookie, i nad tym, co dokładnie dzieje się, kiedy sesji nie ma. Oraz rzecz, której nie doceniałem, dopóki jej nie potrzebowałem: możliwość wstawienia reguł autoryzacji w tym samym miejscu.

W narzędziu BI dla dużej firmy autoryzacja skończyła jako lista adresów mailowych w samym middlewarze:

const authorizedEmails = [ /* ... */ ];
const hasFullAccess = userEmail && authorizedEmails.includes(userEmail);

Z dwoma różnymi przekierowaniami: niezalogowany idzie do logowania, zalogowany bez uprawnień idzie na stronę główną. Przy zarządzanym dostawcy robi się to, walcząc z jego claimami albo panelem. Tam napisałem to w dziesięciu liniach. Zostawiłem też console.log("User email:", userEmail), które trzeba było usunąć w późniejszym commicie, a ta aplikacja przepisywała auth trzy razy w miesiąc.

Tracę wszystko to, co dostawca robił, a czego nie widziałeś: rotację tokenów, unieważnianie sesji, mail odzyskiwania hasła, rate limit logowania. Nic z tego nie jest niemożliwe, ale staje się moim kodem, bez testów, których sam nie napisałem.

Moje kryterium dzisiaj

To nie jest "napisz własne auth". Jest nudniejsze: załóż od pierwszego dnia, że zmienisz dostawcę. Co robię dziś, zanim cokolwiek zainstaluję:

  • Jedna funkcja getSession() w moim kodzie. Żaden komponent nie importuje SDK. Typ zwracany definiuję ja, nie wnioskuję go od dostawcy. To różnica między migrowaniem trzydziestu plików a migrowaniem jednego.
  • user_id w mojej bazie jest mój. Ten od dostawcy idzie do osobnej kolumny. Inaczej migracja przestaje być zmianą biblioteki i staje się migracją danych.
  • URL-e logowania w stałej, nie w konfiguracji dostawcy. Żeby /ass nie miało się gdzie schować.
  • Autoryzacja nigdy wewnątrz dostawcy. Kto może wejść, należy do dostawcy; co może zrobić, należy do mojego kodu.

Niedawno zacząłem nowy projekt i znów wystartowałem od zarządzanego dostawcy. Pierwszego dnia nie dało się wejść: bazowy URL auth wskazywał na wypełniacz z boilerplate'u. Cykl zaczyna się od nowa, tylko tym razem wiem dokładnie, gdzie zaboli.