From 60e2ac1355b124434993ffbbcee397fc324bfe4e Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Tue, 2 Jun 2026 15:09:25 +0330 Subject: [PATCH] fix(auth): non-rotating, sliding refresh tokens to stop the OTP storm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/Meezi.API/Services/AuthService.cs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Meezi.API/Services/AuthService.cs b/src/Meezi.API/Services/AuthService.cs index 1f00d5f..ba5b545 100644 --- a/src/Meezi.API/Services/AuthService.cs +++ b/src/Meezi.API/Services/AuthService.cs @@ -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? 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(