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 area | public |
| Feature group | auth |
| Статус | approved |
| Документ | docs/06-services/identity/api/investor/refresh-token.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | Anonymous (access-JWT может быть уже истёкшим — аутентификация самим refresh-токеном) |
| Auth policy / Permissions | none (валидность refresh-токена = аутентификация); permission-модель не применяется |
| Scope (RBAC) | не применяется |
| Record-level | сессия принадлежит владельцу токена by construction (хэш токена уникален) |
| Tenant isolation | не применяется — RefreshSession не tenanted (user-session.md); изоляция record-level по UserId |
| Audit | rotation фиксируется цепочкой ParentSessionId; replay — событие SessionRevokedEvent(ReplayDetected) |
| Trusted boundary | вход недоверенный: rate-limit 10 попыток/мин на IP; сам токен — единственный секрет, сравнение только по SHA-256-хэшу |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/v1/auth/refresh |
| Success status | 200 |
| Idempotency header | не применяется — повтор с тем же (уже ротированным) токеном намеренно детектируется как replay (INV-S6) |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
RefreshToken | body (web — httpOnly secure cookie pin:refresh, mobile — body) | string | Да | base64url, 43 симв. (256 бит CSPRNG) | секрет: не логируется никогда; в хранилище — только SHA-256 |
IpAddress | middleware | string? | Нет | <= 45 (IPv6) | PII — маскируется в логах |
UserAgent | header User-Agent | string? | Нет | <= 512 | — |
Platform | header X-Pin-Platform | string? | Нет | web / ios / android | — |
Модель ответа RefreshTokenCommandResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
AccessToken | JwtTokenModel | Да | новый JWT | не логируется | Модель {token, notBefore, type, expirationDate} (user-session.md); TTL 15 мин; claims: sub = UserId, tenant_id = CompanyId (если есть), sid = новой сессии. |
RefreshToken | string | Да | CSPRNG 256 бит | секрет — отдаётся один раз | Новый rotating-токен (web — устанавливается cookie, в body не дублируется). |
RefreshExpiresAt | DateTimeOffset | Да | newSession.ExpiresAt | — | Абсолютный TTL (sliding 30 дней). |
SessionId | Guid | Да | newSession.Id | — | Id новой сессии (= claim sid). |
Алгоритм
- Контекст и доступ. Актор анонимный (claims не требуются); из middleware известны device-слепок
(
IpAddress,UserAgent,Platform, вычисленныйDisplayName). Rate-limit по IP ≤ 10/мин — иначеIDENTITY_CONFIRMATION_RATE_LIMITED(429). - Проверка входа и состояния.
RefreshTokenнепустой, base64url — иначеValidationError;- найти сессию по SHA-256-хэшу токена — не найдена →
IDENTITY_SESSION_NOT_FOUND(401); - replay (INV-S6) — если сессия уже
Rotated, перейти к ветке отзыва цепочки (шаг 4b); - состояние — сессия
Activeи не истекла;Revoked/Expired→IDENTITY_SESSION_EXPIRED(401); - владелец — получить пользователя сессии; не
Active→IDENTITY_USER_BLOCKED(401) + отзыв сессии; - блокировка входа (INV-8 user.md) —
LockoutUntil > now→IDENTITY_USER_LOCKED_OUT(401); - компания (INV-9 user.md) — при заданном
CompanyIdкомпания должна бытьActive, иначеIDENTITY_COMPANY_SUSPENDED(401).
- Что читаем. Сессия (по хэшу токена — она аутентифицирует запрос), пользователь, при необходимости компания — в этом порядке.
- Правила и переходы.
- 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/Rotated→Revoked(RevokedReason = ReplayDetected); на каждую фиксируется доменное событиеSessionRevokedEvent; результат —IDENTITY_SESSION_REPLAY_DETECTED(401).
- 4a. Штатная ротация. Текущая сессия:
- Что меняется. 4a: в одном согласованном изменении прежняя сессия обновляется и создаётся новая
(связь по
ParentSessionId); гонка параллельного refresh одного токена разрешается оптимистической блокировкой — проигравший при повторе увидитRotatedи попадёт в ветку 4b. 4b: обновляется вся цепочка сессий. - События. 4a: событие входа не публикуется (ротация — не новый вход; user-session.md: событие только
на успешный вход). 4b: доменные
SessionRevokedEvent×N — при коммите; опционально алерт заботе черезnotifications(подписка на доменную ленту — вне этого файла). - Результат. 4a: новая пара токенов и
SessionId→200; 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, slidingExpiresAt = now + 30 дней); выпущен новый access-JWT (TTL 15 мин); клиенту отдана новая пара иSessionId. - Пост (4b replay): вся цепочка по
ParentSessionId→Revoked(ReplayDetected); на каждую ушёлSessionRevokedEvent; новых токенов нет — клиент вынужден на re-login.
Acceptance Criteria
- Given действующий refresh-токен
Active-сессии владельцаActive, WhenPOST /refresh, Then200с новой паройAccessToken+RefreshTokenиSessionId; прежняя сессия становитсяRotated, дочерняяActiveссылается на неё черезParentSessionId; web получаетSet-Cookieс новым refresh; событие входа не публикуется. - Given уже ротированный (
Rotated) refresh-токен предъявлен повторно (утечка), WhenPOST /refresh, ThenIDENTITY_SESSION_REPLAY_DETECTED(401), вся цепочка сессий →Revoked/ReplayDetected, на каждую фиксируетсяSessionRevokedEvent; клиент очищает сессию. - Given два параллельных refresh с одним валидным токеном, When оба доходят до записи, Then
один выигрывает оптимистическую гонку и получает новую пару, проигравший при повторе видит
Rotatedи падает в ветку replay (IDENTITY_SESSION_REPLAY_DETECTED). - Given токен неизвестен (нет по хэшу), When
POST /refresh, ThenIDENTITY_SESSION_NOT_FOUND(401) → relogin. - Given сессия
Revoked/Expiredлибо истёк TTL, WhenPOST /refresh, ThenIDENTITY_SESSION_EXPIRED(401). - Given владелец сессии
Blocked/Archived, WhenPOST /refresh, ThenIDENTITY_USER_BLOCKED(401) и сессия отзывается. - Given
LockoutUntil > now(INV-8), WhenPOST /refresh, ThenIDENTITY_USER_LOCKED_OUT(401), retry послеLockoutUntil. - Given компания владельца не
Active(INV-9), WhenPOST /refresh, ThenIDENTITY_COMPANY_SUSPENDED(401). - Given превышен лимит 10 попыток/мин с IP, When
POST /refresh, ThenIDENTITY_CONFIRMATION_RATE_LIMITED(429), retry с backoff.
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
ValidationError | 400 | Пустой/некорректный токен | «Некорректный запрос» | no retry |
IDENTITY_SESSION_NOT_FOUND | 401 | Хэш не найден | «Сессия недействительна» | no retry → relogin |
IDENTITY_SESSION_EXPIRED | 401 | Revoked/Expired/TTL истёк | «Сессия истекла» | no retry → relogin |
IDENTITY_SESSION_REPLAY_DETECTED | 401 | Повторное использование Rotated-токена (INV-S6) | «Сессия отозвана» | no retry → relogin (вся цепочка мертва) |
IDENTITY_USER_BLOCKED | 401 | Пользователь Blocked/Archived | «Доступ запрещён» | no retry |
IDENTITY_USER_LOCKED_OUT | 401 | LockoutUntil > now (INV-8) | «Временная блокировка» | retry после LockoutUntil |
IDENTITY_COMPANY_SUSPENDED | 401 | Компания не Active (INV-9) | «Компания заблокирована» | no retry |
IDENTITY_CONFIRMATION_RATE_LIMITED | 429 | > 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; dashboardidentity-overview; runbook: массовый отзыв сессий (README identity).
Обратные ссылки
Автоссылки: где используется этот документ.
- API (8): GET /api/investors/me/referral-links/registrations — Обезличенный журнал по всем моим ссылкам (getAllRegistrations), GET /api/v1/companies/my — Просмотр своей компании, GET /api/v1/partner/stats — Партнёрская сводка по каналам, GET /api/v1/referral-links/{referralLinkId}/stats — Статистика одной реферальной ссылки, Identity API — карта областей и реестр investor-области, POST /api/v1/auth/sign-out — Выход (отзыв refresh-сессии), POST /api/v1/me/active-agency — Выбор активного агентства, POST /api/v1/referral-links — Создание реферальной ссылки (с каналом)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущность: domain/user-session.md (
RefreshSession, INV-S6/S7, stateDiagram). - Пользовательские guards: domain/user.md (INV-8/9).
- Выход: sign-out.md.