Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -447,7 +447,96 @@ 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<LoginResponse>(
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<RefreshTokenResponse>(
Shared.MutationExamples.Mutations.Users.RefreshToken, new { });
var task2 = client2.ExecuteMutationAsync<RefreshTokenResponse>(
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<RefreshTokenResponse>(
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()
{
using var client = CreateClient();
var loginVars = new { input = new { email = "alice@example.com", password = "Password123!" } };
Expand All @@ -459,8 +548,148 @@ await client.ExecuteMutationAsync<LoginResponse>(
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<LoginResponse>(
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<LogoutResponse>(
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<LoginResponse>(
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<LogoutResponse>(
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<LoginResponse>(
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<LogoutResponse>(
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<LogoutResponse>(
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<LoginResponse>(
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<LogoutResponse>(
Shared.MutationExamples.Mutations.Users.LogoutUser, new { });
var task2 = client2.ExecuteMutationAsync<LogoutResponse>(
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]
Expand Down
10 changes: 10 additions & 0 deletions TournamentAPI.Shared/Models/ResponseModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
8 changes: 8 additions & 0 deletions TournamentAPI.Shared/MutationExamples/UserMutations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
12 changes: 11 additions & 1 deletion TournamentAPI/Data/Models/RefreshToken.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
using System.ComponentModel.DataAnnotations;

namespace TournamentAPI.Data.Models;

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!;

[Timestamp]
public byte[] Version { get; set; } = null!;

public bool IsActive => Revoked is null && DateTime.UtcNow < Expires;
}
12 changes: 12 additions & 0 deletions TournamentAPI/Extensions/HttpResponseExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
}
}
3 changes: 3 additions & 0 deletions TournamentAPI/Users/UserErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ 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";
}
18 changes: 18 additions & 0 deletions TournamentAPI/Users/UserErrors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,30 @@ 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.")
.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.")
Expand Down
Loading
Loading