Перейти к содержанию

Серверная сессия и cookie браузера

Приложение получило claims из завершённого OIDC-обмена, но пока не сохраняет вход между страницами. Добавим локальную серверную сессию. Браузер будет хранить лишь случайный непрозрачный id, а профиль и токены останутся в памяти Node. Новый результат — корневая страница узнаёт вошедшего fixture-пользователя в пределах собственного срока приложения. SSO-сессия Keycloak и локальная сессия не объединяются: у каждой свой владелец, время и способ завершения.

В lesson-06 callback после finish удаляет прежнюю локальную запись, создаёт новый id и помещает результат в sessions. Вместе с ним сохраняются csrf, until и tokenUntil. Cookie полноценного пользователя отличается от pending cookie попытки входа. Даже если браузер прислал старый id до обмена, приложение не продолжает его после подтверждения личности. Такой переход помогает избежать фиксации сессии, когда заранее известный идентификатор неожиданно становится идентификатором вошедшего человека.

Почему id должен меняться

Идентификатор генерируется из 32 случайных байтов через node:crypto. Он не строится из username, email или последовательного номера. Браузер не должен угадывать чужую запись по предсказуемому имени. При этом случайность не защищает украденный действующий id: cookie остаётся bearer-секретом локальной сессии. Поэтому важны HttpOnly, правильная область, HTTPS и срок. Нельзя использовать длинный случайный id как оправдание хранения его в публичной ссылке, JavaScript-логе или аналитическом событии.

Удаление прежней записи выполняется перед сохранением нового результата. Если человек переходит с Бориса на Анну, старые claims не должны остаться привязанными к тому же id. Наличие свежего nonce в OIDC не заменяет обновление локального идентификатора. Это две разные связи: протокол подтверждает конкретный обмен, а приложение определяет доступ по своей записи. Полезно проследить обе, чтобы не считать «библиотека входа всё сделала» основанием для небрежной работы с cookies.

Pending cookie очищается в том же ответе, который устанавливает новую cookie сессии. Ответ содержит несколько Set-Cookie как массив, а не одну склеенную строку с запятыми. У таких заголовков особые правила, поэтому случайное объединение может изменить обработку браузером. Полноценная cookie получает срок 900 секунд; серверная запись тоже имеет until. Браузерный Max-Age улучшает очистку клиента, но окончательное решение принимает сервер: вручную присланный истёкший id не должен работать только потому, что cookie ещё есть у клиента.

Проверка сессии на запросе

Функция current разбирает cookies, очищает просроченные sessions и attempts, затем ищет запись по нужному имени. Не найденный или истёкший id означает отсутствие локального входа. У нас нет автоматического нового обращения к Keycloak на каждую страницу. Поэтому удаление Map-записи мгновенно действует на это приложение, а сохранённая SSO-сессия провайдера может при следующем login подтвердить человека без повторного пароля. Такое поведение объяснимо, если не путать два разных состояния.

Cookie имеет Path=/ и не задаёт общий Domain. Имя включает порт: propusk_5100. Важно помнить, что браузерные cookies не изолируются по порту как origin JavaScript. Второе приложение по localhost:5101 позже получит другое имя. Это учебный способ избежать случайного чтения одинакового названия двумя серверами одного localhost. На настоящих разных доменах или поддоменах область и имя должны быть согласованы отдельно; правило «у них разные порты» не является достаточной гарантией разделения cookie.

Срок личности и срок токена

Локальная сессия живёт пятнадцать минут. Access token в realm имеет более короткий срок. Это не ошибка: приложение может хранить своё подтверждённое состояние личности после истечения токена, если такой срок предусмотрен политикой. Но действия, требующие текущего токена или актуальных полномочий, должны понимать эту разницу. Пока мы показываем имя и ещё не обновляем токены. В одиннадцатом уроке появится renew, а роль позже будет явно связана со сроком принятого набора claims.

Для каждого result сохраняется tokenUntil, вычисленный по expires_in. Это серверный срок пригодности полученного токена в выбранной модели. Мы не просим браузер сообщить время истечения и не используем его локальные часы для разрешения операции. При реальном окружении нужно учитывать clock skew и специфику провайдера. Учебный код сохраняет прямой относительный срок и обновит его при refresh. Отдельный until локальной сессии не продлевается бесконечно от каждого запроса: это фиксированный максимум текущего входа.

Map находится в памяти одного процесса. Рестарт удаляет все записи, даже если браузер хранит id ещё пятнадцать минут. Тогда current не найдёт запись и посетителю понадобится новый login. Два независимых процесса также не делят Map. Если балансировщик направит следующий запрос на другой экземпляр, локальный вход пропадёт. Такие ограничения не исправляются увеличением срока cookie. Для нескольких серверов нужно общее доверенное хранилище с TTL и согласованной схемой, а также порядок работы с refresh и отзывом.

Отображение имени

Корневая страница показывает preferred_username, а при его отсутствии sub. Оба значения экранируются. Пользовательский профиль не является безопасным HTML только потому, что пришёл от провайдера. Имя может включать специальные символы или изменяться администратором. Приложение должно показать его как текст. Сам факт наличия session позволяет вывести «Вы вошли», но ещё не разрешает редакторскую операцию. Авторизация по ролям появится следующим шагом и будет выполняться сервером, а не только наличием ссылки в меню.

В ответах задан Cache-Control no-store. Страница с именем текущего пользователя не должна случайно становиться общей кэшированной HTML-страницей другого посетителя. Этот заголовок помогает браузерному и промежуточному поведению, но не заменяет правильную конфигурацию CDN или общего кэша. Если позже появятся публичные и персональные routes, нужно различать их политики. Нельзя просто включить общий кэш всего origin ради скорости и ожидать, что случайные cookies автоматически предотвратят смешение страниц.

Самостоятельный разбор перехода

Полный sessions.mjs ниже показывает структуру хранения, генерацию и очистку. При самостоятельном входе ожидается новая cookie propusk_5100, удалённая pending cookie и имя на корневой странице. Эти действия не выполнялись. После рестарта Node прежняя cookie не должна восстановить исчезнувшую запись. Повторный login может пройти быстро благодаря Keycloak SSO, но это будет новая локальная сессия с новым id. Сопоставьте эти два эффекта, чтобы различать состояние провайдера и приложения.

Сейчас сервер хранит tokens, хотя браузер их не видит. Права доступа к памяти процесса, диагностическим дампам и журналам становятся частью доверенной среды. Не выводите sessions целиком ради удобной отладки. Для наблюдения достаточно типа события, срока и безопасного request id. Следующий урок добавит realm-role mapper и серверную проверку editor. Тогда даже успешно вошедший Борис будет получать отказ на редакторском маршруте, потому что подтверждение личности и разрешение действия останутся отдельными решениями.

Полный файл sessions.mjs из состояния lesson-06:

import { randomBytes, timingSafeEqual } from 'node:crypto';

export const sessions = new Map();
export const attempts = new Map();
export const random = () => randomBytes(32).toString('base64url');
export function cookies(req) {
  return Object.fromEntries((req.headers.cookie || '').split(';').map(x => x.trim())
    .filter(x => x.includes('=')).map(x => { const at = x.indexOf('='); return [x.slice(0, at), x.slice(at + 1)]; }));
}
export function cookie(name, value, seconds = 900) {
  return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${seconds}`;
}
export function current(req, name) {
  const now = Date.now();
  for (const [id, value] of sessions) if (value.until <= now) sessions.delete(id);
  for (const [id, value] of attempts) if (value.until <= now) attempts.delete(id);
  const id = cookies(req)[name];
  return { id, value: sessions.get(id) };
}
export function equal(left, right) {
  if (typeof left !== 'string' || typeof right !== 'string') return false;
  const a = Buffer.from(left), b = Buffer.from(right);
  return a.length === b.length && timingSafeEqual(a, b);
}

Оглавление серии · Исходники всех шагов · Учебные маршруты

Пример остаётся локальным черновиком. При подготовке он не выполнялся; все описанные эффекты являются ожидаемыми, а не результатами запущенных проверок.