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
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:
@@ -253,7 +253,9 @@ public class AuthService : IAuthService
|
||||
if (employee?.Cafe is null)
|
||||
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
|
||||
.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()))
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -510,12 +514,18 @@ public class AuthService : IAuthService
|
||||
Core.Entities.Cafe cafe,
|
||||
List<CafeMembershipDto>? memberships,
|
||||
string? requestedBranchId,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
string? existingRefreshToken = null)
|
||||
{
|
||||
var resolution = await ResolveBranchAsync(employee, cafe, requestedBranchId, cancellationToken);
|
||||
|
||||
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);
|
||||
|
||||
await _refreshTokenStore.StoreAsync(
|
||||
|
||||
Reference in New Issue
Block a user