From 85a9b614fbc18a0c20634beb9ea057dd09ced9c9 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 14 Sep 2026 16:13:58 +0200 Subject: [PATCH 01/12] feat: add LogoutUser mutation --- TournamentAPI/Users/UserErrorCodes.cs | 1 + TournamentAPI/Users/UserErrors.cs | 6 ++++ TournamentAPI/Users/UserMutations.cs | 50 +++++++++++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/TournamentAPI/Users/UserErrorCodes.cs b/TournamentAPI/Users/UserErrorCodes.cs index 3032920..a072d58 100644 --- a/TournamentAPI/Users/UserErrorCodes.cs +++ b/TournamentAPI/Users/UserErrorCodes.cs @@ -8,5 +8,6 @@ public static class UserErrorCodes public const string RefreshTokenExpired = "User.RefreshTokenExpired"; public const string RefreshTokenInvalid = "User.RefreshTokenInvalid"; public const string UnableToSetRefreshTokenCookie = "User.UnableToSetRefreshTokenCookie"; + public const string HttpContextUnavailable = "User.HttpContextUnavailable"; public const string AccountLockedOut = "User.AccountLockedOut"; } diff --git a/TournamentAPI/Users/UserErrors.cs b/TournamentAPI/Users/UserErrors.cs index 2b93af6..392f5c5 100644 --- a/TournamentAPI/Users/UserErrors.cs +++ b/TournamentAPI/Users/UserErrors.cs @@ -40,6 +40,12 @@ public static IError UnableToSetRefreshTokenCookie() => .SetCode(UserErrorCodes.UnableToSetRefreshTokenCookie) .Build(); + public static IError HttpContextUnavailable() => + ErrorBuilder.New() + .SetMessage("Unable to process the request because no HTTP context is available.") + .SetCode(UserErrorCodes.HttpContextUnavailable) + .Build(); + public static IError AccountLockedOut => ErrorBuilder.New() .SetMessage("The account is locked out due to multiple failed login attempts.") diff --git a/TournamentAPI/Users/UserMutations.cs b/TournamentAPI/Users/UserMutations.cs index c41f7d0..15a88a0 100644 --- a/TournamentAPI/Users/UserMutations.cs +++ b/TournamentAPI/Users/UserMutations.cs @@ -56,6 +56,56 @@ public static partial class UserMutations return true; } + [Authorize] + public static async Task LogoutUser( + SignInManager signInManager, + ApplicationDbContext context, + IResolverContext resolverContext, + IHttpContextAccessor httpContextAccessor, + JwtService jwtService + ) + { + await signInManager.SignOutAsync(); + if (httpContextAccessor.HttpContext == null) + { + resolverContext.ReportError(UserErrors.HttpContextUnavailable()); + return null; + } + + var rawCookieToken = httpContextAccessor.HttpContext.Request.Cookies["refreshToken"]; + var hashedCookieToken = jwtService.HashRefreshToken(rawCookieToken ?? string.Empty); + + var existingToken = await context.RefreshTokens + .FirstOrDefaultAsync(r => r.Token == hashedCookieToken); + + if (existingToken is null || !existingToken.IsActive) + { + resolverContext.ReportError(UserErrors.RefreshTokenInvalid()); + return null; + } + + existingToken.Revoked = DateTime.UtcNow; + + try + { + await context.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + await context.Entry(existingToken).ReloadAsync(); + + if (existingToken.IsActive) + { + resolverContext.ReportError(UserErrors.RefreshTokenConflict()); + return null; + } + } + + httpContextAccessor.HttpContext.Response.ClearRefreshTokenCookie(); + + return true; + } + public static async Task LoginUser( LoginUserInput input, UserManager userManager, From fc99dbe03fa7b51d972e38b91dc0d12d0963cd9e Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 14 Sep 2026 16:15:29 +0200 Subject: [PATCH 02/12] feat: add new RefreshTokenConflict and RefreshTokenReused errors --- TournamentAPI/Users/UserErrorCodes.cs | 2 ++ TournamentAPI/Users/UserErrors.cs | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/TournamentAPI/Users/UserErrorCodes.cs b/TournamentAPI/Users/UserErrorCodes.cs index a072d58..e56685d 100644 --- a/TournamentAPI/Users/UserErrorCodes.cs +++ b/TournamentAPI/Users/UserErrorCodes.cs @@ -7,6 +7,8 @@ public static class UserErrorCodes public const string RegistrationFailed = "User.RegistrationFailed"; public const string RefreshTokenExpired = "User.RefreshTokenExpired"; public const string RefreshTokenInvalid = "User.RefreshTokenInvalid"; + public const string RefreshTokenConflict = "User.RefreshTokenConflict"; + public const string RefreshTokenReused = "User.RefreshTokenReused"; public const string UnableToSetRefreshTokenCookie = "User.UnableToSetRefreshTokenCookie"; public const string HttpContextUnavailable = "User.HttpContextUnavailable"; public const string AccountLockedOut = "User.AccountLockedOut"; diff --git a/TournamentAPI/Users/UserErrors.cs b/TournamentAPI/Users/UserErrors.cs index 392f5c5..ebe27a1 100644 --- a/TournamentAPI/Users/UserErrors.cs +++ b/TournamentAPI/Users/UserErrors.cs @@ -34,6 +34,18 @@ public static IError RefreshTokenInvalid() => .SetCode(UserErrorCodes.RefreshTokenInvalid) .Build(); + public static IError RefreshTokenConflict() => + ErrorBuilder.New() + .SetMessage("The refresh token was already used to obtain a new token. Please try refreshing again.") + .SetCode(UserErrorCodes.RefreshTokenConflict) + .Build(); + + public static IError RefreshTokenReused() => + ErrorBuilder.New() + .SetMessage("This refresh token has already been used. All active sessions have been revoked as a precaution.") + .SetCode(UserErrorCodes.RefreshTokenReused) + .Build(); + public static IError UnableToSetRefreshTokenCookie() => ErrorBuilder.New() .SetMessage("Unable to set refresh token cookie.") From 3898083d1844b74d97c23ea02f52a3f24676a1bf Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 14 Sep 2026 16:16:07 +0200 Subject: [PATCH 03/12] feat: enhance RefreshToken model with new properties Replaced `ExpiryDateUtc` with `Expires` for clarity. Added `Created`, `Revoked`, and `ReplacedByToken` properties to track token lifecycle events. Introduced `IsActive` computed property to determine token validity based on revocation status and expiration time. --- TournamentAPI/Data/Models/RefreshToken.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/TournamentAPI/Data/Models/RefreshToken.cs b/TournamentAPI/Data/Models/RefreshToken.cs index b0f79af..47fc249 100644 --- a/TournamentAPI/Data/Models/RefreshToken.cs +++ b/TournamentAPI/Data/Models/RefreshToken.cs @@ -4,7 +4,11 @@ public class RefreshToken { public Guid Id { get; set; } public string Token { get; set; } = null!; - public DateTime ExpiryDateUtc { get; set; } + public DateTime Expires { get; set; } + public DateTime Created { get; set; } + public DateTime? Revoked { get; set; } + public string? ReplacedByToken { get; set; } public int UserId { get; set; } public ApplicationUser User { get; set; } = null!; + public bool IsActive => Revoked is null && DateTime.UtcNow < Expires; } From b3826adb9e78e494153e9b2bcbbb9a8668724b08 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 14 Sep 2026 16:18:46 +0200 Subject: [PATCH 04/12] refactor: update UserMutations to match with recent RefreshToken changes --- TournamentAPI/Users/UserMutations.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/TournamentAPI/Users/UserMutations.cs b/TournamentAPI/Users/UserMutations.cs index 15a88a0..e9a0757 100644 --- a/TournamentAPI/Users/UserMutations.cs +++ b/TournamentAPI/Users/UserMutations.cs @@ -141,7 +141,8 @@ JwtService jwtService Id = Guid.NewGuid(), UserId = user.Id, Token = refreshTokenResult.Hashed, - ExpiryDateUtc = DateTime.UtcNow.AddDays(7), + Created = DateTime.UtcNow, + Expires = DateTime.UtcNow.AddDays(7), }; if (httpContextAccessor.HttpContext == null) @@ -157,7 +158,7 @@ await context.RefreshTokens context.RefreshTokens.Add(refreshToken); await context.SaveChangesAsync(); - httpContextAccessor.HttpContext.Response.AppendRefreshTokenCookie(refreshTokenResult.Raw, refreshToken.ExpiryDateUtc); + httpContextAccessor.HttpContext.Response.AppendRefreshTokenCookie(refreshTokenResult.Raw, refreshToken.Expires); return accessToken; } From 25cfbd52796520b5f653746e858d8b623184c9da Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 14 Sep 2026 16:19:12 +0200 Subject: [PATCH 05/12] feat: add method to clear refresh token cookie --- TournamentAPI/Extensions/HttpResponseExtensions.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/TournamentAPI/Extensions/HttpResponseExtensions.cs b/TournamentAPI/Extensions/HttpResponseExtensions.cs index 65a3dd1..134355d 100644 --- a/TournamentAPI/Extensions/HttpResponseExtensions.cs +++ b/TournamentAPI/Extensions/HttpResponseExtensions.cs @@ -15,4 +15,16 @@ public static void AppendRefreshTokenCookie(this HttpResponse response, string t Expires = expiry }); } + + public static void ClearRefreshTokenCookie(this HttpResponse response) + { + response.Cookies.Delete( + "refreshToken", + new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax + }); + } } From f84dd34fa8f627f00a2c5b702c66e68774086d28 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 14 Sep 2026 16:20:37 +0200 Subject: [PATCH 06/12] fix: prevent silently invalidating every other active user session --- TournamentAPI/Users/UserMutations.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/TournamentAPI/Users/UserMutations.cs b/TournamentAPI/Users/UserMutations.cs index e9a0757..396720f 100644 --- a/TournamentAPI/Users/UserMutations.cs +++ b/TournamentAPI/Users/UserMutations.cs @@ -151,10 +151,6 @@ JwtService jwtService return null; } - await context.RefreshTokens - .Where(r => r.UserId == user.Id) - .ExecuteDeleteAsync(); - context.RefreshTokens.Add(refreshToken); await context.SaveChangesAsync(); From 8571c9b6196c4090c206395b982d2e86c66f2b1c Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 14 Sep 2026 16:21:38 +0200 Subject: [PATCH 07/12] fix: fetch user details only when needed --- TournamentAPI/Users/UserMutations.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/TournamentAPI/Users/UserMutations.cs b/TournamentAPI/Users/UserMutations.cs index 396720f..28368e8 100644 --- a/TournamentAPI/Users/UserMutations.cs +++ b/TournamentAPI/Users/UserMutations.cs @@ -175,8 +175,7 @@ IHttpContextAccessor httpContextAccessor var rawCookieToken = httpContextAccessor.HttpContext.Request.Cookies["refreshToken"]; var hashedCookieToken = jwtService.HashRefreshToken(rawCookieToken ?? string.Empty); - var refreshTokenEntity = await context.RefreshTokens - .Include(r => r.User) + var existingToken = await context.RefreshTokens .FirstOrDefaultAsync(r => r.Token == hashedCookieToken); if (refreshTokenEntity is null) @@ -191,7 +190,13 @@ IHttpContextAccessor httpContextAccessor return null; } - string accessToken = jwtService.CreateToken(refreshTokenEntity.User); + var user = await context.Users.FirstOrDefaultAsync(u => u.Id == existingToken.UserId); + if (user is null) + { + resolverContext.ReportError(UserErrors.UserNotFound(existingToken.UserId)); + return null; + } + var newRefreshToken = jwtService.CreateRefreshToken(); refreshTokenEntity.Token = newRefreshToken.Hashed; refreshTokenEntity.ExpiryDateUtc = DateTime.UtcNow.AddDays(7); From 9261d39ccf4c4f63ad1b133e7a1504c5cea8e36f Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 14 Sep 2026 16:23:06 +0200 Subject: [PATCH 08/12] fix: automaticaly revoke active tokens when theft detected --- TournamentAPI/Users/UserMutations.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/TournamentAPI/Users/UserMutations.cs b/TournamentAPI/Users/UserMutations.cs index 28368e8..5e971cc 100644 --- a/TournamentAPI/Users/UserMutations.cs +++ b/TournamentAPI/Users/UserMutations.cs @@ -178,14 +178,21 @@ IHttpContextAccessor httpContextAccessor var existingToken = await context.RefreshTokens .FirstOrDefaultAsync(r => r.Token == hashedCookieToken); - if (refreshTokenEntity is null) + if (existingToken is null) { resolverContext.ReportError(UserErrors.RefreshTokenInvalid()); return null; } - if (refreshTokenEntity.ExpiryDateUtc < DateTime.UtcNow) + if (!existingToken.IsActive) { + if (existingToken.Revoked is not null) + { + await RevokeAllActiveTokensAsync(context, existingToken.UserId); + resolverContext.ReportError(UserErrors.RefreshTokenReused()); + return null; + } + resolverContext.ReportError(UserErrors.RefreshTokenExpired()); return null; } From 75e9458dc2add81025dd02771f48e28c9637c4ec Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 14 Sep 2026 16:24:41 +0200 Subject: [PATCH 09/12] fix: conncurrently guard refresh token rotation --- TournamentAPI/Data/Models/RefreshToken.cs | 6 ++++ TournamentAPI/Users/UserMutations.cs | 41 ++++++++++++++++++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/TournamentAPI/Data/Models/RefreshToken.cs b/TournamentAPI/Data/Models/RefreshToken.cs index 47fc249..e1f5919 100644 --- a/TournamentAPI/Data/Models/RefreshToken.cs +++ b/TournamentAPI/Data/Models/RefreshToken.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations; + namespace TournamentAPI.Data.Models; public class RefreshToken @@ -10,5 +12,9 @@ public class RefreshToken public string? ReplacedByToken { get; set; } public int UserId { get; set; } public ApplicationUser User { get; set; } = null!; + + [Timestamp] + public byte[] Version { get; set; } = null!; + public bool IsActive => Revoked is null && DateTime.UtcNow < Expires; } diff --git a/TournamentAPI/Users/UserMutations.cs b/TournamentAPI/Users/UserMutations.cs index 5e971cc..91436b7 100644 --- a/TournamentAPI/Users/UserMutations.cs +++ b/TournamentAPI/Users/UserMutations.cs @@ -205,13 +205,46 @@ IHttpContextAccessor httpContextAccessor } var newRefreshToken = jwtService.CreateRefreshToken(); - refreshTokenEntity.Token = newRefreshToken.Hashed; - refreshTokenEntity.ExpiryDateUtc = DateTime.UtcNow.AddDays(7); + var refreshExpiresAt = DateTime.UtcNow.AddDays(7); - await context.SaveChangesAsync(); + existingToken.Revoked = DateTime.UtcNow; + existingToken.ReplacedByToken = newRefreshToken.Hashed; - httpContextAccessor.HttpContext.Response.AppendRefreshTokenCookie(newRefreshToken.Raw, refreshTokenEntity.ExpiryDateUtc); + context.RefreshTokens.Add(new RefreshToken + { + Token = newRefreshToken.Hashed, + UserId = user.Id, + Created = DateTime.UtcNow, + Expires = refreshExpiresAt + }); + + try + { + await context.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + resolverContext.ReportError(UserErrors.RefreshTokenConflict()); + return null; + } + + string accessToken = jwtService.CreateToken(user); + httpContextAccessor.HttpContext.Response.AppendRefreshTokenCookie(newRefreshToken.Raw, refreshExpiresAt); return accessToken; } + + private static async Task RevokeAllActiveTokensAsync(ApplicationDbContext context, int userId) + { + var activeTokens = await context.RefreshTokens + .Where(t => t.UserId == userId && t.Revoked == null) + .ToListAsync(); + + foreach (var token in activeTokens) + { + token.Revoked = DateTime.UtcNow; + } + + await context.SaveChangesAsync(); + } } From 5798c3e0ca778ee4d136ae909fa0b66c80f4bf8f Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 14 Sep 2026 16:27:17 +0200 Subject: [PATCH 10/12] test: add tests covering LogoutUser mutation --- .../GraphQL/Tests/Users/UserMutationTests.cs | 146 +++++++++++++++++- 1 file changed, 144 insertions(+), 2 deletions(-) diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserMutationTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserMutationTests.cs index d330076..bb7838e 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserMutationTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserMutationTests.cs @@ -448,6 +448,8 @@ public async Task RefreshToken_ReturnsExpiredError_WhenTokenIsExpired() [Fact] public async Task LoginUser_RemovesOldRefreshTokens_OnReLogin() + [Fact] + public async Task LoginUser_KeepsOtherSessions_OnReLogin() { using var client = CreateClient(); var loginVars = new { input = new { email = "alice@example.com", password = "Password123!" } }; @@ -459,8 +461,148 @@ await client.ExecuteMutationAsync( Shared.MutationExamples.Mutations.Users.LoginUser, loginVars); var alice = await DbContext.Users.FirstAsync(u => u.Email == "alice@example.com"); - var tokenCount = await DbContext.RefreshTokens.CountAsync(r => r.UserId == alice.Id); - Assert.Equal(1, tokenCount); + var activeTokenCount = await DbContext.RefreshTokens + .AsNoTracking() + .CountAsync(r => r.UserId == alice.Id && r.Revoked == null); + Assert.Equal(2, activeTokenCount); + } + + [Fact] + public async Task LogoutUser_RevokesTokenAndClearsCookie_WhenAuthenticated() + { + using var client = CreateClient(); + + var loginResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email = "alice@example.com", password = "Password123!" } }); + client.SetAuthToken(loginResponse.Data!.LoginUser!.String!); + + var rawRefreshToken = client.GetRefreshTokenCookie(); + Assert.NotNull(rawRefreshToken); + client.SetRefreshTokenCookie(rawRefreshToken); + + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LogoutUser, new { }); + + Assert.False(response.HasErrors); + Assert.True(response.Data?.LogoutUser?.Boolean); + + var alice = await DbContext.Users.FirstAsync(u => u.Email == "alice@example.com"); + var storedToken = await DbContext.RefreshTokens + .AsNoTracking() + .SingleAsync(r => r.UserId == alice.Id); + Assert.NotNull(storedToken.Revoked); + Assert.False(storedToken.IsActive); + + var cookieAfterLogout = client.GetRefreshTokenCookie(); + Assert.NotNull(cookieAfterLogout); + Assert.NotEqual(rawRefreshToken, cookieAfterLogout); + } + + [Fact] + public async Task LogoutUser_ReturnsInvalidError_WhenCookieIsMissing() + { + using var client = CreateClient(); + + var loginResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email = "alice@example.com", password = "Password123!" } }); + client.SetAuthToken(loginResponse.Data!.LoginUser!.String!); + + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LogoutUser, new { }); + + Assert.True(response.HasErrors); + var error = response.Errors!.First(); + var expectedError = UserErrors.RefreshTokenInvalid(); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + Assert.Equal(expectedError.Message, error.Message); + } + + [Fact] + public async Task LogoutUser_ReturnsInvalidError_WhenTokenAlreadyRevoked() + { + var alice = await DbContext.Users.FirstAsync(u => u.Email == "alice@example.com"); + + var rawToken = "already-revoked-token-value"; + var hashedToken = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(rawToken))).ToLowerInvariant(); + var revokedToken = new RefreshToken + { + Id = Guid.NewGuid(), + UserId = alice.Id, + Token = hashedToken, + Created = DateTime.UtcNow.AddMinutes(-10), + Expires = DateTime.UtcNow.AddDays(6), + Revoked = DateTime.UtcNow.AddMinutes(-5) + }; + DbContext.RefreshTokens.Add(revokedToken); + await DbContext.SaveChangesAsync(); + + using var client = CreateClient(); + var loginResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email = "alice@example.com", password = "Password123!" } }); + client.SetAuthToken(loginResponse.Data!.LoginUser!.String!); + client.SetRefreshTokenCookie(rawToken); + + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LogoutUser, new { }); + + Assert.True(response.HasErrors); + var error = response.Errors!.First(); + var expectedError = UserErrors.RefreshTokenInvalid(); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + Assert.Equal(expectedError.Message, error.Message); + } + + [Fact] + public async Task LogoutUser_ReturnsError_WhenNotAuthenticated() + { + using var client = CreateClient(); + + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LogoutUser, new { }); + + Assert.True(response.HasErrors); + Assert.Null(response.Data?.LogoutUser); + } + + [Fact] + public async Task LogoutUser_TreatsConcurrentRevoke_AsIdempotentSuccess() + { + using var client1 = CreateClient(); + using var client2 = CreateClient(); + + var loginResponse = await client1.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email = "alice@example.com", password = "Password123!" } }); + var authToken = loginResponse.Data!.LoginUser!.String!; + client1.SetAuthToken(authToken); + client2.SetAuthToken(authToken); + + var sharedRefreshToken = client1.GetRefreshTokenCookie(); + Assert.NotNull(sharedRefreshToken); + client1.SetRefreshTokenCookie(sharedRefreshToken); + client2.SetRefreshTokenCookie(sharedRefreshToken); + + var task1 = client1.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LogoutUser, new { }); + var task2 = client2.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LogoutUser, new { }); + + var results = await Task.WhenAll(task1, task2); + + Assert.All(results, r => + { + Assert.False(r.HasErrors); + Assert.True(r.Data?.LogoutUser?.Boolean); + }); + + var alice = await DbContext.Users.FirstAsync(u => u.Email == "alice@example.com"); + var activeTokenCount = await DbContext.RefreshTokens + .AsNoTracking() + .CountAsync(r => r.UserId == alice.Id && r.Revoked == null); + Assert.Equal(0, activeTokenCount); } [Fact] From 9a39b20df67337485ec9c12bc631966ada91bed6 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 14 Sep 2026 16:27:51 +0200 Subject: [PATCH 11/12] test: add refresh token revoke and rotation tests --- .../GraphQL/Tests/Users/UserMutationTests.cs | 91 ++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserMutationTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserMutationTests.cs index bb7838e..0d2ae02 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserMutationTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserMutationTests.cs @@ -427,7 +427,7 @@ public async Task RefreshToken_ReturnsExpiredError_WhenTokenIsExpired() Id = Guid.NewGuid(), UserId = alice.Id, Token = hashedToken, - ExpiryDateUtc = DateTime.UtcNow.AddDays(-1) + Expires = DateTime.UtcNow.AddDays(-1) }; DbContext.RefreshTokens.Add(expiredToken); await DbContext.SaveChangesAsync(); @@ -447,7 +447,94 @@ public async Task RefreshToken_ReturnsExpiredError_WhenTokenIsExpired() } [Fact] - public async Task LoginUser_RemovesOldRefreshTokens_OnReLogin() + public async Task RefreshToken_ReturnsConflictError_WhenRotationRaces() + { + using var client1 = CreateClient(); + using var client2 = CreateClient(); + + await client1.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email = "alice@example.com", password = "Password123!" } }); + + var sharedRefreshToken = client1.GetRefreshTokenCookie(); + Assert.NotNull(sharedRefreshToken); + client1.SetRefreshTokenCookie(sharedRefreshToken); + client2.SetRefreshTokenCookie(sharedRefreshToken); + + var task1 = client1.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.RefreshToken, new { }); + var task2 = client2.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.RefreshToken, new { }); + + var results = await Task.WhenAll(task1, task2); + + var successResponse = results.FirstOrDefault(r => !r.HasErrors); + var failureResponse = results.FirstOrDefault(r => r.HasErrors); + + Assert.NotNull(successResponse); + Assert.NotNull(successResponse.Data?.RefreshToken?.String); + Assert.NotNull(failureResponse); + Assert.NotNull(failureResponse.Errors); + + var error = failureResponse.Errors!.First(); + var expectedError = UserErrors.RefreshTokenConflict(); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + Assert.Equal(expectedError.Message, error.Message); + + var alice = await DbContext.Users.FirstAsync(u => u.Email == "alice@example.com"); + var activeTokenCount = await DbContext.RefreshTokens + .AsNoTracking() + .CountAsync(r => r.UserId == alice.Id && r.Revoked == null); + Assert.Equal(1, activeTokenCount); + } + + [Fact] + public async Task RefreshToken_RevokesAllActiveSessions_WhenRevokedTokenIsReused() + { + var alice = await DbContext.Users.FirstAsync(u => u.Email == "alice@example.com"); + + var reusedRawToken = "already-rotated-token-value"; + var reusedHashedToken = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(reusedRawToken))).ToLowerInvariant(); + var revokedToken = new RefreshToken + { + Id = Guid.NewGuid(), + UserId = alice.Id, + Token = reusedHashedToken, + Created = DateTime.UtcNow.AddMinutes(-10), + Expires = DateTime.UtcNow.AddDays(6), + Revoked = DateTime.UtcNow.AddMinutes(-5) + }; + + var otherActiveToken = new RefreshToken + { + Id = Guid.NewGuid(), + UserId = alice.Id, + Token = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("legit-active-session"))).ToLowerInvariant(), + Created = DateTime.UtcNow.AddMinutes(-5), + Expires = DateTime.UtcNow.AddDays(7) + }; + + DbContext.RefreshTokens.AddRange(revokedToken, otherActiveToken); + await DbContext.SaveChangesAsync(); + + using var client = CreateClient(); + client.SetRefreshTokenCookie(reusedRawToken); + + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.RefreshToken, new { }); + + Assert.True(response.HasErrors); + var error = response.Errors!.First(); + var expectedError = UserErrors.RefreshTokenReused(); + Assert.Equal(expectedError.Code, error.Extensions!["code"]?.ToString()); + Assert.Equal(expectedError.Message, error.Message); + + var activeTokenCount = await DbContext.RefreshTokens + .AsNoTracking() + .CountAsync(r => r.UserId == alice.Id && r.Revoked == null); + Assert.Equal(0, activeTokenCount); + } + [Fact] public async Task LoginUser_KeepsOtherSessions_OnReLogin() { From 5c4546747ec3f5ca4fa62225881d06547213e440 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Mon, 14 Sep 2026 16:28:50 +0200 Subject: [PATCH 12/12] test: update response models and user mutation examples to work with LogoutUser mutation --- TournamentAPI.Shared/Models/ResponseModels.cs | 10 ++++++++++ TournamentAPI.Shared/MutationExamples/UserMutations.cs | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/TournamentAPI.Shared/Models/ResponseModels.cs b/TournamentAPI.Shared/Models/ResponseModels.cs index 4f39d17..a401177 100644 --- a/TournamentAPI.Shared/Models/ResponseModels.cs +++ b/TournamentAPI.Shared/Models/ResponseModels.cs @@ -233,6 +233,16 @@ public class RefreshTokenResult public string? String { get; set; } } +public class LogoutResponse +{ + public LogoutUserResult? LogoutUser { get; set; } +} + +public class LogoutUserResult +{ + public bool? Boolean { get; set; } +} + public class MeResponse { public UserNode? Me { get; set; } diff --git a/TournamentAPI.Shared/MutationExamples/UserMutations.cs b/TournamentAPI.Shared/MutationExamples/UserMutations.cs index 25c3abb..e2cadd9 100644 --- a/TournamentAPI.Shared/MutationExamples/UserMutations.cs +++ b/TournamentAPI.Shared/MutationExamples/UserMutations.cs @@ -27,6 +27,14 @@ mutation RefreshToken { } """; + public const string LogoutUser = """ + mutation LogoutUser { + logoutUser { + boolean + } + } + """; + public const string UpdateEmailVisibility = """ mutation UpdateEmailVisibility($input: UpdateEmailVisibilityInput!) { updateEmailVisibility(input: $input) {