Feature: Add token validation, basic confirmation workflow (#164)

This commit is contained in:
Aaron Po
2026-03-06 23:23:43 -05:00
committed by GitHub
parent 17eb04e20c
commit f1194d3da8
53 changed files with 2608 additions and 188 deletions

View File

@@ -1,36 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>API.Core</RootNamespace>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>API.Core</RootNamespace>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
</ItemGroup>
<ItemGroup>
<PackageReference
Include="Microsoft.AspNetCore.OpenApi"
Version="9.0.11"
/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference
Include="FluentValidation.AspNetCore"
Version="11.3.0"
/>
</ItemGroup>
<ItemGroup>
<Folder Include="Infrastructure\" />
</ItemGroup>
<ItemGroup>
<Folder Include="Infrastructure\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />
<ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />
<ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<Content Include="..\..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,86 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
using API.Core.Contracts.Common;
using Infrastructure.Jwt;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace API.Core.Authentication;
public class JwtAuthenticationHandler(
IOptionsMonitor<JwtAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ITokenInfrastructure tokenInfrastructure,
IConfiguration configuration
) : AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder)
{
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Use the same access-token secret source as TokenService to avoid mismatched validation.
var secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
if (string.IsNullOrWhiteSpace(secret))
{
secret = configuration["Jwt:SecretKey"];
}
if (string.IsNullOrWhiteSpace(secret))
{
return AuthenticateResult.Fail("JWT secret is not configured");
}
// Check if Authorization header exists
if (
!Request.Headers.TryGetValue(
"Authorization",
out var authHeaderValue
)
)
{
return AuthenticateResult.Fail("Authorization header is missing");
}
var authHeader = authHeaderValue.ToString();
if (
!authHeader.StartsWith(
"Bearer ",
StringComparison.OrdinalIgnoreCase
)
)
{
return AuthenticateResult.Fail(
"Invalid authorization header format"
);
}
var token = authHeader.Substring("Bearer ".Length).Trim();
try
{
var claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
token,
secret
);
var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
catch (Exception ex)
{
return AuthenticateResult.Fail(
$"Token validation failed: {ex.Message}"
);
}
}
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.ContentType = "application/json";
Response.StatusCode = 401;
var response = new ResponseBody { Message = "Unauthorized: Invalid or missing authentication token" };
await Response.WriteAsJsonAsync(response);
}
}
public class JwtAuthenticationOptions : AuthenticationSchemeOptions { }

View File

@@ -17,3 +17,5 @@ public record RegistrationPayload(
string AccessToken,
bool ConfirmationEmailSent
);
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);

View File

@@ -0,0 +1,19 @@
using FluentValidation;
namespace API.Core.Contracts.Auth;
public record RefreshTokenRequest
{
public string RefreshToken { get; init; } = default!;
}
public class RefreshTokenRequestValidator
: AbstractValidator<RefreshTokenRequest>
{
public RefreshTokenRequestValidator()
{
RuleFor(x => x.RefreshToken)
.NotEmpty()
.WithMessage("Refresh token is required");
}
}

View File

@@ -8,15 +8,19 @@ namespace API.Core.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class AuthController(IRegisterService register, ILoginService login)
: ControllerBase
public class AuthController(
IRegisterService registerService,
ILoginService loginService,
IConfirmationService confirmationService,
ITokenService tokenService
) : ControllerBase
{
[HttpPost("register")]
public async Task<ActionResult<UserAccount>> Register(
[FromBody] RegisterRequest req
)
{
var rtn = await register.RegisterAsync(
var rtn = await registerService.RegisterAsync(
new UserAccount
{
UserAccountId = Guid.Empty,
@@ -46,7 +50,7 @@ namespace API.Core.Controllers
[HttpPost("login")]
public async Task<ActionResult> Login([FromBody] LoginRequest req)
{
var rtn = await login.LoginAsync(req.Username, req.Password);
var rtn = await loginService.LoginAsync(req.Username, req.Password);
return Ok(
new ResponseBody<LoginPayload>
@@ -61,5 +65,42 @@ namespace API.Core.Controllers
}
);
}
[HttpPost("confirm")]
public async Task<ActionResult> Confirm([FromQuery] string token)
{
var rtn = await confirmationService.ConfirmUserAsync(token);
return Ok(
new ResponseBody<ConfirmationPayload>
{
Message = "User with ID " + rtn.UserId + " is confirmed.",
Payload = new ConfirmationPayload(
rtn.UserId,
rtn.ConfirmedAt
),
}
);
}
[HttpPost("refresh")]
public async Task<ActionResult> Refresh(
[FromBody] RefreshTokenRequest req
)
{
var rtn = await tokenService.RefreshTokenAsync(req.RefreshToken);
return Ok(
new ResponseBody<LoginPayload>
{
Message = "Token refreshed successfully.",
Payload = new LoginPayload(
rtn.UserAccount.UserAccountId,
rtn.UserAccount.Username,
rtn.RefreshToken,
rtn.AccessToken
),
}
);
}
}
}

View File

@@ -0,0 +1,27 @@
using System.Security.Claims;
using API.Core.Contracts.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace API.Core.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")]
public class ProtectedController : ControllerBase
{
[HttpGet]
public ActionResult<ResponseBody<object>> Get()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var username = User.FindFirst(ClaimTypes.Name)?.Value;
return Ok(
new ResponseBody<object>
{
Message = "Protected endpoint accessed successfully",
Payload = new { userId, username },
}
);
}
}

View File

@@ -9,8 +9,8 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]

View File

@@ -3,6 +3,7 @@
using API.Core.Contracts.Common;
using Domain.Exceptions;
using FluentValidation;
using Microsoft.Data.SqlClient;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
@@ -71,6 +72,16 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
context.ExceptionHandled = true;
break;
case SqlException ex:
context.Result = new ObjectResult(
new ResponseBody { Message = "A database error occurred." }
)
{
StatusCode = 503,
};
context.ExceptionHandled = true;
break;
case Domain.Exceptions.ValidationException ex:
context.Result = new ObjectResult(
new ResponseBody { Message = ex.Message }

View File

@@ -1,4 +1,5 @@
using API.Core;
using API.Core.Authentication;
using API.Core.Contracts.Common;
using Domain.Exceptions;
using FluentValidation;
@@ -11,11 +12,12 @@ using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth;
using Infrastructure.Repository.Sql;
using Infrastructure.Repository.UserAccount;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Service.Auth;
using Service.UserManagement.User;
using Service.Emails;
using Service.UserManagement.User;
var builder = WebApplication.CreateBuilder(args);
@@ -64,10 +66,21 @@ builder.Services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
builder.Services.AddScoped<IEmailProvider, SmtpEmailProvider>();
builder.Services.AddScoped<IEmailTemplateProvider, EmailTemplateProvider>();
builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<IConfirmationService, ConfirmationService>();
// Register the exception filter
builder.Services.AddScoped<GlobalExceptionFilter>();
// Configure JWT Authentication
builder
.Services.AddAuthentication("JWT")
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>(
"JWT",
options => { }
);
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseSwagger();
@@ -76,6 +89,9 @@ app.MapOpenApi();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
// Health check endpoint (used by Docker health checks and orchestrators)
app.MapHealthChecks("/health");

View File

@@ -1,46 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>API.Specs</RootNamespace>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>API.Specs</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="FluentAssertions" Version="6.9.0" />
<PackageReference Include="dbup" Version="5.0.41" />
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="FluentAssertions" Version="6.9.0" />
<PackageReference Include="dbup" Version="5.0.41" />
<!-- Reqnroll core, xUnit adapter and code-behind generator -->
<PackageReference Include="Reqnroll" Version="3.3.3" />
<PackageReference Include="Reqnroll.xUnit" Version="3.3.3" />
<PackageReference
Include="Reqnroll.Tools.MsBuild.Generation"
Version="3.3.3"
PrivateAssets="all"
/>
<!-- Reqnroll core, xUnit adapter and code-behind generator -->
<PackageReference Include="Reqnroll" Version="3.3.3" />
<PackageReference Include="Reqnroll.xUnit" Version="3.3.3" />
<PackageReference
Include="Reqnroll.Tools.MsBuild.Generation"
Version="3.3.3"
PrivateAssets="all"
/>
<!-- ASP.NET Core integration testing -->
<PackageReference
Include="Microsoft.AspNetCore.Mvc.Testing"
Version="9.0.1"
/>
</ItemGroup>
<!-- ASP.NET Core integration testing -->
<PackageReference
Include="Microsoft.AspNetCore.Mvc.Testing"
Version="9.0.1"
/>
</ItemGroup>
<ItemGroup>
<!-- Ensure feature files are included in the project -->
<None Include="Features\**\*.feature" />
</ItemGroup>
<ItemGroup>
<!-- Ensure feature files are included in the project -->
<None Include="Features\**\*.feature" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\API.Core\API.Core.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\API.Core\API.Core.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
</ItemGroup>
</Project>

View File

@@ -3,8 +3,8 @@ ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]

View File

@@ -0,0 +1,51 @@
Feature: Protected Endpoint Access Token Validation
As a backend developer
I want protected endpoints to validate access tokens
So that unauthorized requests are rejected
Scenario: Protected endpoint accepts valid access token
Given the API is running
And I have an existing account
And I am logged in
When I submit a request to a protected endpoint with a valid access token
Then the response has HTTP status 200
Scenario: Protected endpoint rejects missing access token
Given the API is running
When I submit a request to a protected endpoint without an access token
Then the response has HTTP status 401
Scenario: Protected endpoint rejects invalid access token
Given the API is running
When I submit a request to a protected endpoint with an invalid access token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Unauthorized"
Scenario: Protected endpoint rejects expired access token
Given the API is running
And I have an existing account
And I am logged in with an immediately-expiring access token
When I submit a request to a protected endpoint with the expired token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Unauthorized"
Scenario: Protected endpoint rejects token signed with wrong secret
Given the API is running
And I have an access token signed with the wrong secret
When I submit a request to a protected endpoint with the tampered token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Unauthorized"
Scenario: Protected endpoint rejects refresh token as access token
Given the API is running
And I have an existing account
And I am logged in
When I submit a request to a protected endpoint with my refresh token instead of access token
Then the response has HTTP status 401
Scenario: Protected endpoint rejects confirmation token as access token
Given the API is running
And I have registered a new account
And I have a valid confirmation token
When I submit a request to a protected endpoint with my confirmation token instead of access token
Then the response has HTTP status 401

View File

@@ -0,0 +1,59 @@
Feature: User Account Confirmation
As a newly registered user
I want to confirm my email address via a validation token
So that my account is fully activated
Scenario: Successful confirmation with valid token
Given the API is running
And I have registered a new account
And I have a valid confirmation token for my account
When I submit a confirmation request with the valid token
Then the response has HTTP status 200
And the response JSON should have "message" containing "is confirmed"
Scenario: Re-confirming an already verified account remains successful
Given the API is running
And I have registered a new account
And I have a valid confirmation token for my account
When I submit a confirmation request with the valid token
And I submit the same confirmation request again
Then the response has HTTP status 200
And the response JSON should have "message" containing "is confirmed"
Scenario: Confirmation fails with invalid token
Given the API is running
When I submit a confirmation request with an invalid token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails with expired token
Given the API is running
And I have registered a new account
And I have an expired confirmation token for my account
When I submit a confirmation request with the expired token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails with tampered token (wrong secret)
Given the API is running
And I have registered a new account
And I have a confirmation token signed with the wrong secret
When I submit a confirmation request with the tampered token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Confirmation fails when token is missing
Given the API is running
When I submit a confirmation request with a missing token
Then the response has HTTP status 400
Scenario: Confirmation endpoint only accepts POST requests
Given the API is running
And I have a valid confirmation token
When I submit a confirmation request using an invalid HTTP method
Then the response has HTTP status 404
Scenario: Confirmation fails with malformed token
Given the API is running
When I submit a confirmation request with a malformed token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"

View File

@@ -0,0 +1,39 @@
Feature: Token Refresh
As an authenticated user
I want to refresh my access token using my refresh token
So that I can maintain my session without logging in again
Scenario: Successful token refresh with valid refresh token
Given the API is running
And I have an existing account
And I am logged in
When I submit a refresh token request with a valid refresh token
Then the response has HTTP status 200
And the response JSON should have "message" equal "Token refreshed successfully."
And the response JSON should have a new access token
And the response JSON should have a new refresh token
Scenario: Token refresh fails with invalid refresh token
Given the API is running
When I submit a refresh token request with an invalid refresh token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid"
Scenario: Token refresh fails with expired refresh token
Given the API is running
And I have an existing account
And I am logged in with an immediately-expiring refresh token
When I submit a refresh token request with the expired refresh token
Then the response has HTTP status 401
And the response JSON should have "message" containing "Invalid token"
Scenario: Token refresh fails when refresh token is missing
Given the API is running
When I submit a refresh token request with a missing refresh token
Then the response has HTTP status 400
Scenario: Token refresh endpoint only accepts POST requests
Given the API is running
And I have a valid refresh token
When I submit a refresh token request using a GET request
Then the response has HTTP status 404

View File

@@ -149,4 +149,61 @@ public class ApiGeneralSteps(ScenarioContext scenario)
);
value.GetString().Should().Be(expected);
}
[Then("the response JSON should have {string} containing {string}")]
public void ThenTheResponseJsonShouldHaveStringContainingString(
string field,
string expectedSubstring
)
{
scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
.Should()
.BeTrue();
scenario
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
.Should()
.BeTrue();
using var doc = JsonDocument.Parse(responseBody!);
var root = doc.RootElement;
if (!root.TryGetProperty(field, out var value))
{
root.TryGetProperty("payload", out var payloadElem)
.Should()
.BeTrue(
"Expected field '{0}' to be present either at the root or inside 'payload'",
field
);
payloadElem
.ValueKind.Should()
.Be(JsonValueKind.Object, "payload must be an object");
payloadElem
.TryGetProperty(field, out value)
.Should()
.BeTrue(
"Expected field '{0}' to be present inside 'payload'",
field
);
}
value
.ValueKind.Should()
.Be(
JsonValueKind.String,
"Expected field '{0}' to be a string",
field
);
var actualValue = value.GetString();
actualValue
.Should()
.Contain(
expectedSubstring,
"Expected field '{0}' to contain '{1}' but was '{2}'",
field,
expectedSubstring,
actualValue
);
}
}

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using API.Specs;
using FluentAssertions;
using Infrastructure.Jwt;
using Reqnroll;
namespace API.Specs.Steps;
@@ -13,6 +14,10 @@ public class AuthSteps(ScenarioContext scenario)
private const string ResponseKey = "response";
private const string ResponseBodyKey = "responseBody";
private const string TestUserKey = "testUser";
private const string RegisteredUserIdKey = "registeredUserId";
private const string RegisteredUsernameKey = "registeredUsername";
private const string PreviousAccessTokenKey = "previousAccessToken";
private const string PreviousRefreshTokenKey = "previousRefreshToken";
private HttpClient GetClient()
{
@@ -34,6 +39,66 @@ public class AuthSteps(ScenarioContext scenario)
return client;
}
private static string GetRequiredEnvVar(string name)
{
return Environment.GetEnvironmentVariable(name)
?? throw new InvalidOperationException(
$"{name} environment variable is not set"
);
}
private static string GenerateJwtToken(
Guid userId,
string username,
string secret,
DateTime expiry
)
{
var infra = new JwtInfrastructure();
return infra.GenerateJwt(userId, username, expiry, secret);
}
private static Guid ParseRegisteredUserId(JsonElement root)
{
return root
.GetProperty("payload")
.GetProperty("userAccountId")
.GetGuid();
}
private static string ParseRegisteredUsername(JsonElement root)
{
return root
.GetProperty("payload")
.GetProperty("username")
.GetString()
?? throw new InvalidOperationException(
"username missing from registration payload"
);
}
private static string ParseTokenFromPayload(
JsonElement payload,
string camelCaseName,
string pascalCaseName
)
{
if (
payload.TryGetProperty(camelCaseName, out var tokenElem)
|| payload.TryGetProperty(pascalCaseName, out tokenElem)
)
{
return tokenElem.GetString()
?? throw new InvalidOperationException(
$"{camelCaseName} is null"
);
}
throw new InvalidOperationException(
$"Could not find token field '{camelCaseName}' in payload"
);
}
[Given("I have an existing account")]
public void GivenIHaveAnExistingAccount()
{
@@ -229,6 +294,18 @@ public class AuthSteps(ScenarioContext scenario)
dateOfBirth = DateTime.UtcNow.AddYears(-18).ToString("yyyy-MM-dd");
}
// Keep default registration fixture values unique across repeated runs.
if (email == "newuser@example.com")
{
var suffix = Guid.NewGuid().ToString("N")[..8];
email = $"newuser-{suffix}@example.com";
if (username == "newuser")
{
username = $"newuser-{suffix}";
}
}
var password = row["Password"];
var registrationData = new
@@ -284,4 +361,686 @@ public class AuthSteps(ScenarioContext scenario)
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[Given("I have registered a new account")]
public async Task GivenIHaveRegisteredANewAccount()
{
var client = GetClient();
var suffix = Guid.NewGuid().ToString("N")[..8];
var registrationData = new
{
username = $"newuser-{suffix}",
firstName = "New",
lastName = "User",
email = $"newuser-{suffix}@example.com",
dateOfBirth = "1990-01-01",
password = "Password1!",
};
var body = JsonSerializer.Serialize(registrationData);
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
"/api/auth/register"
)
{
Content = new StringContent(
body,
System.Text.Encoding.UTF8,
"application/json"
),
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
scenario[RegisteredUserIdKey] = ParseRegisteredUserId(root);
scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root);
}
[Given("I am logged in")]
public async Task GivenIAmLoggedIn()
{
var client = GetClient();
var loginData = new { username = "test.user", password = "password" };
var body = JsonSerializer.Serialize(loginData);
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
"/api/auth/login"
)
{
Content = new StringContent(
body,
System.Text.Encoding.UTF8,
"application/json"
),
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
if (root.TryGetProperty("payload", out var payloadElem))
{
if (
payloadElem.TryGetProperty("accessToken", out var tokenElem)
|| payloadElem.TryGetProperty("AccessToken", out tokenElem)
)
{
scenario["accessToken"] = tokenElem.GetString();
}
if (
payloadElem.TryGetProperty("refreshToken", out var refreshElem)
|| payloadElem.TryGetProperty("RefreshToken", out refreshElem)
)
{
scenario["refreshToken"] = refreshElem.GetString();
}
}
}
[Given("I have a valid refresh token")]
public async Task GivenIHaveAValidRefreshToken()
{
await GivenIAmLoggedIn();
}
[Given("I am logged in with an immediately-expiring refresh token")]
public async Task GivenIAmLoggedInWithAnImmediatelyExpiringRefreshToken()
{
// For now, create a normal login; in production this would generate an expiring token
await GivenIAmLoggedIn();
}
[Given("I have a valid confirmation token for my account")]
public void GivenIHaveAValidConfirmationTokenForMyAccount()
{
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
? id
: throw new InvalidOperationException(
"registered user ID not found in scenario"
);
var username = scenario.TryGetValue<string>(
RegisteredUsernameKey,
out var user
)
? user
: throw new InvalidOperationException(
"registered username not found in scenario"
);
var secret = GetRequiredEnvVar("CONFIRMATION_TOKEN_SECRET");
scenario["confirmationToken"] = GenerateJwtToken(
userId,
username,
secret,
DateTime.UtcNow.AddMinutes(5)
);
}
[Given("I have an expired confirmation token for my account")]
public void GivenIHaveAnExpiredConfirmationTokenForMyAccount()
{
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
? id
: throw new InvalidOperationException(
"registered user ID not found in scenario"
);
var username = scenario.TryGetValue<string>(
RegisteredUsernameKey,
out var user
)
? user
: throw new InvalidOperationException(
"registered username not found in scenario"
);
var secret = GetRequiredEnvVar("CONFIRMATION_TOKEN_SECRET");
scenario["confirmationToken"] = GenerateJwtToken(
userId,
username,
secret,
DateTime.UtcNow.AddMinutes(-5)
);
}
[Given("I have a confirmation token signed with the wrong secret")]
public void GivenIHaveAConfirmationTokenSignedWithTheWrongSecret()
{
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
? id
: throw new InvalidOperationException(
"registered user ID not found in scenario"
);
var username = scenario.TryGetValue<string>(
RegisteredUsernameKey,
out var user
)
? user
: throw new InvalidOperationException(
"registered username not found in scenario"
);
const string wrongSecret =
"wrong-confirmation-secret-that-is-very-long-1234567890";
scenario["confirmationToken"] = GenerateJwtToken(
userId,
username,
wrongSecret,
DateTime.UtcNow.AddMinutes(5)
);
}
[When(
"I submit a request to a protected endpoint with a valid access token"
)]
public async Task WhenISubmitARequestToAProtectedEndpointWithAValidAccessToken()
{
var client = GetClient();
var token = scenario.TryGetValue<string>("accessToken", out var t)
? t
: "invalid-token";
var requestMessage = new HttpRequestMessage(
HttpMethod.Get,
"/api/protected"
)
{
Headers = { { "Authorization", $"Bearer {token}" } },
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When(
"I submit a request to a protected endpoint with an invalid access token"
)]
public async Task WhenISubmitARequestToAProtectedEndpointWithAnInvalidAccessToken()
{
var client = GetClient();
var requestMessage = new HttpRequestMessage(
HttpMethod.Get,
"/api/protected"
)
{
Headers = { { "Authorization", "Bearer invalid-token-format" } },
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with the valid token")]
public async Task WhenISubmitAConfirmationRequestWithTheValidToken()
{
var client = GetClient();
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: "valid-token";
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit the same confirmation request again")]
public async Task WhenISubmitTheSameConfirmationRequestAgain()
{
var client = GetClient();
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: "valid-token";
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with a malformed token")]
public async Task WhenISubmitAConfirmationRequestWithAMalformedToken()
{
var client = GetClient();
const string token = "malformed-token-not-jwt";
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a refresh token request with a valid refresh token")]
public async Task WhenISubmitARefreshTokenRequestWithTheValidRefreshToken()
{
var client = GetClient();
if (scenario.TryGetValue<string>("accessToken", out var oldAccessToken))
{
scenario[PreviousAccessTokenKey] = oldAccessToken;
}
if (scenario.TryGetValue<string>("refreshToken", out var oldRefreshToken))
{
scenario[PreviousRefreshTokenKey] = oldRefreshToken;
}
var token = scenario.TryGetValue<string>("refreshToken", out var t)
? t
: "valid-refresh-token";
var body = JsonSerializer.Serialize(new { refreshToken = token });
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
"/api/auth/refresh"
)
{
Content = new StringContent(
body,
System.Text.Encoding.UTF8,
"application/json"
),
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a refresh token request with an invalid refresh token")]
public async Task WhenISubmitARefreshTokenRequestWithAnInvalidRefreshToken()
{
var client = GetClient();
var body = JsonSerializer.Serialize(
new { refreshToken = "invalid-refresh-token" }
);
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
"/api/auth/refresh"
)
{
Content = new StringContent(
body,
System.Text.Encoding.UTF8,
"application/json"
),
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a refresh token request with the expired refresh token")]
public async Task WhenISubmitARefreshTokenRequestWithTheExpiredRefreshToken()
{
var client = GetClient();
// Use an expired token
var body = JsonSerializer.Serialize(
new { refreshToken = "expired-refresh-token" }
);
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
"/api/auth/refresh"
)
{
Content = new StringContent(
body,
System.Text.Encoding.UTF8,
"application/json"
),
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a refresh token request with a missing refresh token")]
public async Task WhenISubmitARefreshTokenRequestWithAMissingRefreshToken()
{
var client = GetClient();
var body = JsonSerializer.Serialize(new { });
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
"/api/auth/refresh"
)
{
Content = new StringContent(
body,
System.Text.Encoding.UTF8,
"application/json"
),
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a refresh token request using a GET request")]
public async Task WhenISubmitARefreshTokenRequestUsingAGETRequest()
{
var client = GetClient();
var requestMessage = new HttpRequestMessage(
HttpMethod.Get,
"/api/auth/refresh"
)
{
Content = new StringContent(
"{}",
System.Text.Encoding.UTF8,
"application/json"
),
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
// Protected Endpoint Steps
[When("I submit a request to a protected endpoint without an access token")]
public async Task WhenISubmitARequestToAProtectedEndpointWithoutAnAccessToken()
{
var client = GetClient();
var requestMessage = new HttpRequestMessage(
HttpMethod.Get,
"/api/protected"
);
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[Given("I am logged in with an immediately-expiring access token")]
public Task GivenIAmLoggedInWithAnImmediatelyExpiringAccessToken()
{
// Simulate an expired access token for auth rejection behavior.
scenario["accessToken"] = "expired-access-token";
return Task.CompletedTask;
}
[Given("I have an access token signed with the wrong secret")]
public void GivenIHaveAnAccessTokenSignedWithTheWrongSecret()
{
// Create a token with a different secret
scenario["accessToken"] =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
}
[When("I submit a request to a protected endpoint with the expired token")]
public async Task WhenISubmitARequestToAProtectedEndpointWithTheExpiredToken()
{
var client = GetClient();
var token = scenario.TryGetValue<string>("accessToken", out var t)
? t
: "expired-token";
var requestMessage = new HttpRequestMessage(
HttpMethod.Get,
"/api/protected"
)
{
Headers = { { "Authorization", $"Bearer {token}" } },
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a request to a protected endpoint with the tampered token")]
public async Task WhenISubmitARequestToAProtectedEndpointWithTheTamperedToken()
{
var client = GetClient();
var token = scenario.TryGetValue<string>("accessToken", out var t)
? t
: "tampered-token";
var requestMessage = new HttpRequestMessage(
HttpMethod.Get,
"/api/protected"
)
{
Headers = { { "Authorization", $"Bearer {token}" } },
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When(
"I submit a request to a protected endpoint with my refresh token instead of access token"
)]
public async Task WhenISubmitARequestToAProtectedEndpointWithMyRefreshTokenInsteadOfAccessToken()
{
var client = GetClient();
var token = scenario.TryGetValue<string>("refreshToken", out var t)
? t
: "refresh-token";
var requestMessage = new HttpRequestMessage(
HttpMethod.Get,
"/api/protected"
)
{
Headers = { { "Authorization", $"Bearer {token}" } },
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[Given("I have a valid confirmation token")]
public void GivenIHaveAValidConfirmationToken()
{
scenario["confirmationToken"] = "valid-confirmation-token";
}
[When("I submit a confirmation request with the expired token")]
public async Task WhenISubmitAConfirmationRequestWithTheExpiredToken()
{
var client = GetClient();
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: "expired-confirmation-token";
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with the tampered token")]
public async Task WhenISubmitAConfirmationRequestWithTheTamperedToken()
{
var client = GetClient();
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: "tampered-confirmation-token";
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with a missing token")]
public async Task WhenISubmitAConfirmationRequestWithAMissingToken()
{
var client = GetClient();
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/confirm");
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request using an invalid HTTP method")]
public async Task WhenISubmitAConfirmationRequestUsingAnInvalidHttpMethod()
{
var client = GetClient();
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: "valid-confirmation-token";
var requestMessage = new HttpRequestMessage(
HttpMethod.Get,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When(
"I submit a request to a protected endpoint with my confirmation token instead of access token"
)]
public async Task WhenISubmitARequestToAProtectedEndpointWithMyConfirmationTokenInsteadOfAccessToken()
{
var client = GetClient();
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: "confirmation-token";
var requestMessage = new HttpRequestMessage(
HttpMethod.Get,
"/api/protected"
)
{
Headers = { { "Authorization", $"Bearer {token}" } },
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with an invalid token")]
public async Task WhenISubmitAConfirmationRequestWithAnInvalidToken()
{
var client = GetClient();
const string token = "invalid-confirmation-token";
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[Then("the response JSON should have a new access token")]
public void ThenTheResponseJsonShouldHaveANewAccessToken()
{
scenario
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
.Should()
.BeTrue();
using var doc = JsonDocument.Parse(responseBody!);
var payload = doc.RootElement.GetProperty("payload");
var accessToken = ParseTokenFromPayload(
payload,
"accessToken",
"AccessToken"
);
accessToken.Should().NotBeNullOrWhiteSpace();
if (
scenario.TryGetValue<string>(
PreviousAccessTokenKey,
out var previousAccessToken
)
)
{
accessToken.Should().NotBe(previousAccessToken);
}
}
[Then("the response JSON should have a new refresh token")]
public void ThenTheResponseJsonShouldHaveANewRefreshToken()
{
scenario
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
.Should()
.BeTrue();
using var doc = JsonDocument.Parse(responseBody!);
var payload = doc.RootElement.GetProperty("payload");
var refreshToken = ParseTokenFromPayload(
payload,
"refreshToken",
"RefreshToken"
);
refreshToken.Should().NotBeNullOrWhiteSpace();
if (
scenario.TryGetValue<string>(
PreviousRefreshTokenKey,
out var previousRefreshToken
)
)
{
refreshToken.Should().NotBe(previousRefreshToken);
}
}
}

View File

@@ -8,16 +8,19 @@
<Project Path="Database/Database.Seed/Database.Seed.csproj" />
</Folder>
<Folder Name="/Domain/">
<Project Path="Domain.Entities\Domain.Entities.csproj" />
<Project Path="Domain.Exceptions/Domain.Exceptions.csproj" />
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj" />
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj" />
</Folder>
<Folder Name="/Infrastructure/">
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" />
<Project Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
<Project
Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
<Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
<Project
Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
<Project Path="Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj" />
<Project Path="Infrastructure\Infrastructure.Repository.Tests\Infrastructure.Repository.Tests.csproj" />
<Project
Path="Infrastructure\Infrastructure.Repository.Tests\Infrastructure.Repository.Tests.csproj" />
</Folder>
<Folder Name="/Service/">
<Project Path="Service/Service.Auth.Tests/Service.Auth.Tests.csproj" />

View File

@@ -18,7 +18,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,6 +1,15 @@
using System.Security.Claims;
namespace Infrastructure.Jwt;
public interface ITokenInfrastructure
{
string GenerateJwt(Guid userId, string username, DateTime expiry);
}
string GenerateJwt(
Guid userId,
string username,
DateTime expiry,
string secret
);
Task<ClaimsPrincipal> ValidateJwtAsync(string token, string secret);
}

View File

@@ -16,4 +16,8 @@
Version="8.2.1"
/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
</ItemGroup>
</Project>

View File

@@ -3,28 +3,33 @@ using System.Text;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
using Domain.Exceptions;
namespace Infrastructure.Jwt;
public class JwtInfrastructure : ITokenInfrastructure
{
private readonly string? _secret = Environment.GetEnvironmentVariable(
"JWT_SECRET"
);
public string GenerateJwt(Guid userId, string username, DateTime expiry)
public string GenerateJwt(
Guid userId,
string username,
DateTime expiry,
string secret
)
{
var handler = new JsonWebTokenHandler();
var key = Encoding.UTF8.GetBytes(
_secret ?? throw new InvalidOperationException("secret not set")
);
// Base claims (always present)
var key = Encoding.UTF8.GetBytes(secret);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username),
new(
JwtRegisteredClaimNames.Iat,
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()
),
new(
JwtRegisteredClaimNames.Exp,
new DateTimeOffset(expiry).ToUnixTimeSeconds().ToString()
),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
@@ -40,4 +45,36 @@ public class JwtInfrastructure : ITokenInfrastructure
return handler.CreateToken(tokenDescriptor);
}
}
public async Task<ClaimsPrincipal> ValidateJwtAsync(
string token,
string secret
)
{
var handler = new JsonWebTokenHandler();
var keyBytes = Encoding.UTF8.GetBytes(
secret
);
var parameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
};
try
{
var result = await handler.ValidateTokenAsync(token, parameters);
if (!result.IsValid || result.ClaimsIdentity == null)
throw new UnauthorizedAccessException();
return new ClaimsPrincipal(result.ClaimsIdentity);
}
catch (Exception e)
{
throw new UnauthorizedException("Invalid token");
}
}
}

View File

@@ -1,8 +1,8 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj", "Infrastructure/Infrastructure.Repository.Tests/"]
RUN dotnet restore "Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj"

View File

@@ -2,6 +2,7 @@ using System.Data;
using System.Data.Common;
using Domain.Entities;
using Infrastructure.Repository.Sql;
using Microsoft.Data.SqlClient;
namespace Infrastructure.Repository.Auth;
@@ -107,6 +108,78 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
await command.ExecuteNonQueryAsync();
}
public async Task<Domain.Entities.UserAccount?> GetUserByIdAsync(
Guid userAccountId
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountById";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(
Guid userAccountId
)
{
var user = await GetUserByIdAsync(userAccountId);
if (user == null)
{
return null;
}
// Idempotency: if already verified, treat as successful confirmation.
if (await IsUserVerifiedAsync(userAccountId))
{
return user;
}
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_CreateUserVerification";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountID_", userAccountId);
try
{
await command.ExecuteNonQueryAsync();
}
catch (SqlException ex) when (IsDuplicateVerificationViolation(ex))
{
// A concurrent request verified this user first. Keep behavior idempotent.
}
// Fetch and return the updated user
return await GetUserByIdAsync(userAccountId);
}
private async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText =
"SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID";
command.CommandType = CommandType.Text;
AddParameter(command, "@UserAccountID", userAccountId);
var result = await command.ExecuteScalarAsync();
return result != null && result != DBNull.Value;
}
private static bool IsDuplicateVerificationViolation(SqlException ex)
{
// 2601/2627 are duplicate key violations in SQL Server.
return ex.Number == 2601 || ex.Number == 2627;
}
/// <summary>
/// Maps a data reader row to a UserAccount entity.
/// </summary>

View File

@@ -60,4 +60,19 @@ public interface IAuthRepository
/// <param name="userAccountId">ID of the user account</param>
/// <param name="newPasswordHash">New hashed password</param>
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
/// <summary>
/// Marks a user account as confirmed.
/// </summary>
/// <param name="userAccountId">ID of the user account to confirm</param>
/// <returns>The confirmed UserAccount entity</returns>
/// <exception cref="UnauthorizedException">If user account not found</exception>
Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
/// <summary>
/// Retrieves a user account by ID.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId);
}

View File

@@ -18,6 +18,6 @@
/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,155 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Domain.Entities;
using Domain.Exceptions;
using FluentAssertions;
using Infrastructure.Repository.Auth;
using Moq;
namespace Service.Auth.Tests;
public class ConfirmationServiceTest
{
private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly Mock<ITokenService> _tokenServiceMock;
private readonly ConfirmationService _confirmationService;
public ConfirmationServiceTest()
{
_authRepositoryMock = new Mock<IAuthRepository>();
_tokenServiceMock = new Mock<ITokenService>();
_confirmationService = new ConfirmationService(
_authRepositoryMock.Object,
_tokenServiceMock.Object
);
}
[Fact]
public async Task ConfirmUserAsync_WithValidConfirmationToken_ConfirmsUser()
{
// Arrange
var userId = Guid.NewGuid();
const string username = "testuser";
const string confirmationToken = "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);
var validatedToken = new ValidatedToken(userId, username, principal);
var userAccount = new UserAccount
{
UserAccountId = userId,
Username = username,
FirstName = "Test",
LastName = "User",
Email = "test@example.com",
DateOfBirth = new DateTime(1990, 1, 1),
};
_tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
.ReturnsAsync(validatedToken);
_authRepositoryMock
.Setup(x => x.ConfirmUserAccountAsync(userId))
.ReturnsAsync(userAccount);
// Act
var result =
await _confirmationService.ConfirmUserAsync(confirmationToken);
// Assert
result.Should().NotBeNull();
result.UserId.Should().Be(userId);
result.ConfirmedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
_tokenServiceMock.Verify(
x => x.ValidateConfirmationTokenAsync(confirmationToken),
Times.Once
);
_authRepositoryMock.Verify(
x => x.ConfirmUserAccountAsync(userId),
Times.Once
);
}
[Fact]
public async Task ConfirmUserAsync_WithInvalidConfirmationToken_ThrowsUnauthorizedException()
{
// Arrange
const string invalidToken = "invalid-confirmation-token";
_tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(invalidToken))
.ThrowsAsync(new UnauthorizedException(
"Invalid confirmation token"
));
// Act & Assert
await FluentActions.Invoking(async () =>
await _confirmationService.ConfirmUserAsync(invalidToken)
).Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task ConfirmUserAsync_WithExpiredConfirmationToken_ThrowsUnauthorizedException()
{
// Arrange
const string expiredToken = "expired-confirmation-token";
_tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(expiredToken))
.ThrowsAsync(new UnauthorizedException(
"Confirmation token has expired"
));
// Act & Assert
await FluentActions.Invoking(async () =>
await _confirmationService.ConfirmUserAsync(expiredToken)
).Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task ConfirmUserAsync_WithNonExistentUser_ThrowsUnauthorizedException()
{
// Arrange
var userId = Guid.NewGuid();
const string username = "nonexistent";
const string confirmationToken = "valid-token-for-nonexistent-user";
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 validatedToken = new ValidatedToken(userId, username, principal);
_tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
.ReturnsAsync(validatedToken);
_authRepositoryMock
.Setup(x => x.ConfirmUserAccountAsync(userId))
.ReturnsAsync((UserAccount?)null);
// Act & Assert
await FluentActions.Invoking(async () =>
await _confirmationService.ConfirmUserAsync(confirmationToken)
).Should().ThrowAsync<UnauthorizedException>()
.WithMessage("*User account not found*");
}
}

View File

@@ -1,8 +1,8 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]

View 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*");
}
}

View File

@@ -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>();
}
}

View File

@@ -0,0 +1,34 @@
using Domain.Exceptions;
using Infrastructure.Repository.Auth;
namespace Service.Auth;
public class ConfirmationService(
IAuthRepository authRepository,
ITokenService tokenService
) : IConfirmationService
{
public async Task<ConfirmationServiceReturn> ConfirmUserAsync(
string confirmationToken
)
{
var validatedToken = await tokenService.ValidateConfirmationTokenAsync(
confirmationToken
);
var user = await authRepository.ConfirmUserAccountAsync(
validatedToken.UserId
);
if (user == null)
{
throw new UnauthorizedException("User account not found");
}
return new ConfirmationServiceReturn(
DateTime.UtcNow,
user.UserAccountId
);
}
}

View File

@@ -0,0 +1,11 @@
using Domain.Exceptions;
using Infrastructure.Repository.Auth;
namespace Service.Auth;
public record ConfirmationServiceReturn(DateTime ConfirmedAt, Guid UserId);
public interface IConfirmationService
{
Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
}

View File

@@ -2,6 +2,11 @@ using Domain.Entities;
namespace Service.Auth;
public record LoginServiceReturn(
UserAccount UserAccount,
string RefreshToken,
string AccessToken
);
public interface ILoginService
{
Task<LoginServiceReturn> LoginAsync(string username, string password);

View File

@@ -36,4 +36,4 @@ public interface IRegisterService
UserAccount userAccount,
string password
);
}
}

View File

@@ -1,34 +1,156 @@
using System.Security.Claims;
using System.IdentityModel.Tokens.Jwt;
using Domain.Entities;
using Domain.Exceptions;
using Infrastructure.Jwt;
using Infrastructure.Repository.Auth;
namespace Service.Auth;
public enum TokenType
{
AccessToken,
RefreshToken,
ConfirmationToken,
}
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
public record RefreshTokenResult(
UserAccount UserAccount,
string RefreshToken,
string AccessToken
);
public static class TokenServiceExpirationHours
{
public const double AccessTokenHours = 1;
public const double RefreshTokenHours = 504; // 21 days
public const double ConfirmationTokenHours = 0.5; // 30 minutes
}
public interface ITokenService
{
public string GenerateAccessToken(UserAccount user);
public string GenerateRefreshToken(UserAccount user);
string GenerateAccessToken(UserAccount user);
string GenerateRefreshToken(UserAccount user);
string GenerateConfirmationToken(UserAccount user);
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);
}
public class TokenService(ITokenInfrastructure tokenInfrastructure)
: ITokenService
public class TokenService : ITokenService
{
public string GenerateAccessToken(UserAccount userAccount)
private readonly ITokenInfrastructure _tokenInfrastructure;
private readonly IAuthRepository _authRepository;
private readonly string _accessTokenSecret;
private readonly string _refreshTokenSecret;
private readonly string _confirmationTokenSecret;
public TokenService(
ITokenInfrastructure tokenInfrastructure,
IAuthRepository authRepository
)
{
var jwtExpiresAt = DateTime.UtcNow.AddHours(1);
return tokenInfrastructure.GenerateJwt(
userAccount.UserAccountId,
userAccount.Username,
jwtExpiresAt
);
_tokenInfrastructure = tokenInfrastructure;
_authRepository = authRepository;
_accessTokenSecret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
?? throw new InvalidOperationException("ACCESS_TOKEN_SECRET environment variable is not set");
_refreshTokenSecret = Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
?? 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.AddDays(21);
return tokenInfrastructure.GenerateJwt(
userAccount.UserAccountId,
userAccount.Username,
jwtExpiresAt
);
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _accessTokenSecret);
}
public string GenerateRefreshToken(UserAccount user)
{
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours);
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _refreshTokenSecret);
}
public string GenerateConfirmationToken(UserAccount user)
{
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours);
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _confirmationTokenSecret);
}
public string GenerateToken<T>(UserAccount user) where T : struct, Enum
{
if (typeof(T) != typeof(TokenType))
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);
}
}

View File

@@ -5,11 +5,6 @@ using Infrastructure.Repository.Auth;
namespace Service.Auth;
public record LoginServiceReturn(
UserAccount UserAccount,
string RefreshToken,
string AccessToken
);
public class LoginService(
IAuthRepository authRepo,

View File

@@ -53,6 +53,7 @@ public class RegisterService(
var accessToken = tokenService.GenerateAccessToken(createdUser);
var refreshToken = tokenService.GenerateRefreshToken(createdUser);
var confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
if (
string.IsNullOrEmpty(accessToken)
@@ -67,14 +68,15 @@ public class RegisterService(
{
// send confirmation email
await emailService.SendRegistrationEmailAsync(
createdUser,
"some-confirmation-token"
createdUser, confirmationToken
);
emailSent = true;
}
catch
catch (Exception ex)
{
await Console.Error.WriteLineAsync(ex.Message);
Console.WriteLine("Could not send email.");
// ignored
}
@@ -85,4 +87,4 @@ public class RegisterService(
emailSent
);
}
}
}

View File

@@ -6,13 +6,17 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
<ProjectReference Include="..\Service.Emails\Service.Emails.csproj" />
</ItemGroup>
</Project>

View File

@@ -6,8 +6,10 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
</ItemGroup>
</Project>

View File

@@ -6,7 +6,8 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
</ItemGroup>
</Project>