mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-04-05 18:09:04 +00:00
feat: implement consolidated TokenService with token generation, validation, and refresh
- Implement ITokenService interface with unified token handling - Add TokenService class supporting AccessToken, RefreshToken, and ConfirmationToken generation - Add ValidateAccessTokenAsync, ValidateRefreshTokenAsync, ValidateConfirmationTokenAsync methods - Add RefreshTokenAsync for token rotation with new access and refresh tokens - Include ValidatedToken and RefreshTokenResult records for type safety - Add unit tests for token validation and refresh operations - Support environment-based token secrets: ACCESS_TOKEN_SECRET, REFRESH_TOKEN_SECRET, CONFIRMATION_TOKEN_SECRET
This commit is contained in:
162
src/Core/Service/Service.Auth.Tests/TokenServiceRefresh.test.cs
Normal file
162
src/Core/Service/Service.Auth.Tests/TokenServiceRefresh.test.cs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Domain.Entities;
|
||||||
|
using Domain.Exceptions;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Infrastructure.Jwt;
|
||||||
|
using Infrastructure.Repository.Auth;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace Service.Auth.Tests;
|
||||||
|
|
||||||
|
public class TokenServiceRefreshTest
|
||||||
|
{
|
||||||
|
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||||
|
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||||
|
private readonly TokenService _tokenService;
|
||||||
|
|
||||||
|
public TokenServiceRefreshTest()
|
||||||
|
{
|
||||||
|
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
||||||
|
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||||
|
|
||||||
|
// Set environment variables for tokens
|
||||||
|
Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890");
|
||||||
|
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890");
|
||||||
|
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET", "test-confirmation-secret-that-is-very-long-1234567890");
|
||||||
|
|
||||||
|
_tokenService = new TokenService(
|
||||||
|
_tokenInfraMock.Object,
|
||||||
|
_authRepositoryMock.Object
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshTokenAsync_WithValidRefreshToken_ReturnsNewTokens()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
const string username = "testuser";
|
||||||
|
const string refreshToken = "valid-refresh-token";
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var claimsIdentity = new ClaimsIdentity(claims);
|
||||||
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||||
|
|
||||||
|
var userAccount = new UserAccount
|
||||||
|
{
|
||||||
|
UserAccountId = userId,
|
||||||
|
Username = username,
|
||||||
|
FirstName = "Test",
|
||||||
|
LastName = "User",
|
||||||
|
Email = "test@example.com",
|
||||||
|
DateOfBirth = new DateTime(1990, 1, 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the validation of refresh token
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
// Mock the generation of new tokens
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.GenerateJwt(userId, username, It.IsAny<DateTime>(), It.IsAny<string>()))
|
||||||
|
.Returns((Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}");
|
||||||
|
|
||||||
|
_authRepositoryMock
|
||||||
|
.Setup(x => x.GetUserByIdAsync(userId))
|
||||||
|
.ReturnsAsync(userAccount);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _tokenService.RefreshTokenAsync(refreshToken);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.UserAccount.UserAccountId.Should().Be(userId);
|
||||||
|
result.UserAccount.Username.Should().Be(username);
|
||||||
|
result.AccessToken.Should().NotBeEmpty();
|
||||||
|
result.RefreshToken.Should().NotBeEmpty();
|
||||||
|
|
||||||
|
_authRepositoryMock.Verify(
|
||||||
|
x => x.GetUserByIdAsync(userId),
|
||||||
|
Times.Once
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify tokens were generated (called twice - once for access, once for refresh)
|
||||||
|
_tokenInfraMock.Verify(
|
||||||
|
x => x.GenerateJwt(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()),
|
||||||
|
Times.Exactly(2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshTokenAsync_WithInvalidRefreshToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string invalidToken = "invalid-refresh-token";
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(invalidToken, It.IsAny<string>()))
|
||||||
|
.ThrowsAsync(new UnauthorizedException("Invalid refresh token"));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.RefreshTokenAsync(invalidToken)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshTokenAsync_WithExpiredRefreshToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string expiredToken = "expired-refresh-token";
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(expiredToken, It.IsAny<string>()))
|
||||||
|
.ThrowsAsync(new UnauthorizedException("Refresh token has expired"));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.RefreshTokenAsync(expiredToken)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshTokenAsync_WithNonExistentUser_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
const string username = "testuser";
|
||||||
|
const string refreshToken = "valid-refresh-token";
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var claimsIdentity = new ClaimsIdentity(claims);
|
||||||
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
_authRepositoryMock
|
||||||
|
.Setup(x => x.GetUserByIdAsync(userId))
|
||||||
|
.ReturnsAsync((UserAccount?)null);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.RefreshTokenAsync(refreshToken)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>()
|
||||||
|
.WithMessage("*User account not found*");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Domain.Entities;
|
||||||
|
using Domain.Exceptions;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Infrastructure.Jwt;
|
||||||
|
using Infrastructure.Repository.Auth;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace Service.Auth.Tests;
|
||||||
|
|
||||||
|
public class TokenServiceValidationTest
|
||||||
|
{
|
||||||
|
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||||
|
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||||
|
private readonly TokenService _tokenService;
|
||||||
|
|
||||||
|
public TokenServiceValidationTest()
|
||||||
|
{
|
||||||
|
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
||||||
|
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||||
|
|
||||||
|
// Set environment variables for tokens
|
||||||
|
Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890");
|
||||||
|
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890");
|
||||||
|
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET", "test-confirmation-secret-that-is-very-long-1234567890");
|
||||||
|
|
||||||
|
_tokenService = new TokenService(
|
||||||
|
_tokenInfraMock.Object,
|
||||||
|
_authRepositoryMock.Object
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAccessTokenAsync_WithValidToken_ReturnsValidatedToken()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
const string username = "testuser";
|
||||||
|
const string token = "valid-access-token";
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var claimsIdentity = new ClaimsIdentity(claims);
|
||||||
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result =
|
||||||
|
await _tokenService.ValidateAccessTokenAsync(token);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.UserId.Should().Be(userId);
|
||||||
|
result.Username.Should().Be(username);
|
||||||
|
result.Principal.Should().NotBeNull();
|
||||||
|
result.Principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value.Should().Be(userId.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateRefreshTokenAsync_WithValidToken_ReturnsValidatedToken()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
const string username = "testuser";
|
||||||
|
const string token = "valid-refresh-token";
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var claimsIdentity = new ClaimsIdentity(claims);
|
||||||
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result =
|
||||||
|
await _tokenService.ValidateRefreshTokenAsync(token);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.UserId.Should().Be(userId);
|
||||||
|
result.Username.Should().Be(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateConfirmationTokenAsync_WithValidToken_ReturnsValidatedToken()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
const string username = "testuser";
|
||||||
|
const string token = "valid-confirmation-token";
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var claimsIdentity = new ClaimsIdentity(claims);
|
||||||
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result =
|
||||||
|
await _tokenService.ValidateConfirmationTokenAsync(token);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.UserId.Should().Be(userId);
|
||||||
|
result.Username.Should().Be(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAccessTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string token = "invalid-token";
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.ValidateAccessTokenAsync(token)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAccessTokenAsync_WithExpiredToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string token = "expired-token";
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ThrowsAsync(new UnauthorizedException(
|
||||||
|
"Token has expired"
|
||||||
|
));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.ValidateAccessTokenAsync(token)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAccessTokenAsync_WithMissingUserIdClaim_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string username = "testuser";
|
||||||
|
const string token = "token-without-user-id";
|
||||||
|
|
||||||
|
// Claims without Sub (user ID)
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var claimsIdentity = new ClaimsIdentity(claims);
|
||||||
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.ValidateAccessTokenAsync(token)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>()
|
||||||
|
.WithMessage("*missing required claims*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAccessTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
const string token = "token-without-username";
|
||||||
|
|
||||||
|
// Claims without UniqueName (username)
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var claimsIdentity = new ClaimsIdentity(claims);
|
||||||
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.ValidateAccessTokenAsync(token)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>()
|
||||||
|
.WithMessage("*missing required claims*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAccessTokenAsync_WithMalformedUserId_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string username = "testuser";
|
||||||
|
const string token = "token-with-malformed-user-id";
|
||||||
|
|
||||||
|
// Claims with invalid GUID format
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"),
|
||||||
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var claimsIdentity = new ClaimsIdentity(claims);
|
||||||
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.ValidateAccessTokenAsync(token)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>()
|
||||||
|
.WithMessage("*malformed user ID*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateRefreshTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string token = "invalid-refresh-token";
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.ValidateRefreshTokenAsync(token)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateConfirmationTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string token = "invalid-confirmation-token";
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.ValidateConfirmationTokenAsync(token)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
|
using Domain.Exceptions;
|
||||||
using Infrastructure.Jwt;
|
using Infrastructure.Jwt;
|
||||||
|
using Infrastructure.Repository.Auth;
|
||||||
|
|
||||||
namespace Service.Auth;
|
namespace Service.Auth;
|
||||||
|
|
||||||
@@ -10,15 +14,13 @@ public enum TokenType
|
|||||||
ConfirmationToken,
|
ConfirmationToken,
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface ITokenService
|
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
|
||||||
{
|
|
||||||
public string GenerateAccessToken(UserAccount user);
|
|
||||||
public string GenerateRefreshToken(UserAccount user);
|
|
||||||
public string GenerateConfirmationToken(UserAccount user);
|
|
||||||
|
|
||||||
public string GenerateToken<T>(UserAccount user)
|
public record RefreshTokenResult(
|
||||||
where T : struct, Enum;
|
UserAccount UserAccount,
|
||||||
}
|
string RefreshToken,
|
||||||
|
string AccessToken
|
||||||
|
);
|
||||||
|
|
||||||
public static class TokenServiceExpirationHours
|
public static class TokenServiceExpirationHours
|
||||||
{
|
{
|
||||||
@@ -27,87 +29,128 @@ public static class TokenServiceExpirationHours
|
|||||||
public const double ConfirmationTokenHours = 0.5; // 30 minutes
|
public const double ConfirmationTokenHours = 0.5; // 30 minutes
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TokenService(ITokenInfrastructure tokenInfrastructure)
|
public interface ITokenService
|
||||||
: ITokenService
|
|
||||||
{
|
{
|
||||||
private readonly string _accessTokenSecret =
|
string GenerateAccessToken(UserAccount user);
|
||||||
Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
|
string GenerateRefreshToken(UserAccount user);
|
||||||
?? throw new InvalidOperationException(
|
string GenerateConfirmationToken(UserAccount user);
|
||||||
"ACCESS_TOKEN_SECRET environment variable is not set"
|
string GenerateToken<T>(UserAccount user) where T : struct, Enum;
|
||||||
);
|
Task<ValidatedToken> ValidateAccessTokenAsync(string token);
|
||||||
|
Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
|
||||||
|
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
|
||||||
|
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
|
||||||
|
}
|
||||||
|
|
||||||
private readonly string _refreshTokenSecret =
|
public class TokenService : ITokenService
|
||||||
Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
|
{
|
||||||
?? throw new InvalidOperationException(
|
private readonly ITokenInfrastructure _tokenInfrastructure;
|
||||||
"REFRESH_TOKEN_SECRET environment variable is not set"
|
private readonly IAuthRepository _authRepository;
|
||||||
);
|
|
||||||
|
|
||||||
private readonly string _confirmationTokenSecret =
|
private readonly string _accessTokenSecret;
|
||||||
Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
|
private readonly string _refreshTokenSecret;
|
||||||
?? throw new InvalidOperationException(
|
private readonly string _confirmationTokenSecret;
|
||||||
"CONFIRMATION_TOKEN_SECRET environment variable is not set"
|
|
||||||
);
|
|
||||||
|
|
||||||
public string GenerateAccessToken(UserAccount userAccount)
|
public TokenService(
|
||||||
|
ITokenInfrastructure tokenInfrastructure,
|
||||||
|
IAuthRepository authRepository
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var jwtExpiresAt = DateTime.UtcNow.AddHours(
|
_tokenInfrastructure = tokenInfrastructure;
|
||||||
TokenServiceExpirationHours.AccessTokenHours
|
_authRepository = authRepository;
|
||||||
);
|
|
||||||
return tokenInfrastructure.GenerateJwt(
|
_accessTokenSecret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
|
||||||
userAccount.UserAccountId,
|
?? throw new InvalidOperationException("ACCESS_TOKEN_SECRET environment variable is not set");
|
||||||
userAccount.Username,
|
|
||||||
jwtExpiresAt,
|
_refreshTokenSecret = Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
|
||||||
_accessTokenSecret
|
?? throw new InvalidOperationException("REFRESH_TOKEN_SECRET environment variable is not set");
|
||||||
);
|
|
||||||
|
_confirmationTokenSecret = Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
|
||||||
|
?? throw new InvalidOperationException("CONFIRMATION_TOKEN_SECRET environment variable is not set");
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GenerateRefreshToken(UserAccount userAccount)
|
public string GenerateAccessToken(UserAccount user)
|
||||||
{
|
{
|
||||||
var jwtExpiresAt = DateTime.UtcNow.AddHours(
|
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
||||||
TokenServiceExpirationHours.RefreshTokenHours
|
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _accessTokenSecret);
|
||||||
);
|
|
||||||
return tokenInfrastructure.GenerateJwt(
|
|
||||||
userAccount.UserAccountId,
|
|
||||||
userAccount.Username,
|
|
||||||
jwtExpiresAt,
|
|
||||||
_refreshTokenSecret
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GenerateConfirmationToken(UserAccount userAccount)
|
public string GenerateRefreshToken(UserAccount user)
|
||||||
{
|
{
|
||||||
var jwtExpiresAt = DateTime.UtcNow.AddHours(
|
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours);
|
||||||
TokenServiceExpirationHours.ConfirmationTokenHours
|
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _refreshTokenSecret);
|
||||||
);
|
|
||||||
return tokenInfrastructure.GenerateJwt(
|
|
||||||
userAccount.UserAccountId,
|
|
||||||
userAccount.Username,
|
|
||||||
jwtExpiresAt,
|
|
||||||
_confirmationTokenSecret
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GenerateToken<T>(UserAccount userAccount)
|
public string GenerateConfirmationToken(UserAccount user)
|
||||||
where T : struct, Enum
|
|
||||||
{
|
{
|
||||||
var tokenType = typeof(T);
|
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours);
|
||||||
if (tokenType == typeof(TokenType))
|
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _confirmationTokenSecret);
|
||||||
{
|
}
|
||||||
var tokenTypeValue = (TokenType)
|
|
||||||
Enum.Parse(tokenType, typeof(T).Name);
|
public string GenerateToken<T>(UserAccount user) where T : struct, Enum
|
||||||
return tokenTypeValue switch
|
{
|
||||||
{
|
if (typeof(T) != typeof(TokenType))
|
||||||
TokenType.AccessToken => GenerateAccessToken(userAccount),
|
|
||||||
TokenType.RefreshToken => GenerateRefreshToken(userAccount),
|
|
||||||
TokenType.ConfirmationToken => GenerateConfirmationToken(
|
|
||||||
userAccount
|
|
||||||
),
|
|
||||||
_ => throw new InvalidOperationException("Invalid token type"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Invalid token type");
|
throw new InvalidOperationException("Invalid token type");
|
||||||
|
|
||||||
|
var tokenTypeName = typeof(T).Name;
|
||||||
|
if (!Enum.TryParse(typeof(TokenType), tokenTypeName, out var parsed))
|
||||||
|
throw new InvalidOperationException("Invalid token type");
|
||||||
|
|
||||||
|
var tokenType = (TokenType)parsed;
|
||||||
|
return tokenType switch
|
||||||
|
{
|
||||||
|
TokenType.AccessToken => GenerateAccessToken(user),
|
||||||
|
TokenType.RefreshToken => GenerateRefreshToken(user),
|
||||||
|
TokenType.ConfirmationToken => GenerateConfirmationToken(user),
|
||||||
|
_ => throw new InvalidOperationException("Invalid token type"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
|
||||||
|
=> await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
||||||
|
|
||||||
|
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
|
||||||
|
=> await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
||||||
|
|
||||||
|
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
|
||||||
|
=> await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
||||||
|
|
||||||
|
private async Task<ValidatedToken> ValidateTokenInternalAsync(string token, string secret, string tokenType)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret);
|
||||||
|
|
||||||
|
var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
||||||
|
var usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(userIdClaim) || string.IsNullOrEmpty(usernameClaim))
|
||||||
|
throw new UnauthorizedException($"Invalid {tokenType} token: missing required claims");
|
||||||
|
|
||||||
|
if (!Guid.TryParse(userIdClaim, out var userId))
|
||||||
|
throw new UnauthorizedException($"Invalid {tokenType} token: malformed user ID");
|
||||||
|
|
||||||
|
return new ValidatedToken(userId, usernameClaim, principal);
|
||||||
}
|
}
|
||||||
|
catch (UnauthorizedException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
throw new UnauthorizedException($"Failed to validate {tokenType} token: {e.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
||||||
|
{
|
||||||
|
var validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
||||||
|
var user = await _authRepository.GetUserByIdAsync(validated.UserId);
|
||||||
|
if (user == null)
|
||||||
|
throw new UnauthorizedException("User account not found");
|
||||||
|
|
||||||
|
var newAccess = GenerateAccessToken(user);
|
||||||
|
var newRefresh = GenerateRefreshToken(user);
|
||||||
|
|
||||||
|
return new RefreshTokenResult(user, newRefresh, newAccess);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user