fix(auth): non-rotating, sliding refresh tokens to stop the OTP storm
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m53s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 1m37s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 1m8s
CI/CD / Deploy · all services (push) Successful in 1m40s

Login already issues a 7-day access token + 30-day refresh token, and the
dashboard persists the session and silently refreshes on 401 — so a session
should last well over a week. The real cause of "re-login every time / massive
OTP" was single-use refresh-token rotation: RefreshAsync revoked the presented
token and minted a new one, so when a café runs POS + KDS + queue display at
once (or two tabs), the first refresh won the race and every other concurrent
refresh hit the now-revoked token -> INVALID_TOKEN -> forced logout -> OTP.

Make refresh idempotent and race-safe:
- IssueTokensAsync takes an optional existingRefreshToken; on refresh we reuse
  the presented token and re-store it (sliding the 30-day TTL) instead of
  minting a new one. Login still mints a fresh token.
- RefreshAsync no longer revokes the presented token.

Net effect: concurrent refreshes all succeed; an active session slides forward
and effectively never forces re-auth. Access stays 7 days, refresh 30 days.
All 81 API tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-02 15:09:25 +03:30
parent a37d93f6cd
commit 60e2ac1355
+14 -4
View File
@@ -253,7 +253,9 @@ public class AuthService : IAuthService
if (employee?.Cafe is null) if (employee?.Cafe is null)
return (false, null, "NOT_FOUND", "User no longer exists."); return (false, null, "NOT_FOUND", "User no longer exists.");
await _refreshTokenStore.RevokeAsync(request.RefreshToken, cancellationToken); // Note: we intentionally do NOT revoke the presented refresh token here.
// It is reused (with a slid TTL) so concurrent refreshes from multiple
// tabs/devices stay valid instead of racing each other into a logout.
var allMemberships = await _db.Employees var allMemberships = await _db.Employees
.Include(e => e.Cafe) .Include(e => e.Cafe)
@@ -265,7 +267,9 @@ public class AuthService : IAuthService
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString())) .Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
.ToList(); .ToList();
var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, payload.ActiveBranchId, cancellationToken); var tokens = await IssueTokensAsync(
employee, employee.Cafe, membershipDtos, payload.ActiveBranchId, cancellationToken,
existingRefreshToken: request.RefreshToken);
return (true, tokens, null, null); return (true, tokens, null, null);
} }
@@ -510,12 +514,18 @@ public class AuthService : IAuthService
Core.Entities.Cafe cafe, Core.Entities.Cafe cafe,
List<CafeMembershipDto>? memberships, List<CafeMembershipDto>? memberships,
string? requestedBranchId, string? requestedBranchId,
CancellationToken cancellationToken) CancellationToken cancellationToken,
string? existingRefreshToken = null)
{ {
var resolution = await ResolveBranchAsync(employee, cafe, requestedBranchId, cancellationToken); var resolution = await ResolveBranchAsync(employee, cafe, requestedBranchId, cancellationToken);
var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe, resolution.EffectiveRole, resolution.ActiveBranchId); var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe, resolution.EffectiveRole, resolution.ActiveBranchId);
var refreshToken = _jwtTokenService.CreateRefreshToken(); // On refresh, reuse the caller's refresh token (and slide its TTL below) instead
// of minting a new one. A café often runs POS + KDS + queue display at once; if
// refresh rotated the token, the first refresh would revoke it and every other
// concurrent refresh would get INVALID_TOKEN → forced logout → OTP storm.
// Mint a fresh token only on a real login (existingRefreshToken == null).
var refreshToken = existingRefreshToken ?? _jwtTokenService.CreateRefreshToken();
var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30); var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30);
await _refreshTokenStore.StoreAsync( await _refreshTokenStore.StoreAsync(