Мне нужно было, чтобы несколько независимых сайтов работали как единая система аккаунтов, не ослабляя обычную изоляцию в браузере. Пользователь мог войти на одном домене, позже напрямую открыть другой домен, нажать Войти и продолжить работу без повторного ввода учётных данных.
Ключевое ограничение заключалось в том, что это независимые корневые домены. Сессионный cookie, созданный одним корневым доменом, нельзя просто распространить на другой, не связанный с ним корневой домен. Архитектура стала гораздо понятнее, когда я разделил два понятия, которые часто смешивают: глобальную идентичность и локальную браузерную сессию.
Итоговая модель проста: бэкенд распознаёт одну общую идентичность аккаунта, каждый домен владеет собственной сессией, привязанной к хосту, а короткоживущая одноразовая передача позволяет аутентифицированному доверенному origin авторизовать создание новой сессии на другом доверенном origin.
Глобальная идентичность не требует общего cookie
Аккаунт может быть глобальным, даже если браузерные сессии локальны.
Каждый сайт хранит сессионный cookie, доступный только своему хосту. Cookie, выпущенный одним корневым доменом, нельзя распространить на не связанный с ним корневой домен через атрибут Domain. Этот атрибут может расширить область действия cookie только внутри той доменной иерархии, которую разрешено использовать хосту, выпустившему cookie.
Для сессии, которая должна оставаться привязанной к одному хосту, хорошо подходит cookie с префиксом __Host-. Концептуально:
Set-Cookie: __Host-<session-cookie>=<opaque-token>; Path=/; Secure; HttpOnly; SameSite=LaxГлавное здесь — границы:
- Сайт A не может прочитать сессионный cookie сайта B, доступный только хосту B.
- Сайт B не может прочитать сессионный cookie сайта A, доступный только хосту A.
- При этом обе сессии всё равно могут соответствовать одной и той же идентичности в бэкенде.
Поэтому междоменный SSO не должен пытаться переносить cookie с одного сайта на другой. Нужно передавать временное доказательство того, что доверенный источник уже аутентифицировал пользователя.
Схема одноразовой передачи
Аутентифицированный источник предоставляет небольшую операцию передачи. Браузер вызывает её только тогда, когда требуется переход аутентификации между сайтами.
Бэкенд генерирует криптографически случайный токен, один раз возвращает браузеру его исходное значение и сохраняет только хеш вместе с минимальным набором данных, необходимых для проверки передачи:
{
tokenHash,
userId,
sourceOrigin,
targetSite,
returnPath,
expiresAt
}Токен должен быть короткоживущим и действовать только для одной цели. Запрос на его выпуск со стороны источника уже должен быть аутентифицирован и защищён от CSRF. Запрошенную цель и адрес возврата нужно проверить до того, как что-либо будет выпущено.
Затем браузер выполняет POST верхнего уровня к целевому сайту. Целевой сайт потребляет токен, определяет общую идентичность пользователя, создаёт собственную локальную сессию и перенаправляет браузер по нужному пути.
Концептуально:
record = consumeOnce({
tokenHash: hash(ticket),
targetSite: currentSite,
sourceOrigin: requestOrigin,
notExpired: true
})
if (!record) deny()
user = loadSharedUser(record.userId)
createLocalSession(user)
redirect(record.returnPath)Токен — не целевая сессия. Это одноразовая авторизация на создание такой сессии.
Атомарное потребление закрывает окно повторного использования
В более слабой реализации токен сначала загружается и проверяется, затем создаётся сессия, и только после этого токен удаляется. Это создаёт окно, в котором два запроса могут увидеть одну и ту же всё ещё действующую запись.
Безопаснее объединить проверку и аннулирование в одну атомарную операцию потребления. Если подходящая запись не найдена, аутентификация завершается неудачей. Если запись возвращена, токен уже удалён из набора доступных для использования до создания сессии.
Это также позволяет связать с одной операцией сразу несколько условий: ожидаемую цель, ожидаемый origin источника, хеш токена и срок действия.
Проверка origin должна завершаться отказом по умолчанию
Привязка к origin даёт ещё одно полезное свойство: токен, выпущенный через один доверенный источник, не должен приниматься с постороннего origin.
Проверка origin должна входить непосредственно в условие потребления. Тогда запрос с неправильного origin не расходует действующий токен, а легитимный источник всё ещё сможет завершить передачу позже.
Есть важное ограничение: заголовок Origin не обязан содержать обычную строку origin в каждом браузерном контексте. Некоторые запросы могут приходить без пригодного значения, а непрозрачные контексты могут давать Origin: null.
Моё правило — считать отсутствующий или непрозрачный Origin проваленной проверкой origin, если у приложения нет явно определённого альтернативного механизма проверки. Литеральное значение null не должно становиться доверенным origin только ради совместимости.
Это политика отказа по умолчанию. Если корректному сценарию нужно поддерживать контекст без пригодного origin, такое исключение следует спроектировать явно, а не незаметно ослаблять общую проверку.
Не помещайте учётные данные передачи в URL
Я предпочитаю отправлять временный токен через форму POST верхнего уровня, а не помещать его в строку запроса.
Обобщённый клиентский вариант выглядит так:
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();Целевой сайт принимает токен из ожидаемого тела POST-запроса и отклоняет эквивалентные попытки передать его через URL.
Это не делает учётные данные безвредными, но не даёт им попадать в обычные навигационные URL и снижает риск случайного раскрытия через историю адресов, скопированные ссылки, рефереры и журналы, ориентированные на URL.
Аутентифицированные межсайтовые ссылки могут автоматически использовать SSO
Если текущий сайт уже знает, что пользователь аутентифицирован, обычная ссылка на другой доверенный сайт может при подходящем случае выполнить передачу.
При этом ссылка всё равно должна содержать настоящий href. SSO дополняет обычную навигацию, а не заменяет её.
Упрощённый клиентский сценарий выглядит так:
if (session.status !== 'authenticated') {
return; // ordinary browser navigation
}
if (!isTrustedTarget(destination)) {
return;
}
event.preventDefault();
const ticket = await requestHandoff({
targetSite,
returnPath: destinationPath
});
postTicketToTarget(ticket);В продакшен-версии также нужна защита от повторной отправки, проверка цели по реестру доверенных сайтов и возврат к обычной навигации, если передачу создать не удалось.
Нативное поведение браузера должно оставаться нативным. Клик средней кнопкой, клики с клавишами-модификаторами, скачивание файлов и ссылки, предназначенные для другого контекста просмотра, не должны незаметно превращаться в запросы аутентификации.
Кнопке «Войти» на втором сайте нужен известный эмитент
Более сложный случай начинается, когда пользователь напрямую открывает сайт B. Локальной сессии на сайте B нет, поэтому он корректно считает посетителя анонимным, даже если в браузере всё ещё существует действующая сессия на сайте A.
Сайт B не может проверить cookie сайта A, доступный только хосту A. Он также не может перенаправить пользователя на произвольный сайт C и ожидать, что тот обнаружит сессии остальных частей системы. Каждый независимый корневой домен видит только принадлежащие ему cookie.
Поэтому анонимному сценарию Войти нужен известный эмитент: либо канонический origin аутентификации, либо специально выбранный доверенный сайт, на котором предполагается наличие повторно используемой сессии входа.
Для простого случая сайт A → сайт B последовательность такая:
- Пользователь входит на сайте A.
- Позже пользователь напрямую открывает сайт B.
- На сайте B нет сессии, поэтому он показывает Войти.
- Пользователь нажимает Войти.
- Браузер выполняет навигацию верхнего уровня на сайт A, выбранный в качестве эмитента.
- Сайт A может прочитать собственный существующий сессионный cookie, доступный только этому хосту.
- Поскольку сессия всё ещё действительна, сайт A сразу выпускает одноразовую передачу для сайта B.
- Браузер отправляет токен методом POST на сайт B.
- Сайт B создаёт собственную локальную сессию и возвращает пользователя на запрошенную страницу.
С точки зрения пользователя это быстрое перенаправление на сайт A и обратно. Пароль не нужен, потому что у сайта A уже есть сессия, которую он имеет право видеть.
Не менее важно ограничение: если пользователь вошёл только на сайте C, перенаправление с сайта B на сайт A не поможет, если сайт A не является настоящим центральным брокером аутентификации с собственным независимым знанием о состоянии входа. Общая база пользователей не делает браузерные сессии общими.
Почему канонический эмитент упрощает систему
По мере роста числа независимых сайтов канонический эмитент устраняет необходимость искать сессии по произвольным доменам.
Без него анонимному сайту B пришлось бы каким-то образом решать, на каком другом сайте сейчас может находиться полезный cookie. Сайт A не может проверить cookie сайта C, сайт C не может проверить cookie сайта A, а сайт B не может проверить ни один из них.
Канонический эмитент даёт каждой анонимной попытке входа один предсказуемый маршрут:
Site B
→ authentication issuer
→ issuer session exists?
yes → create one-time handoff
no → show normal login
→ Site B consumes handoff
→ Site B creates local sessionЭмитенту не нужно обслуживать остальную часть приложения. Его важная задача — владеть повторно используемой сессией аутентификации и выпускать для доверенных сайтов передачи с узкой областью действия.
В небольшой системе эту роль может выполнять уже существующий сайт. В более широкой архитектуре отдельный origin аутентификации может сделать модель доверия понятнее.
Не прогоняйте каждого посетителя через эмитент
Сценарий с эмитентом не должен автоматически запускаться при каждом анонимном просмотре страницы.
Перенаправление каждого посетителя на другой домен только ради проверки существующей сессии создаёт ненужную навигацию, дополнительную задержку, больше вариантов отказа, более шумную аналитику и более сложное поведение кеширования.
Это также плохо подходит для публичных страниц, чувствительных к SEO. Поисковый робот или обычный посетитель, запросивший публичный URL, должен получить эту страницу напрямую, а не сначала проходить через лишнее перенаправление для аутентификации.
Я использую такое правило:
Не проверяйте эмитент при загрузке страницы. Запускайте междоменную аутентификацию только после явного действия Войти или намеренной аутентифицированной навигации между сайтами.
Так публичный трафик остаётся простым, а аутентификация — быстрой для пользователя, у которого уже есть действующая сессия на выбранном эмитенте.
Проверка пути возврата требует большего, чем проверка префикса
Передаче обычно нужно запомнить, куда браузер должен попасть после неё. Если воспринимать это значение как произвольный URL под контролем клиента, оно может превратиться в открытое перенаправление.
По возможности безопаснее вообще не принимать полный URL назначения. Короткий идентификатор или серверное сопоставление известных мест назначения уменьшает объём необходимого парсинга и проверки URL.
Если относительный путь всё же приходится принимать, его нужно тщательно проверять и ограничивать целевым сайтом.
Намеренно упрощённый пример — не полноценная процедура проверки перенаправления — выглядит так:
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;
}В реальной реализации также нужно учитывать поведение декодирования, некорректный ввод, управляющие символы, нормализацию, правила маршрутизации приложения и любые преобразования, которые выполняют прокси или фреймворк до формирования перенаправления.
Инвариант безопасности проще деталей парсера: передача может выбрать разрешённое место внутри целевого приложения, но никогда — произвольный внешний адрес.
Ошибочные сценарии важнее успешного
Успешный вход доказывает только то, что базовый сценарий работает. Гораздо полезнее тесты, которые проверяют границы доверия.
Я проверил поведение, в частности, для таких случаев:
- неаутентифицированный источник пытается выпустить передачу;
- отсутствует защита от CSRF;
- origin отсутствует, непрозрачен или неожидан;
- цель не является доверенной;
- небезопасное место возврата;
- токен предъявлен неправильной цели;
- попытка потребления с неправильного origin;
- повторное использование токена;
- истечение срока действия токена;
- пользователь больше не существует;
- токен передан через URL вместо ожидаемого тела POST-запроса.
Особенно полезно напрямую проверять два инварианта: отклонённый запрос не должен случайно уничтожать токен, который всё ещё действителен для легитимного сценария, а успешное потребление должно немедленно делать этот токен непригодным для повторного использования.
Протокол масштабируется без попарных интеграций
Схема передачи принципиально не меняется при добавлении новых сайтов.
Каждому участвующему сайту нужен один и тот же небольшой контракт:
- стабильный логический идентификатор;
- доверенный публичный origin;
- доступ к общей идентичности аккаунта;
- операция выпуска передачи для аутентифицированных источников;
- операция потребления передачи;
- реестр доверенных сайтов;
- локальная сессия, привязанная к хосту;
- безопасная политика адреса возврата;
- и известный эмитент для анонимных сценариев Войти.
Ключевое решение для масштабирования — отказаться от отдельной логики аутентификации для каждой пары сайтов. Уже аутентифицированный сайт может выпустить такой же тип передачи для другого доверенного сайта. Анонимный сайт может отправить браузер к известному эмитенту.
В результате система опирается на один протокол, а не на постоянно растущую матрицу особых случаев.
Переносимость входа и распространение выхода — разные задачи
Независимые сессии, привязанные к хостам, дают ещё одно полезное различие: возможность переносить вход между сайтами не определяет автоматически глобальный выход.
Если пользователь выйдет с сайта A, локальная сессия другого сайта может остаться действительной, если бэкенд не отзовёт её намеренно. Это политика управления сессиями, а не ошибка механизма передачи SSO.
Система может выбрать локальный выход, глобальный выход или явное управление сессиями. Если держать это решение отдельно от передачи, оба механизма проще анализировать.
Модель, которую я использую
Архитектура стала проще, когда я перестал описывать задачу как «совместное использование cookie для входа между доменами».
Существует одна глобальная идентичность, каждый домен владеет собственной браузерной сессией, а короткоживущая одноразовая передача позволяет аутентифицированному доверенному origin авторизовать создание ещё одной локальной сессии.
При прямом входе на анонимный сайт важно ещё одно правило: браузер нужно отправить к эмитенту, который действительно может видеть существующую сессию пользователя. Если пользователь ранее вошёл у этого эмитента, переход туда и обратно может быть почти незаметным. Если у эмитента нет действующей сессии, он должен перейти к обычной аутентификации, а не делать вид, будто видит сессию другого, не связанного с ним домена.
Эта модель сохраняет доменные границы браузера, избавляет публичных посетителей от лишних перенаправлений аутентификации и при этом даёт нужный результат: пользователь входит у эмитента, позже открывает другой доверенный сайт, нажимает Войти и возвращается с новой локальной сессией без повторного ввода пароля.