В обработчике входа часто видны четыре независимые строчки: выдать cookie, поставить Secure, обновить ID и сделать logout. Проблема начинается, когда каждая строчка живёт по своей логике. Старый идентификатор продолжает проходить после rotation, cookie очищается без серверного revoke или срок в браузере принимают за срок доступа. Цена ошибки конкретна: пользователь считает сессию закрытой, а сервер ещё принимает её; либо новый вход ломается поздним ответом со старым состоянием.
Ниже — один контракт учебной сессии: браузер хранит непрозрачный ID, сервер хранит статус, и оба слоя меняются одним решением. После входа запись active; rotation выпускает successor и выводит predecessor из обращения; logout revoke-ит current ID и просит клиент удалить cookie. Это не browser trace и не запуск приложения, а in-memory fixture с явно заданными состояниями и идентификаторами.
Сначала разделите носитель и источник истины
Cookie не является сессией. Это способ вернуть серверу строку в заголовке Cookie, если браузер решил, что её scope подходит запросу. RFC 6265 описывает именно такую передачу и допускает, что user agent удалит cookie раньше срока. Поэтому Max-Age полезен как клиентский предел хранения, но не должен быть единственной проверкой timeout. В контракте active, rotated и revoked принадлежат серверной записи; cookie только выбирает запись, которую сервер ещё обязан проверить.
Это разделение меняет и формулировку logout. Удалить cookie недостаточно: другой клиент, сохранённый запрос или уже отправленный заголовок может всё ещё предъявить идентификатор. Сервер должен решить, принимает ли он его. В учебной модели после logout запись получает статус revoked и исчезает из currentByLineage. Отдельный Set-Cookie с Expires в прошлом просит браузер убрать прежнее значение. Оба действия нужны, но они доказывают разное: первое закрывает доступ в модели, второе убирает удобный носитель на клиенте.
| Объект | Минимальное поле | Кто проверяет | Что происходит при logout | Чего этого недостаточно |
|---|---|---|---|---|
| Cookie | __Host-session=ID; Path=/; Secure; HttpOnly; SameSite=Lax | user agent решает, приложить ли его к запросу | получает Set-Cookie с Expires в прошлом той же формы | доказать, что сервер уже revoke-ил ID |
| Session record | id, lineage, generation, status | сервер перед обработкой защищённого действия | current ID становится revoked | принять решение за другой сервис без общей политики |
| Lineage pointer | lineage → active ID | операция rotation/logout | ссылка на active ID удаляется | описать logout всех устройств |
| Set-Cookie при rotation | successor ID и та же политика flags | сервер формирует ответ | не выдаётся при stale reference | синхронизировать вкладки или порядок сетевых ответов |
Минимальный fixture: проверить переходы, не притворяясь браузером
Скрипт этой партии экспортирует createTeachingSessionState, rotateTeachingSession и logoutTeachingSession. Первый вызов создаёт fixture-s-1 с состоянием active. Rotation принимает только текущий ID для линии fixture-lineage-a, помечает его rotated и создаёт fixture-s-2. Поздняя операция со старым ID возвращает stale-session и получает тот же объект state. Это намеренно узкая проверка: нет HTTP, хранилища браузера, случайных чисел, конкурентных запросов, clock skew, пользователя или настоящего сервера.
import {
createTeachingSessionState,
rotateTeachingSession,
logoutTeachingSession,
} from './upgrade-2023-02.mjs';
const initial = createTeachingSessionState();
const rotated = rotateTeachingSession(initial, 'fixture-s-1');
const staleLogout = logoutTeachingSession(rotated.state, 'fixture-s-1');
const currentLogout = logoutTeachingSession(rotated.state, 'fixture-s-2');
console.log(rotated.accepted); // true
console.log(staleLogout.reason); // stale-session
console.log(currentLogout.clearCookie);
// __Host-session=; Path=/; Secure; HttpOnly; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT
Здесь stale logout для fixture-s-1 не revoke-ит fixture-s-2: это политика logout только предъявленной current-сессии. Другой продукт может выбрать logout всей lineage или всех устройств, но это должна быть отдельная операция с собственным scope и проверкой. Иначе поздний запрос молча закроет другую активную сессию.
Rotation — замена права, а не продление строки
Rotation начинается с проверки текущей записи, а не с Set-Cookie. Предшественник остаётся rotated для диагностики, но уже не current; successor получает новый ID, generation и ту же lineage. Так сервер различает unknown и уже устаревший ID. В production это изменение требует реальной гарантии хранилища; синхронный fixture не называет себя atomic.
Для двух одновременных запросов заранее определите один исход: один создаёт successor, другой получает stale/rejected. Без этого появятся два successor. Fixture не моделирует гонку, но фиксирует инвариант для реализации: после rotation один current ID, а повторный старый ID не создаёт fixture-s-3.
Cookie flags описывают область доставки, не авторизацию
Для учебного ID выбран __Host-session. В draft-ietf-httpbis-rfc6265bis-11 префикс __Host- требует Secure, Path=/ и отсутствия Domain, то есть фиксирует host-only область. Он не обещает пользователя, права, logout или серверный срок. В феврале 2023 это был Internet-Draft ноября 2022, не финальный RFC. __Host- не годится, если cookie обязана жить на нескольких поддоменах.
Secure ограничивает канал доставки, HttpOnly — доступ из browser API, SameSite=Lax — часть cross-site доставок. Ни один не заменяет серверную проверку ID, прав и запроса. Path задаёт маршрут, но не является security boundary. Поэтому каждый flag записывают с его узкой причиной, а не как список безопасности.
Logout: две синхронные обязанности
Logout имеет серверную и клиентскую ветви. Сервер revoke-ит только current active ID и снимает pointer; клиент получает то же имя и Path=/ с пустым значением и Expires в прошлом. RFC 6265 связывает замену/удаление с name, Domain и Path: если issue содержал Domain, а clear нет, браузер может оставить другую cookie. Учебный контракт исключает Domain с самого начала.
Удаление cookie не доказывает, что logout дошёл до сервера: ответ мог не примениться, а другой запрос уже нести ID. Критерий доступа — server reject после revoke. Очищение остаётся нужно для UX. CSRF-защита logout, если она есть, — отдельный контракт; SameSite не отменяет его.
Маршрут: симптом → причина → проверка → действие
- Симптом. В выписке change найдите одно из трёх наблюдений: старый ID принимается после renewal, logout меняет только интерфейс или cookie живёт дольше серверной записи. Не называйте это инцидентом, пока нет проверяемых данных.
- Причина. Для одного ID нарисуйте record, lineage pointer, Set-Cookie при issue/rotation и Set-Cookie при clear. Если один из объектов не имеет владельца, контракт уже разорван.
- Проверка fixture. Запустите
node web/scripts/upgrade-2023-02.mjs --verify-fixture. Он проверяет только 15 предзаданных assertions учебной модели, включая stale rotation и stale logout; он не проверяет браузер, endpoint или конфигурацию. - Проверка реализации. Отдельно выберите разрешённую среду и проверьте, что сервер отклоняет rotated/revoked ID, а clear cookie повторяет name, Domain при наличии и Path исходной cookie. Не переносите в отчёт реальные session ID.
- Действие. Сделайте server-side status обязательным до защищённой операции; rotation обновляет current pointer, logout revoke-ит current pointer. Добавьте явное отдельное действие, если бизнесу нужен logout всех сессий.
- Откат. Не используйте fixture как план отката. Для production заранее опишите совместимость старых ID, миграцию хранилища, ожидаемый reject и способ вернуть change без повторной легитимации stale идентификатора.
Границы и следующий проверяемый шаг
Модель не генерирует секреты: fixture-s-1 и fixture-s-2 нельзя копировать как production ID. В ней нет шифрования, CSRF, пользователя, reauthentication, browser Network/Storage и реальной гонки. NIST SP 800-63B помогает отделить session secret, минимальный cookie scope и server timeout; это контекст документа NIST, а не готовая политика продукта.
Следующий шаг — зафиксировать для команды status, lineage, reject rotated/revoked ID и отдельный план logout всех устройств. Затем провести разрешённую интеграционную проверку вне fixture. Если нельзя назвать current ID после rotation, cookie-конфигурации ещё рано доверять.
Историческая граница февраля 2023
К февралю 2023 уже существовали RFC 6265 от апреля 2011, NIST SP 800-63B от июня 2017 с обновлениями до 2 марта 2020 и draft-ietf-httpbis-rfc6265bis-11 от 7 ноября 2022. Последний в статье назван work in progress, а не стандартом. Источники объясняют свойства cookie и сессий, но не подтверждают состояние конкретного браузера, балансировщика, базы данных или auth-платформы.
Проверяемые источники
- IETF RFC 6265: HTTP State Management Mechanism, April 2011 — стандарт IETF, опубликованный в 2011 году. Он задаёт передачу Cookie/Set-Cookie, scope, Secure и HttpOnly; он не задаёт прикладной срок валидности серверной сессии.
- IETF draft-ietf-httpbis-rfc6265bis-11: Cookies: HTTP State Management Mechanism, November 2022 — датированный Internet-Draft, опубликованный 7 ноября 2022 года и доступный к февралю 2023. Это work in progress, а не финальный RFC; здесь он нужен для SameSite и префикса __Host-.
- NIST SP 800-63B: Digital Identity Guidelines — Authentication and Lifecycle Management, June 2017 with updates through 2 March 2020 — официальный датированный документ NIST, доступный к февралю 2023. Его требования относятся к заданному NIST контексту; он помогает отделить cookie от серверного timeout, logout и reauthentication.