fix(auth): refresh-token rotation немедленно инвалидирует старый токен

Два бага в refresh-flow, из-за которых утёкший refresh-token давал доступ
после ротации (auth-edge step03):

1. AuthorizationController прокидывал в новый principal только
   AuthorizationId, но не TokenId. Handler RedeemTokenEntry читает TokenId
   из подписываемого principal, чтобы пометить погашаемый refresh как
   Redeemed — без него старый токен оставался valid.
2. Даже после починки редемпшна OpenIddict по умолчанию даёт 30-секундный
   reuse-leeway: погашенный refresh ещё принимается в этом окне. Обнуляем
   окно (SetRefreshTokenReuseLeeway(TimeSpan.Zero)) — ротация инвалидирует
   старый refresh сразу.

auth-edge: 10/10 (было 9/10).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-26 11:03:29 +05:00
parent 17a454cce5
commit 32729e72a3
2 changed files with 21 additions and 0 deletions

View file

@ -71,6 +71,20 @@ public async Task<IActionResult> Exchange()
if (rejection is not null) return rejection;
var principal = await CreatePrincipal(user, request.GetScopes());
// Прокидываем внутренние OpenIddict-claims погашаемого refresh-token
// в новый principal. Ключевой — TokenId: handler RedeemTokenEntry
// читает его из подписываемого principal, чтобы понять, какой именно
// refresh-token пометить Redeemed. Без него ротация не гасит старый
// токен — он остаётся valid и допускает повторное использование
// (одна утечка refresh = вечный доступ). AuthorizationId связывает
// новый токен с той же авторизацией, чтобы не плодить дубли.
var tokenId = info.Principal?.GetTokenId();
if (!string.IsNullOrEmpty(tokenId))
principal.SetTokenId(tokenId);
var authzId = info.Principal?.GetAuthorizationId();
if (!string.IsNullOrEmpty(authzId))
principal.SetAuthorizationId(authzId);
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}

View file

@ -101,6 +101,13 @@
builder.Configuration["OpenIddict:AccessTokenLifetime"] ?? "01:00:00"));
opts.SetRefreshTokenLifetime(TimeSpan.Parse(
builder.Configuration["OpenIddict:RefreshTokenLifetime"] ?? "30.00:00:00"));
// Rolling refresh включён по умолчанию: каждый refresh гасит старый
// токен (Redeemed) и выдаёт новый. Но по умолчанию OpenIddict даёт
// 30-секундный reuse-leeway — окно, в котором погашенный refresh ещё
// принимается (для гонок/ретраев). Для розничной админки это дыра:
// утёкший refresh остаётся рабочим 30с после ротации. Обнуляем окно —
// ротация инвалидирует старый refresh немедленно.
opts.SetRefreshTokenReuseLeeway(TimeSpan.Zero);
// Явный Issuer = публичный URL админки. Без него OpenIddict
// вычисляет issuer из текущего HTTP-запроса, что ломается за
// nginx-прокси если Host/X-Forwarded-Proto не доходят. Берём