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

Обновление токенов и срок полномочий

Access token имеет короткий срок, а локальная сессия может жить дольше. Пока приложение сохраняло первоначальные claims и не обращалось к provider после входа. Добавим серверное обновление токенов перед дальнейшими действиями. Новый результат — действующая локальная запись получает свежий token response, сохраняет текущие сведения и прекращает доступ при отказе обновления. При этом fixed lifetime сессии остаётся пятнадцать минут, а refresh не обещает мгновенный отзыв всех полномочий во всех приложениях.

Состояние lesson-11 добавляет refresh.mjs. Функция renew вызывается только после поиска существующей сессии. Если tokenUntil ещё достаточно далеко, возвращаем прежнюю запись. Если срок близок, используем refresh_token через openid-client. Браузер не получает refresh token и не инициирует обмен произвольным токеном из формы. Он отправляет обычную cookie, а сервер самостоятельно решает необходимость обновления. Токены, provider и обмен при подготовке не запускались.

Срок токена отдельно от срока сессии

TokenUntil вычислен по expires_in последнего ответа. Обновление начинается за десять секунд до окончания, чтобы обычный запрос не начинал действие с почти просроченным токеном. Это небольшой учебный запас, не универсальная настройка часов и сети. Until самой Map-сессии не изменяется: даже удачный refresh не продлевает вход бесконечно. Иначе активный браузер мог бы сохранять локальное состояние без явно принятого максимума. Продуктовая политика inactivity и maximum lifetime требует отдельного выбора, который не должен случайно определяться каждой строкой middleware.

При отсутствии refresh_token функция завершает операцию ошибкой и предлагает новый вход. Не следует пытаться использовать ID Token как замену или повторно применять прежний authorization code. У этих объектов разные назначения, а code уже одноразовый. Провайдер может не выдать refresh token согласно своей политике; это нормальная возможность протокола. В нашем realm ожидается такой token response для выбранного standard flow, но реальное поведение нужно проверить в своей версии и конфигурации, а не выводить из одного названия scope.

Refresh token остаётся секретом серверного store. В памяти процесса его могут увидеть доверенные диагностические средства и оператор машины. Поэтому «не хранится в браузере» не означает отсутствия требований к хранению. Для production нужен ограниченный доступ к store, защита резервных копий и журналов, стратегия ротации и обработки повторного использования. Учебный Map исчезает при рестарте, что сокращает долговечность, но не превращает дамп памяти в безопасный публичный файл. Секрет не следует выводить при разборе ошибки.

Один refresh на сессию

Два HTTP-запроса могут одновременно увидеть близкий срок. Если оба обменяют один refresh token, ротация или политика provider способна привести к конфликту. Поэтому в записи сохраняется promise refreshing. Первый запрос начинает обмен, второй ожидает тот же promise. После завершения поле удаляется через finally. Это простой single-flight внутри одного Node-процесса. Он не координирует разные серверы, поэтому при общем внешнем store понадобится другой механизм согласования и аккуратная запись нового набора токенов.

Promise хранится в серверном объекте, который уже выбран по cookie. Посетитель не может прислать refreshing или заставить приложение ждать произвольный чужой promise. Такой технический механизм не является отдельной ролью; он лишь удерживает согласованный обмен. Если запрос отменён браузером, текущий учебный код не отменяет сетевую операцию библиотеке автоматически. Для реальной нагрузки нужно спроектировать таймаут, отмену и число параллельных обновлений. Нельзя считать одно поле refreshing полной защитой от перегрузки provider.

Личность после обновления

Полученные tokens снова дают claims. Наш realm возвращает ID Token при refresh; если его нет, приложение прекращает локальный вход. Затем сравниваем sub и issuer с прежней записью. Refresh не должен незаметно сменить владельца сессии. При корректном результате сохраняем новые claims, поэтому роль editor также пересматривается. Если fresh refresh_token отсутствует, сохраняем предыдущий согласно выбранному контракту. Когда provider выдаёт новый, он заменяет старый. Эта ветка не отменяет его ротационную политику и требует собственного разбора при выпуске.

Отсутствующий или неверный тип realm_roles позже приводит к отказу editor, как в седьмом уроке. Не сохраняем прежние широкие роли только потому, что свежий набор не содержит поле. Такой подход мог бы продлить снятые полномочия. Если mapper ошибочен, полезнее завершить действие или отказать в роли и исправить конфигурацию, чем подставить старую авторизацию. В реальной системе можно различать временный сбой и принятый отзыв, но это должно быть явным правилом, а не случайным fallback к удобному прошлому состоянию.

Ошибка и граница отзыва

При ошибке renew сервер удаляет локальную Map-запись, очищает cookie и возвращает сообщение «Войдите заново». Это строгая политика: если нельзя подтвердить продолжение, доступ не продолжается автоматически. Сбой сети может вызвать лишний новый вход, но не продлевает полномочия без принятого решения. Нельзя маскировать такой случай как пустую успешную сессию. Для пользовательского продукта возможна более подробная страница, но она должна сохранять различие недоступности provider и успешного подтверждения текущего пользователя.

Если администратор снял роль, старая локальная запись может использовать её до следующего обновления. Если provider logout завершил SSO, следующий refresh может быть отклонён. Но срок access token и локальной сессии всё равно создаёт окно сохранения состояния. Этот урок не добавляет back-channel logout и не гарантирует мгновенный отзыв. Наличие renew лишь меняет момент пересмотра. Для критичной операции нужна отдельная политика актуальности и, возможно, синхронное решение о доступе, соответствующее риску и ожиданиям продукта.

Полный refresh.mjs ниже показывает последовательность. При самостоятельном входе и ожидании короткого token lifetime обычный запрос должен вызвать refresh, сохранив пользователя в пределах fixed local lifetime. После преднамеренного завершения provider-сессии возможен новый вход по отказу. Эти сценарии не выполнялись при подготовке; проверяйте точное поведение выбранного Keycloak. Следующий урок добавит passkey у самого provider. Node продолжит принимать обычный OIDC-результат, не получая приватный ключ пользователя и не превращаясь в собственный WebAuthn-сервер.

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

import * as oidc from 'openid-client';
import { config } from './config.mjs';

export async function renew(session) {
  if (session.tokenUntil > Date.now() + 10_000) return session;
  if (!session.tokens.refresh_token) throw new Error('Login is required again');
  if (session.refreshing) return session.refreshing;
  session.refreshing = (async () => {
    const tokens = await oidc.refreshTokenGrant(config, session.tokens.refresh_token);
    const claims = tokens.claims();
    // Our realm returns an ID Token on refresh. Missing identity ends the local session.
    if (!claims || claims.sub !== session.claims.sub || claims.iss !== session.claims.iss) {
      throw new Error('Identity changed or is missing');
    }
    session.tokens = { ...tokens, refresh_token: tokens.refresh_token || session.tokens.refresh_token };
    session.claims = claims;
    session.tokenUntil = Date.now() + Math.max(0, Number(tokens.expires_in || 0)) * 1000;
    return session;
  })();
  try { return await session.refreshing; } finally { delete session.refreshing; }
}

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

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