Potrzebowałem, aby kilka niezależnych serwisów internetowych działało jak jeden system kont, bez osłabiania standardowej izolacji przeglądarki. Użytkownik mógł zalogować się w jednej domenie, później bezpośrednio otworzyć inną domenę, kliknąć Zaloguj się i kontynuować bez ponownego podawania danych logowania.
Kluczowym ograniczeniem było to, że były to niezależne domeny główne. Cookie sesyjne utworzone przez jedną domenę główną nie może po prostu zostać rozszerzone na niepowiązaną domenę główną. Architektura stała się znacznie łatwiejsza do zrozumienia, gdy rozdzieliłem dwa pojęcia, które często się miesza: globalną tożsamość i lokalną sesję przeglądarki.
Powstały model jest prosty: backend rozpoznaje jedną wspólną tożsamość konta, każda domena ma własną sesję powiązaną z hostem, a krótkotrwałe jednorazowe przekazanie pozwala uwierzytelnionemu, zaufanemu originowi autoryzować utworzenie nowej sesji w innym zaufanym originie.
Globalna tożsamość nie wymaga współdzielonego cookie
Konto może być globalne, nawet jeśli sesje przeglądarki są lokalne.
Każdy serwis przechowuje cookie sesyjne ograniczone do hosta. Cookie wystawione przez jedną domenę główną nie może zostać rozszerzone na niepowiązaną domenę główną za pomocą atrybutu Domain. Ten atrybut może rozszerzyć zakres cookie wyłącznie w hierarchii domen, z której host wystawiający cookie ma prawo korzystać.
W przypadku sesji, która ma pozostać przypisana do jednego hosta, dobrze sprawdza się cookie z prefiksem __Host-. Koncepcyjnie:
Set-Cookie: __Host-<session-cookie>=<opaque-token>; Path=/; Secure; HttpOnly; SameSite=LaxNajważniejsze są tutaj granice:
- Serwis A nie może odczytać cookie sesyjnego serwisu B ograniczonego do hosta.
- Serwis B nie może odczytać cookie sesyjnego serwisu A ograniczonego do hosta.
- Obie sesje mogą mimo to wskazywać na tę samą tożsamość w backendzie.
SSO między domenami nie powinno więc próbować przenosić cookie z jednego serwisu do drugiego. Zamiast tego powinno przekazywać tymczasowy dowód, że zaufane źródło już uwierzytelniło użytkownika.
Wzorzec jednorazowego przekazania
Uwierzytelnione źródło udostępnia niewielką operację przekazania. Przeglądarka wywołuje ją tylko wtedy, gdy potrzebne jest przejście uwierzytelnienia między serwisami.
Backend generuje kryptograficznie losowy token, jeden raz zwraca jego surową wartość przeglądarce, a przechowuje wyłącznie hash wraz z minimalnym zestawem informacji potrzebnych do zweryfikowania przekazania:
{
tokenHash,
userId,
sourceOrigin,
targetSite,
returnPath,
expiresAt
}Token powinien być krótkotrwały i ważny tylko dla jednego celu. Żądanie wystawienia po stronie źródła powinno już być uwierzytelnione i chronione przed CSRF. Żądany cel i miejsce powrotu należy zweryfikować przed wystawieniem czegokolwiek.
Następnie przeglądarka wykonuje POST najwyższego poziomu do celu. Serwis docelowy konsumuje token, ustala wspólną tożsamość użytkownika, tworzy własną lokalną sesję i przekierowuje przeglądarkę do zamierzonej ścieżki.
Koncepcyjnie:
record = consumeOnce({
tokenHash: hash(ticket),
targetSite: currentSite,
sourceOrigin: requestOrigin,
notExpired: true
})
if (!record) deny()
user = loadSharedUser(record.userId)
createLocalSession(user)
redirect(record.returnPath)Token nie jest sesją docelową. Jest jednorazową autoryzacją do utworzenia tej sesji.
Atomowe zużycie zamyka okno powtórzenia
Słabsza implementacja najpierw wczytałaby token, zweryfikowała go, utworzyła sesję, a dopiero potem usunęła token. Powstaje wtedy okno, w którym dwa żądania mogą zobaczyć ten sam, wciąż ważny rekord.
Bezpieczniejsza konstrukcja łączy weryfikację i unieważnienie w jednej atomowej operacji konsumpcji. Jeśli żaden rekord nie pasuje, uwierzytelnienie kończy się niepowodzeniem. Jeśli rekord zostanie zwrócony, token został już usunięty ze zbioru możliwego do użycia, zanim utworzono sesję.
Pozwala to również powiązać w tej samej operacji kilka warunków: oczekiwany cel, oczekiwany origin źródła, hash tokenu i termin ważności.
Walidacja originu powinna domyślnie kończyć się odrzuceniem
Powiązanie z originem daje jeszcze jedną użyteczną właściwość: token wystawiony przez jedno zaufane źródło nie powinien dać się wykorzystać z niepowiązanego originu.
Sprawdzenie originu powinno być częścią samego warunku konsumpcji. Dzięki temu żądanie z niewłaściwego originu nie zużywa ważnego tokenu, a prawidłowe źródło może później dokończyć przekazanie.
Jest tu jedno ważne zastrzeżenie: nagłówek Origin nie ma gwarancji zawierania zwykłego ciągu originu w każdym kontekście przeglądarki. Niektóre żądania mogą przyjść bez użytecznej wartości, a nieprzezroczyste konteksty mogą wygenerować Origin: null.
Moja zasada jest taka, aby brakujący lub nieprzezroczysty Origin traktować jako nieudane sprawdzenie originu, chyba że aplikacja ma jawnie zdefiniowany alternatywny mechanizm walidacji. Literalna wartość null nie powinna stawać się zaufanym originem tylko ze względu na kompatybilność.
To podejście z domyślnym odrzuceniem. Jeśli prawidłowy przepływ musi obsługiwać kontekst bez użytecznego originu, taki wyjątek powinien zostać zaprojektowany jawnie, zamiast po cichu osłabiać ogólne sprawdzenie.
Nie umieszczaj poświadczenia przekazania w URL
Wolę wysyłać tymczasowy token w formularzu POST najwyższego poziomu, zamiast umieszczać go w ciągu zapytania.
Uogólniona wersja po stronie klienta wygląda tak:
const form = document.createElement('form');
form.method = 'POST';
form.action = targetOrigin + '/auth/consume';
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'ticket';
input.value = ticket;
form.append(input);
document.body.append(form);
form.submit();Serwis docelowy przyjmuje token z oczekiwanej treści żądania POST i odrzuca równoważne próby przekazania go przez URL.
Nie czyni to poświadczenia nieszkodliwym, ale usuwa je ze zwykłych adresów nawigacyjnych i ogranicza ryzyko przypadkowego ujawnienia przez historię URL, skopiowane linki, referrery oraz logowanie zorientowane na URL.
Uwierzytelnione linki między serwisami mogą automatycznie korzystać z SSO
Jeśli bieżący serwis już wie, że użytkownik jest uwierzytelniony, zwykły link do innego zaufanego serwisu może oportunistycznie wykonać przekazanie.
Link nadal powinien zawierać prawdziwy href. SSO ma ulepszać normalną nawigację, a nie ją zastępować.
Uproszczony przepływ po stronie klienta wygląda tak:
if (session.status !== 'authenticated') {
return; // ordinary browser navigation
}
if (!isTrustedTarget(destination)) {
return;
}
event.preventDefault();
const ticket = await requestHandoff({
targetSite,
returnPath: destinationPath
});
postTicketToTarget(ticket);Wersja produkcyjna powinna dodatkowo chronić przed wielokrotnym wysłaniem, weryfikować cel na podstawie rejestru zaufanych serwisów i wracać do zwykłej nawigacji, jeśli nie da się utworzyć przekazania.
Natywne zachowanie przeglądarki powinno pozostać natywne. Kliknięcie środkowym przyciskiem, kliknięcia z klawiszami modyfikującymi, pobieranie plików oraz linki przeznaczone dla innego kontekstu przeglądania nie powinny być po cichu zamieniane w żądania uwierzytelnienia.
Przycisk „Zaloguj się” w drugim serwisie potrzebuje znanego wystawcy
Trudniejszy przypadek zaczyna się, gdy użytkownik otwiera bezpośrednio serwis B. Serwis B nie ma lokalnej sesji, więc prawidłowo traktuje odwiedzającego jako anonimowego, nawet jeśli przeglądarka nadal ma ważną sesję w serwisie A.
Serwis B nie może sprawdzić cookie serwisu A ograniczonego do hosta. Nie może też przekierować użytkownika do dowolnego serwisu C i oczekiwać, że ten serwis wykryje sesje należące do pozostałej części systemu. Każda niepowiązana domena główna widzi tylko należące do niej cookie.
Oznacza to, że anonimowy przepływ Zaloguj się potrzebuje znanego wystawcy: albo kanonicznego originu uwierzytelnienia, albo konkretnie wybranego zaufanego serwisu, który zgodnie z założeniem przechowuje sesję logowania możliwą do ponownego użycia.
Dla prostego przypadku serwis A → serwis B sekwencja wygląda tak:
- Użytkownik loguje się w serwisie A.
- Później użytkownik otwiera bezpośrednio serwis B.
- Serwis B nie ma sesji, więc pokazuje Zaloguj się.
- Użytkownik klika Zaloguj się.
- Przeglądarka wykonuje nawigację najwyższego poziomu do serwisu A, który jest wybranym wystawcą.
- Serwis A może odczytać własne istniejące cookie sesyjne ograniczone do hosta.
- Ponieważ ta sesja nadal jest ważna, serwis A natychmiast wystawia jednorazowe przekazanie dla serwisu B.
- Przeglądarka wysyła token metodą POST do serwisu B.
- Serwis B tworzy własną lokalną sesję i odsyła użytkownika na żądaną stronę.
Z perspektywy użytkownika jest to szybkie przekierowanie do serwisu A i z powrotem. Hasło nie jest potrzebne, ponieważ serwis A ma już sesję, którą wolno mu obserwować.
Ograniczenie jest równie ważne: jeśli użytkownik jest zalogowany wyłącznie w serwisie C, przekierowanie serwisu B do serwisu A nie pomoże, chyba że serwis A jest prawdziwym centralnym brokerem uwierzytelnienia z własną, niezależną wiedzą o stanie logowania. Wspólna baza użytkowników nie sprawia, że sesje przeglądarki stają się wspólne.
Dlaczego kanoniczny wystawca upraszcza system
Gdy liczba niezależnych serwisów rośnie, kanoniczny wystawca eliminuje potrzebę wyszukiwania sesji w dowolnych domenach.
Bez niego anonimowy serwis B musiałby w jakiś sposób zdecydować, który inny serwis może obecnie mieć przydatne cookie. Serwis A nie może sprawdzić cookie serwisu C, serwis C nie może sprawdzić cookie serwisu A, a serwis B nie może sprawdzić żadnego z nich.
Kanoniczny wystawca zapewnia każdej anonimowej próbie logowania jedną przewidywalną ścieżkę:
Site B
→ authentication issuer
→ issuer session exists?
yes → create one-time handoff
no → show normal login
→ Site B consumes handoff
→ Site B creates local sessionWystawca nie musi obsługiwać pozostałej części aplikacji. Jego istotnym zadaniem jest posiadanie sesji uwierzytelnienia możliwej do ponownego użycia oraz wystawianie ściśle ograniczonych przekazań do zaufanych serwisów.
W mniejszym systemie tę rolę może pełnić istniejący serwis. W szerszej architekturze wydzielony origin uwierzytelnienia może ułatwić zrozumienie modelu zaufania.
Nie przekierowuj każdego odwiedzającego przez wystawcę
Przepływ przez wystawcę nie powinien uruchamiać się automatycznie przy każdym anonimowym wyświetleniu strony.
Przekierowywanie każdego odwiedzającego do innej domeny tylko po to, aby sprawdzić istniejącą sesję, powoduje niepotrzebną nawigację, dodatkowe opóźnienia, więcej trybów awarii, bardziej zaszumione dane analityczne i bardziej złożone zachowanie pamięci podręcznej.
To także słabe rozwiązanie dla publicznych stron wrażliwych na SEO. Robot indeksujący lub zwykły użytkownik żądający publicznego URL powinien otrzymać tę stronę bezpośrednio, zamiast najpierw przechodzić przez objazd związany z uwierzytelnieniem.
Moja zasada jest taka:
Nie sprawdzaj wystawcy podczas ładowania strony. Uwierzytelnienie między domenami uruchamiaj dopiero po jawnym działaniu Zaloguj się albo po celowej, uwierzytelnionej nawigacji między serwisami.
Dzięki temu ruch publiczny pozostaje prosty, a uwierzytelnienie jest nadal szybkie dla użytkownika, który ma już ważną sesję u wybranego wystawcy.
Walidacja ścieżki powrotu wymaga czegoś więcej niż sprawdzenia prefiksu
Przekazanie zwykle musi pamiętać, dokąd przeglądarka ma trafić później. Jeśli ta wartość zostanie potraktowana jako dowolny URL kontrolowany przez klienta, może stać się źródłem otwartego przekierowania.
Jeśli to możliwe, najbezpieczniej jest w ogóle nie przyjmować pełnego docelowego URL. Krótki identyfikator albo mapowanie znanych miejsc docelowych po stronie serwera zmniejsza zakres potrzebnego parsowania i walidacji URL.
Jeśli trzeba przyjmować ścieżkę względną, należy ją dokładnie zweryfikować i ograniczyć do serwisu docelowego.
Celowo uproszczony przykład — niepełna procedura walidacji przekierowania — wygląda tak:
function isSafeReturnPath(value) {
if (!value.startsWith('/')) return false;
if (value.startsWith('//')) return false;
if (value.includes('\\\\')) return false;
const base = 'https://example.invalid';
const parsed = new URL(value, base);
return parsed.origin === base;
}Rzeczywista implementacja musi także uwzględniać sposób dekodowania, błędne dane wejściowe, znaki sterujące, normalizację, reguły routingu aplikacji oraz wszelkie transformacje wykonywane przez proxy lub framework przed utworzeniem przekierowania.
Niezmiennik bezpieczeństwa jest prostszy niż szczegóły parsera: przekazanie może wskazać dozwolone miejsce w aplikacji docelowej, ale nigdy dowolne zewnętrzne miejsce docelowe.
Przypadki błędów są ważniejsze niż ścieżka pozytywna
Udane logowanie dowodzi jedynie, że podstawowa ścieżka działa. Bardziej użyteczne testy sprawdzają granice zaufania.
Zweryfikowałem zachowanie między innymi w takich przypadkach:
- nieuwierzytelnione źródło próbuje wystawić przekazanie;
- brak ochrony CSRF;
- brakujący, nieprzezroczysty lub nieoczekiwany origin;
- niezaufany cel;
- niebezpieczne miejsce powrotu;
- token przedstawiony niewłaściwemu celowi;
- próba konsumpcji z niewłaściwego originu;
- ponowne użycie tokenu;
- wygaśnięcie tokenu;
- użytkownik już nie istnieje;
- token przekazany przez URL zamiast oczekiwanej treści żądania POST.
Szczególnie warto bezpośrednio testować dwa niezmienniki: odrzucone żądanie nie może przypadkowo zniszczyć tokenu, który nadal jest ważny dla prawidłowego przepływu, a udana konsumpcja musi natychmiast uczynić ten token bezużytecznym.
Protokół skaluje się bez integracji parami
Konstrukcja przekazania nie zmienia się zasadniczo po dodaniu kolejnych serwisów.
Każdy uczestniczący serwis potrzebuje tego samego niewielkiego kontraktu:
- stabilnego identyfikatora logicznego;
- zaufanego publicznego originu;
- dostępu do wspólnej tożsamości konta;
- operacji wystawiania przekazania dla uwierzytelnionych źródeł;
- operacji konsumpcji przekazania;
- rejestru zaufanych serwisów;
- lokalnej sesji powiązanej z hostem;
- bezpiecznej polityki miejsca powrotu;
- oraz znanego wystawcy dla anonimowych przepływów Zaloguj się.
Najważniejszą decyzją przy skalowaniu jest unikanie własnej logiki uwierzytelnienia dla każdej pary serwisów. Serwis, w którym użytkownik jest już uwierzytelniony, może wystawić taki sam rodzaj przekazania do innego zaufanego serwisu. Anonimowy serwis może wysłać przeglądarkę do znanego wystawcy.
Dzięki temu system opiera się na jednym protokole zamiast na rosnącej macierzy wyjątków.
Przenośność logowania to co innego niż propagacja wylogowania
Niezależne sesje powiązane z hostem wprowadzają jeszcze jedno użyteczne rozróżnienie: przenośność logowania nie definiuje automatycznie globalnego wylogowania.
Jeśli użytkownik wyloguje się z serwisu A, lokalna sesja innego serwisu może pozostać ważna, chyba że backend celowo ją unieważni. To kwestia polityki zarządzania sesjami, a nie błąd mechanizmu przekazania SSO.
System może wybrać wylogowanie lokalne, wylogowanie globalne albo jawne zarządzanie sesjami. Oddzielenie tej decyzji od przekazania ułatwia rozumowanie o obu mechanizmach.
Model, którego używam
Architektura stała się prostsza, gdy przestałem opisywać problem jako „współdzielenie cookie logowania między domenami”.
Istnieje jedna globalna tożsamość, każda domena ma własną sesję przeglądarki, a krótkotrwałe jednorazowe przekazanie pozwala uwierzytelnionemu, zaufanemu originowi autoryzować utworzenie kolejnej lokalnej sesji.
Przy bezpośrednim wejściu do anonimowego serwisu liczy się jeszcze jedna zasada: przeglądarka musi zostać wysłana do wystawcy, który rzeczywiście może zobaczyć istniejącą sesję użytkownika. Jeśli użytkownik wcześniej zalogował się u tego wystawcy, przejście tam i z powrotem może być niemal niewidoczne. Jeśli wystawca nie ma ważnej sesji, musi wrócić do zwykłego uwierzytelnienia, zamiast udawać, że widzi sesję innej, niepowiązanej domeny.
Ten model zachowuje granice domen w przeglądarce, unika zbędnych przekierowań uwierzytelnienia dla publicznych odwiedzających, a jednocześnie zapewnia zamierzony efekt: użytkownik loguje się u wystawcy, później otwiera inny zaufany serwis, klika Zaloguj się i wraca z nową lokalną sesją bez ponownego wpisywania hasła.