Перейти к основному содержимому

POST /api/v1/auth/refresh — Обновление пары токенов (refresh-flow)

Ротация refresh-токена: клиент меняет действующий refresh-токен на новую пару access-JWT + refresh (rotating, INV-S6 replay-detection). Вместо разлогина по истечении access-токена клиент прозрачно обновляет сессию.

Назначение

Основа долгоживущих сессий и списка устройств. Вызывается клиентом (web/mobile), предъявляя действующий refresh-токен; на выходе — новая пара токенов и идентификатор новой сессии. Дом полей — RefreshSession (domain/user-session.md). Owning-контекст — identity.

Metadata

ПолеЗначение
Сервис/контекстidentity
API areapublic
Feature groupauth
Статусapproved
Документdocs/06-services/identity/api/investor/refresh-token.md

Актор и доступ

ПроверкаЗначение
Actor typeAnonymous (access-JWT может быть уже истёкшим — аутентификация самим refresh-токеном)
Auth policy / Permissionsnone (валидность refresh-токена = аутентификация); permission-модель не применяется
Scope (RBAC)не применяется
Record-levelсессия принадлежит владельцу токена by construction (хэш токена уникален)
Tenant isolationне применяется — RefreshSession не tenanted (user-session.md); изоляция record-level по UserId
Auditrotation фиксируется цепочкой ParentSessionId; replay — событие SessionRevokedEvent(ReplayDetected)
Trusted boundaryвход недоверенный: rate-limit 10 попыток/мин на IP; сам токен — единственный секрет, сравнение только по SHA-256-хэшу

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/auth/refresh
Success status200
Idempotency headerне применяется — повтор с тем же (уже ротированным) токеном намеренно детектируется как replay (INV-S6)
Correlationсквозной trace через traceparent

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
RefreshTokenbody (web — httpOnly secure cookie pin:refresh, mobile — body)stringДаbase64url, 43 симв. (256 бит CSPRNG)секрет: не логируется никогда; в хранилище — только SHA-256
IpAddressmiddlewarestring?Нет<= 45 (IPv6)PII — маскируется в логах
UserAgentheader User-Agentstring?Нет<= 512
Platformheader X-Pin-Platformstring?Нетweb / ios / android

Модель ответа RefreshTokenCommandResult

ПолеТипОбязательностьИсточникMaskingОписание
AccessTokenJwtTokenModelДановый JWTне логируетсяМодель {token, notBefore, type, expirationDate} (user-session.md); TTL 15 мин; claims: sub = UserId, tenant_id = CompanyId (если есть), sid = новой сессии.
RefreshTokenstringДаCSPRNG 256 битсекрет — отдаётся один разНовый rotating-токен (web — устанавливается cookie, в body не дублируется).
RefreshExpiresAtDateTimeOffsetДаnewSession.ExpiresAtАбсолютный TTL (sliding 30 дней).
SessionIdGuidДаnewSession.IdId новой сессии (= claim sid).

Алгоритм

  1. Контекст и доступ. Актор анонимный (claims не требуются); из middleware известны device-слепок (IpAddress, UserAgent, Platform, вычисленный DisplayName). Rate-limit по IP ≤ 10/мин — иначе IDENTITY_CONFIRMATION_RATE_LIMITED (429).
  2. Проверка входа и состояния.
    • RefreshToken непустой, base64url — иначе ValidationError;
    • найти сессию по SHA-256-хэшу токена — не найдена → IDENTITY_SESSION_NOT_FOUND (401);
    • replay (INV-S6) — если сессия уже Rotated, перейти к ветке отзыва цепочки (шаг 4b);
    • состояние — сессия Active и не истекла; Revoked/ExpiredIDENTITY_SESSION_EXPIRED (401);
    • владелец — получить пользователя сессии; не ActiveIDENTITY_USER_BLOCKED (401) + отзыв сессии;
    • блокировка входа (INV-8 user.md) — LockoutUntil > nowIDENTITY_USER_LOCKED_OUT (401);
    • компания (INV-9 user.md) — при заданном CompanyId компания должна быть Active, иначе IDENTITY_COMPANY_SUSPENDED (401).
  3. Что читаем. Сессия (по хэшу токена — она аутентифицирует запрос), пользователь, при необходимости компания — в этом порядке.
  4. Правила и переходы.
    • 4a. Штатная ротация. Текущая сессия: Active → Rotated, LastUsedAt = now. Создаётся новая сессия: новый идентификатор, тот же UserId, RefreshTokenHash = SHA-256(новый CSPRNG-токен), Status = Active, SignInMethod = Refresh, текущий device-слепок, IssuedAt = now, ExpiresAt = now + 30 дней (sliding), ParentSessionId = прежняя сессия. Новый access-JWT: sub = user.Id, tenant_id = user.CompanyId, sid = новая сессия, TTL 15 мин (permissions в JWT не кладутся — авторизация через check-permission).
    • 4b. Replay (INV-S6). Использование Rotated-токена = утечка: обход всей цепочки по ParentSessionId вверх и вниз, все Active/RotatedRevoked (RevokedReason = ReplayDetected); на каждую фиксируется доменное событие SessionRevokedEvent; результат — IDENTITY_SESSION_REPLAY_DETECTED (401).
  5. Что меняется. 4a: в одном согласованном изменении прежняя сессия обновляется и создаётся новая (связь по ParentSessionId); гонка параллельного refresh одного токена разрешается оптимистической блокировкой — проигравший при повторе увидит Rotated и попадёт в ветку 4b. 4b: обновляется вся цепочка сессий.
  6. События. 4a: событие входа не публикуется (ротация — не новый вход; user-session.md: событие только на успешный вход). 4b: доменные SessionRevokedEvent ×N — при коммите; опционально алерт заботе через notifications (подписка на доменную ленту — вне этого файла).
  7. Результат. 4a: новая пара токенов и SessionId200; web — Set-Cookie: pin:refresh=...; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth. 4b/ошибки: 401 с кодом — клиент выполняет очистку локальной сессии и re-login.

Диаграмма

Побочные эффекты

ТипДетали
Интеграционные событияnone в штатной ротации; replay — доменные SessionRevokedEvent (лента заботы)
Внешние вызовыnone
Проекции / поискrate-limit-счётчик по IP
Уведомленияnone напрямую; алерт «подозрительная активность» — подписчик доменной ленты
Окно согласованности и кросс-сервисный контракт

Ротация — source of truth сессий здесь (identity); витрина устройств контекста user-activity (ADR-0054) подписана на доменную ленту сессий через internal-механизм и обновляется асинхронно (лаг — секунды): сразу после 200 клиент уже работает с новой парой токенов, а в списке устройств новая/ротированная запись и признак LastUsedAt проявляются с задержкой. Штатная ротация (4a) интеграционных событий в шину не публикует (событие только на новый вход — user-session.md «События»), поэтому downstream новую активность по refresh не видит. Ветка replay (4b) фиксирует доменные SessionRevokedEvent ×N — их потребляет лента расследований заботы; отзыв всей цепочки виден в identity сразу, в витрине устройств — по мере применения ленты.

Предусловия и постусловия

  • Пред: предъявлен непустой refresh-токен; целевая сессия существует по SHA-256-хэшу; владелец Active, не в lockout, его компания (если есть) Active.
  • Пост (4a успех): прежняя сессия Active → Rotated (LastUsedAt = now); создана дочерняя Active сессия (ParentSessionId = прежняя, SignInMethod = Refresh, sliding ExpiresAt = now + 30 дней); выпущен новый access-JWT (TTL 15 мин); клиенту отдана новая пара и SessionId.
  • Пост (4b replay): вся цепочка по ParentSessionIdRevoked (ReplayDetected); на каждую ушёл SessionRevokedEvent; новых токенов нет — клиент вынужден на re-login.

Acceptance Criteria

  • Given действующий refresh-токен Active-сессии владельца Active, When POST /refresh, Then 200 с новой парой AccessToken+RefreshToken и SessionId; прежняя сессия становится Rotated, дочерняя Active ссылается на неё через ParentSessionId; web получает Set-Cookie с новым refresh; событие входа не публикуется.
  • Given уже ротированный (Rotated) refresh-токен предъявлен повторно (утечка), When POST /refresh, Then IDENTITY_SESSION_REPLAY_DETECTED (401), вся цепочка сессий → Revoked/ReplayDetected, на каждую фиксируется SessionRevokedEvent; клиент очищает сессию.
  • Given два параллельных refresh с одним валидным токеном, When оба доходят до записи, Then один выигрывает оптимистическую гонку и получает новую пару, проигравший при повторе видит Rotated и падает в ветку replay (IDENTITY_SESSION_REPLAY_DETECTED).
  • Given токен неизвестен (нет по хэшу), When POST /refresh, Then IDENTITY_SESSION_NOT_FOUND (401) → relogin.
  • Given сессия Revoked/Expired либо истёк TTL, When POST /refresh, Then IDENTITY_SESSION_EXPIRED (401).
  • Given владелец сессии Blocked/Archived, When POST /refresh, Then IDENTITY_USER_BLOCKED (401) и сессия отзывается.
  • Given LockoutUntil > now (INV-8), When POST /refresh, Then IDENTITY_USER_LOCKED_OUT (401), retry после LockoutUntil.
  • Given компания владельца не Active (INV-9), When POST /refresh, Then IDENTITY_COMPANY_SUSPENDED (401).
  • Given превышен лимит 10 попыток/мин с IP, When POST /refresh, Then IDENTITY_CONFIRMATION_RATE_LIMITED (429), retry с backoff.

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
ValidationError400Пустой/некорректный токен«Некорректный запрос»no retry
IDENTITY_SESSION_NOT_FOUND401Хэш не найден«Сессия недействительна»no retry → relogin
IDENTITY_SESSION_EXPIRED401Revoked/Expired/TTL истёк«Сессия истекла»no retry → relogin
IDENTITY_SESSION_REPLAY_DETECTED401Повторное использование Rotated-токена (INV-S6)«Сессия отозвана»no retry → relogin (вся цепочка мертва)
IDENTITY_USER_BLOCKED401Пользователь Blocked/Archived«Доступ запрещён»no retry
IDENTITY_USER_LOCKED_OUT401LockoutUntil > now (INV-8)«Временная блокировка»retry после LockoutUntil
IDENTITY_COMPANY_SUSPENDED401Компания не Active (INV-9)«Компания заблокирована»no retry
IDENTITY_CONFIRMATION_RATE_LIMITED429> 10 попыток/мин с IP«Слишком много запросов»retry с backoff
Единый принцип

Все отказы аутентификации → 401 без деталей; конкретный код — в теле ответа для собственного клиента.

Совместимость и наблюдаемость

  • Ввод — поэтапный, с постепенной миграцией клиентов; при 401 на refresh клиент падает в очистку локальной сессии и re-login.
  • Logs: SessionId, UserId, исход (токен — никогда, IP — маскированный); metrics: identity_refresh_total{result}, identity_replay_detected_total (alert при всплеске); traces: OTel; dashboard identity-overview; runbook: массовый отзыв сессий (README identity).

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы