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:
parent
17a454cce5
commit
32729e72a3
|
|
@ -71,6 +71,20 @@ public async Task<IActionResult> Exchange()
|
||||||
if (rejection is not null) return rejection;
|
if (rejection is not null) return rejection;
|
||||||
|
|
||||||
var principal = await CreatePrincipal(user, request.GetScopes());
|
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);
|
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,13 @@
|
||||||
builder.Configuration["OpenIddict:AccessTokenLifetime"] ?? "01:00:00"));
|
builder.Configuration["OpenIddict:AccessTokenLifetime"] ?? "01:00:00"));
|
||||||
opts.SetRefreshTokenLifetime(TimeSpan.Parse(
|
opts.SetRefreshTokenLifetime(TimeSpan.Parse(
|
||||||
builder.Configuration["OpenIddict:RefreshTokenLifetime"] ?? "30.00:00:00"));
|
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 = публичный URL админки. Без него OpenIddict
|
||||||
// вычисляет issuer из текущего HTTP-запроса, что ломается за
|
// вычисляет issuer из текущего HTTP-запроса, что ломается за
|
||||||
// nginx-прокси если Host/X-Forwarded-Proto не доходят. Берём
|
// nginx-прокси если Host/X-Forwarded-Proto не доходят. Берём
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue