update features docs

This commit is contained in:
Aaron Po
2026-08-04 06:18:30 -04:00
parent 2ed655e03d
commit 88abb8e995
46 changed files with 65 additions and 671 deletions

View File

@@ -3,8 +3,5 @@ using MediatR;
namespace Features.Auth.Commands.ConfirmUser;
/// <summary>
/// Validates a confirmation token and confirms the corresponding user account.
/// </summary>
/// <param name="Token">The confirmation token issued to the user, typically delivered via email.</param>
/// <summary>Validates a confirmation token and confirms the corresponding user account.</summary>
public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>;

View File

@@ -11,8 +11,6 @@ namespace Features.Auth.Commands.ConfirmUser;
/// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the
/// corresponding user account as confirmed.
/// </summary>
/// <param name="authRepository">Repository used to look up and confirm user accounts.</param>
/// <param name="tokenService">Service used to validate the confirmation token.</param>
public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService tokenService)
: IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
{

View File

@@ -8,7 +8,6 @@ namespace Features.Auth.Commands.RefreshToken;
/// Handles <see cref="RefreshTokenCommand" /> by validating the refresh token and issuing a new
/// access/refresh token pair.
/// </summary>
/// <param name="tokenService">Service used to validate and exchange the refresh token.</param>
public class RefreshTokenHandler(ITokenService tokenService)
: IRequestHandler<RefreshTokenCommand, LoginPayload>
{

View File

@@ -2,14 +2,8 @@ using FluentValidation;
namespace Features.Auth.Commands.RefreshToken;
/// <summary>
/// Validates <see cref="RefreshTokenCommand" /> instances before they are processed.
/// </summary>
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
{
/// <summary>
/// Configures a validation rule requiring <see cref="RefreshTokenCommand.RefreshToken" /> to be non-empty.
/// </summary>
public RefreshTokenValidator()
{
RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("Refresh token is required");

View File

@@ -14,10 +14,6 @@ namespace Features.Auth.Commands.RegisterUser;
/// account, issues access/refresh/confirmation tokens, and attempts to send the registration
/// confirmation email via Features.Emails.
/// </summary>
/// <param name="authRepo">Repository used to check for existing users and persist the new account.</param>
/// <param name="passwordInfrastructure">Infrastructure component used to hash the user's plain-text password.</param>
/// <param name="tokenService">Service used to generate access, refresh, and confirmation tokens.</param>
/// <param name="mediator">Used to send the cross-slice command that triggers the confirmation email.</param>
public class RegisterUserHandler(
IAuthRepository authRepo,
IPasswordInfrastructure passwordInfrastructure,

View File

@@ -2,15 +2,8 @@ using FluentValidation;
namespace Features.Auth.Commands.RegisterUser;
/// <summary>
/// Validates <see cref="RegisterUserCommand" /> instances before they are processed.
/// </summary>
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
{
/// <summary>
/// Configures validation rules for username format and length, first/last name length, email format and
/// length, minimum age based on date of birth, and password strength requirements.
/// </summary>
public RegisterUserValidator()
{
RuleFor(x => x.Username)

View File

@@ -2,8 +2,5 @@ using MediatR;
namespace Features.Auth.Commands.ResendConfirmationEmail;
/// <summary>
/// Resends the account confirmation email to a user, generating a fresh confirmation token.
/// </summary>
/// <param name="UserId">The unique identifier of the user requesting the resend.</param>
/// <summary>Resends the account confirmation email to a user, generating a fresh confirmation token.</summary>
public record ResendConfirmationEmailCommand(Guid UserId) : IRequest;

View File

@@ -10,9 +10,6 @@ namespace Features.Auth.Commands.ResendConfirmationEmail;
/// Handles <see cref="ResendConfirmationEmailCommand" /> by generating a fresh confirmation token and
/// sending it via Features.Emails.
/// </summary>
/// <param name="authRepository">Repository used to look up the user and check verification status.</param>
/// <param name="tokenService">Service used to generate the confirmation token.</param>
/// <param name="mediator">Used to send the cross-slice command that triggers the email.</param>
/// <remarks>
/// Returns silently without sending an email if the user does not exist (to prevent user enumeration)
/// or if the user's account is already verified.

View File

@@ -18,21 +18,11 @@ namespace Features.Auth.Controllers;
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default, but most
/// actions opt out via <c>[AllowAnonymous]</c> since they are entry points used before a caller holds a token.
/// </remarks>
/// <param name="mediator">Used to dispatch auth commands and queries to their handlers.</param>
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")]
public class AuthController(IMediator mediator) : ControllerBase
{
/// <summary>
/// Registers a new user account.
/// </summary>
/// <remarks>
/// Allows anonymous access. On success, responds with <c>201 Created</c> containing the new user's ID,
/// username, issued refresh/access tokens, and whether a confirmation email was sent.
/// </remarks>
/// <param name="command">The registration details, including username, name, email, date of birth, and password.</param>
/// <returns>A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="RegistrationPayload" />.</returns>
[AllowAnonymous]
[HttpPost("register")]
public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register(
@@ -50,15 +40,6 @@ public class AuthController(IMediator mediator) : ControllerBase
);
}
/// <summary>
/// Authenticates a user with a username and password.
/// </summary>
/// <remarks>
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
/// username, and a newly issued refresh/access token pair.
/// </remarks>
/// <param name="query">The login credentials (username and password).</param>
/// <returns>An <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="LoginPayload" />.</returns>
[AllowAnonymous]
[HttpPost("login")]
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query)
@@ -73,15 +54,6 @@ public class AuthController(IMediator mediator) : ControllerBase
);
}
/// <summary>
/// Confirms a user account using a confirmation token.
/// </summary>
/// <remarks>
/// Requires JWT authentication. On success, responds with <c>200 OK</c> containing the confirmed
/// user's ID and the confirmation timestamp.
/// </remarks>
/// <param name="token">The confirmation token supplied via the confirmation email link.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="ConfirmationPayload" />.</returns>
[HttpPost("confirm")]
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm(
[FromQuery] string token
@@ -97,14 +69,6 @@ public class AuthController(IMediator mediator) : ControllerBase
);
}
/// <summary>
/// Resends the account confirmation email for the specified user.
/// </summary>
/// <remarks>
/// Requires JWT authentication. On success, responds with <c>200 OK</c>.
/// </remarks>
/// <param name="userId">The unique identifier of the user account to resend the confirmation email for.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody" /> confirming the email was resent.</returns>
[HttpPost("confirm/resend")]
public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId)
{
@@ -112,15 +76,6 @@ public class AuthController(IMediator mediator) : ControllerBase
return Ok(new ResponseBody { Message = "confirmation email has been resent" });
}
/// <summary>
/// Exchanges a valid refresh token for a new access/refresh token pair.
/// </summary>
/// <remarks>
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
/// username, and the newly issued refresh/access token pair.
/// </remarks>
/// <param name="command">The request containing the refresh token to exchange.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="LoginPayload" />.</returns>
[AllowAnonymous]
[HttpPost("refresh")]
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(

View File

@@ -5,9 +5,6 @@ using Microsoft.Extensions.DependencyInjection;
namespace Features.Auth.DependencyInjection;
/// <summary>
/// Registers the services owned by the Auth feature slice.
/// </summary>
public static class FeaturesAuthServiceCollectionExtensions
{
public static IServiceCollection AddFeaturesAuth(this IServiceCollection services)

View File

@@ -1,12 +1,6 @@
namespace Features.Auth.Dtos;
/// <summary>
/// Payload returned to the client after a successful login or token refresh.
/// </summary>
/// <param name="UserAccountId">The unique identifier of the authenticated user account.</param>
/// <param name="Username">The username of the authenticated user account.</param>
/// <param name="RefreshToken">The newly issued refresh token used to obtain new access tokens.</param>
/// <param name="AccessToken">The newly issued JWT access token used to authorize subsequent requests.</param>
/// <summary>Payload returned to the client after a successful login or token refresh.</summary>
public record LoginPayload(
Guid UserAccountId,
string Username,
@@ -14,14 +8,7 @@ public record LoginPayload(
string AccessToken
);
/// <summary>
/// Payload returned to the client after a successful registration.
/// </summary>
/// <param name="UserAccountId">The unique identifier of the newly created user account.</param>
/// <param name="Username">The username of the newly created user account.</param>
/// <param name="RefreshToken">The refresh token issued for the new account.</param>
/// <param name="AccessToken">The JWT access token issued for the new account.</param>
/// <param name="ConfirmationEmailSent">Whether a confirmation email was successfully sent to the new user.</param>
/// <summary>Payload returned to the client after a successful registration.</summary>
public record RegistrationPayload(
Guid UserAccountId,
string Username,
@@ -30,9 +17,5 @@ public record RegistrationPayload(
bool ConfirmationEmailSent
);
/// <summary>
/// Payload returned to the client after a user account's email has been confirmed.
/// </summary>
/// <param name="UserAccountId">The unique identifier of the user account that was confirmed.</param>
/// <param name="ConfirmedDate">The date and time at which the account was confirmed.</param>
/// <summary>Payload returned to the client after a user account's email has been confirmed.</summary>
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);

View File

@@ -11,12 +11,6 @@ namespace Features.Auth.Queries.Login;
/// <summary>
/// Handles <see cref="LoginQuery" /> by verifying credentials and issuing access/refresh tokens.
/// </summary>
/// <param name="authRepo">Repository used to look up the user account and its active credential.</param>
/// <param name="passwordInfrastructure">
/// Infrastructure component used to verify a plain-text password against a stored
/// hash.
/// </param>
/// <param name="tokenService">Service used to generate access and refresh tokens for the authenticated user.</param>
public class LoginHandler(
IAuthRepository authRepo,
IPasswordInfrastructure passwordInfrastructure,

View File

@@ -2,15 +2,8 @@ using FluentValidation;
namespace Features.Auth.Queries.Login;
/// <summary>
/// Validates <see cref="LoginQuery" /> instances before they are processed.
/// </summary>
public class LoginValidator : AbstractValidator<LoginQuery>
{
/// <summary>
/// Configures validation rules requiring both <see cref="LoginQuery.Username" /> and
/// <see cref="LoginQuery.Password" /> to be non-empty.
/// </summary>
public LoginValidator()
{
RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required");

View File

@@ -11,7 +11,6 @@ namespace Features.Auth.Repository;
/// ADO.NET-based implementation of <see cref="IAuthRepository" /> backed by SQL Server stored procedures,
/// handling user registration, credential lookup/rotation, and account verification.
/// </summary>
/// <param name="connectionFactory">The factory used to create database connections.</param>
public class AuthRepository(ISqlConnectionFactory connectionFactory)
: Repository<UserAccount>(connectionFactory),
IAuthRepository
@@ -26,15 +25,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// If the result cannot be interpreted, <see cref="Guid.Empty" /> is used, which will cause the
/// subsequent lookup to fail.
/// </remarks>
/// <param name="username">Unique username for the user</param>
/// <param name="firstName">User's first name</param>
/// <param name="lastName">User's last name</param>
/// <param name="email">User's email address</param>
/// <param name="dateOfBirth">User's date of birth</param>
/// <param name="passwordHash">Hashed password</param>
/// <returns>The newly created UserAccount with generated ID</returns>
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<UserAccount> RegisterUserAsync(
UserRegistrationDto userRegistrationDto
)
@@ -84,13 +75,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
?? throw new Exception("Failed to retrieve newly registered user.");
}
/// <summary>
/// Retrieves a user account by email address (typically used for login) using the
/// <c>usp_GetUserAccountByEmail</c> stored procedure.
/// </summary>
/// <param name="email">Email address to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
/// <summary>Retrieves a user account by email using the <c>usp_GetUserAccountByEmail</c> stored procedure.</summary>
public async Task<UserAccount?> GetUserByEmailAsync(string email)
{
await using DbConnection connection = await CreateConnection();
@@ -104,13 +89,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
/// <summary>
/// Retrieves a user account by username (typically used for login) using the
/// <c>usp_GetUserAccountByUsername</c> stored procedure.
/// </summary>
/// <param name="username">Username to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
/// <summary>Retrieves a user account by username using the <c>usp_GetUserAccountByUsername</c> stored procedure.</summary>
public async Task<UserAccount?> GetUserByUsernameAsync(string username)
{
await using DbConnection connection = await CreateConnection();
@@ -128,9 +107,6 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// Retrieves the active (non-revoked) credential for a user account using the
/// <c>USP_GetActiveUserCredentialByUserAccountId</c> stored procedure.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>Active UserCredential if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
{
await using DbConnection connection = await CreateConnection();
@@ -148,9 +124,6 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// Rotates a user's credential by invalidating all existing credentials and creating a new one,
/// using the <c>USP_RotateUserCredential</c> stored procedure.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <param name="newPasswordHash">New hashed password</param>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash)
{
await using DbConnection connection = await CreateConnection();
@@ -164,12 +137,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
/// <summary>Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure.</summary>
public async Task<UserAccount?> GetUserByIdAsync(Guid userAccountId)
{
await using DbConnection connection = await CreateConnection();
@@ -189,8 +157,6 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// no-op and the existing user is returned (idempotent). If a concurrent request verifies the user
/// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed.
/// </summary>
/// <param name="userAccountId">ID of the user account to confirm</param>
/// <returns>The confirmed <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if the user account does not exist.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">
/// Thrown when the database command fails for a reason other than
/// a duplicate verification record.
@@ -225,13 +191,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return await GetUserByIdAsync(userAccountId);
}
/// <summary>
/// Checks whether a user account has been verified by querying the
/// <c>dbo.UserVerification</c> table for a matching record.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>True if the user has a verification record, false otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
/// <summary>Checks whether a user account has been verified by querying the <c>dbo.UserVerification</c> table.</summary>
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
{
await using DbConnection connection = await CreateConnection();
@@ -246,23 +206,13 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return result != null && result != DBNull.Value;
}
/// <summary>
/// Determines whether a <see cref="SqlException" /> represents a duplicate key violation
/// (SQL Server error 2601 or 2627), used to detect a concurrent duplicate verification insert.
/// </summary>
/// <param name="ex">The SQL exception to inspect.</param>
/// <returns>True if the exception represents a duplicate key violation, false otherwise.</returns>
/// <summary>Determines whether a <see cref="SqlException" /> is a duplicate key violation (SQL Server error 2601 or 2627).</summary>
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>
/// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped <see cref="Domain.Entities.UserAccount" /> instance.</returns>
protected override UserAccount MapToEntity(DbDataReader reader)
{
return new UserAccount
@@ -285,8 +235,6 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
/// Maps a data reader row to a UserCredential entity. The <c>Timer</c> column is mapped only if
/// present in the reader's schema, allowing this method to support result sets that omit it.
/// </summary>
/// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped <see cref="UserCredential" /> instance.</returns>
private static UserCredential MapToCredentialEntity(DbDataReader reader)
{
UserCredential entity = new()
@@ -319,13 +267,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return entity;
}
/// <summary>
/// Helper method to add a parameter to a database command, converting <c>null</c> values to
/// <see cref="DBNull.Value" />.
/// </summary>
/// <param name="command">The command to add the parameter to.</param>
/// <param name="name">The parameter name (including any prefix, e.g. "@Username").</param>
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value" />.</param>
/// <summary>Adds a parameter to a database command, converting <c>null</c> values to <see cref="DBNull.Value" />.</summary>
private static void AddParameter(DbCommand command, string name, object? value)
{
DbParameter p = command.CreateParameter();

View File

@@ -7,64 +7,23 @@ namespace Features.Auth.Repository;
/// </summary>
public interface IAuthRepository
{
/// <summary>
/// Registers a new user with account details and initial credential.
/// Uses stored procedure: USP_RegisterUser
/// </summary>
Task<UserAccount> RegisterUserAsync(
UserRegistrationDto userRegistrationDto
);
/// <summary>
/// Retrieves a user account by email address (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByEmail
/// </summary>
/// <param name="email">Email address to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<UserAccount?> GetUserByEmailAsync(string email);
/// <summary>
/// Retrieves a user account by username (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByUsername
/// </summary>
/// <param name="username">Username to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<UserAccount?> GetUserByUsernameAsync(string username);
/// <summary>
/// Retrieves the active (non-revoked) credential for a user account.
/// Uses stored procedure: USP_GetActiveUserCredentialByUserAccountId
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>Active UserCredential if found, null otherwise</returns>
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
/// <summary>
/// Rotates a user's credential by invalidating all existing credentials and creating a new one.
/// Uses stored procedure: USP_RotateUserCredential
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <param name="newPasswordHash">New hashed password</param>
/// <summary>Rotates a user's credential by invalidating all existing credentials and creating a new one.</summary>
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, or null if the user account does not exist</returns>
/// <summary>Marks a user account as confirmed. Idempotent if the account is already verified.</summary>
Task<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<UserAccount?> GetUserByIdAsync(Guid userAccountId);
/// <summary>
/// Checks whether a user account has been verified.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>True if the user has a verification record, false otherwise</returns>
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
}

View File

@@ -18,20 +18,10 @@ public enum TokenType
ConfirmationToken,
}
/// <summary>
/// Represents the result of successfully validating a token.
/// </summary>
/// <param name="UserId">The unique identifier of the user the token belongs to.</param>
/// <param name="Username">The username extracted from the token's claims.</param>
/// <param name="Principal">The <see cref="ClaimsPrincipal" /> produced from the validated token.</param>
/// <summary>Represents the result of successfully validating a token.</summary>
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
/// <summary>
/// Represents the result of refreshing a user's session.
/// </summary>
/// <param name="UserAccount">The user account associated with the refreshed session.</param>
/// <param name="RefreshToken">The newly issued refresh token.</param>
/// <param name="AccessToken">The newly issued access token.</param>
/// <summary>Represents the result of refreshing a user's session.</summary>
public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, string AccessToken);
/// <summary>
@@ -39,14 +29,13 @@ public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, s
/// </summary>
public static class TokenServiceExpirationHours
{
/// <summary>The expiration window, in hours, for access tokens.</summary>
public const double AccessTokenHours = 1;
/// <summary>The expiration window, in hours, for refresh tokens (21 days).</summary>
public const double RefreshTokenHours = 504; // 21 days
/// <summary>21 days.</summary>
public const double RefreshTokenHours = 504;
/// <summary>The expiration window, in hours, for confirmation tokens (30 minutes).</summary>
public const double ConfirmationTokenHours = 0.5; // 30 minutes
/// <summary>30 minutes.</summary>
public const double ConfirmationTokenHours = 0.5;
}
/// <summary>
@@ -54,61 +43,23 @@ public static class TokenServiceExpirationHours
/// </summary>
public interface ITokenService
{
/// <summary>
/// Generates a new access token for the given user.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed access token string.</returns>
string GenerateAccessToken(UserAccount user);
/// <summary>
/// Generates a new refresh token for the given user.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed refresh token string.</returns>
string GenerateRefreshToken(UserAccount user);
/// <summary>
/// Generates a new confirmation token for the given user.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed confirmation token string.</returns>
string GenerateConfirmationToken(UserAccount user);
/// <summary>
/// Generates a token of the type specified by <typeparamref name="T" />, which must be <see cref="TokenType" />.
/// </summary>
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType" />.</typeparam>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed token string corresponding to the requested token type.</returns>
/// <summary>Generates a token of the type specified by <typeparamref name="T" />.</summary>
/// <typeparam name="T">Must be <see cref="TokenType" />.</typeparam>
string GenerateToken<T>(UserAccount user)
where T : struct, Enum;
/// <summary>
/// Validates an access token.
/// </summary>
/// <param name="token">The access token string to validate.</param>
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
Task<ValidatedToken> ValidateAccessTokenAsync(string token);
/// <summary>
/// Validates a refresh token.
/// </summary>
/// <param name="token">The refresh token string to validate.</param>
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
/// <summary>
/// Validates a confirmation token.
/// </summary>
/// <param name="token">The confirmation token string to validate.</param>
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
/// <summary>
/// Validates a refresh token and issues a new access/refresh token pair for the associated user.
/// </summary>
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
/// <returns>A <see cref="RefreshTokenResult" /> containing the user and newly issued tokens.</returns>
/// <summary>Validates a refresh token and issues a new access/refresh token pair for the associated user.</summary>
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
}

View File

@@ -19,12 +19,7 @@ public class TokenService : ITokenService
private readonly string _refreshTokenSecret;
private readonly ITokenInfrastructure _tokenInfrastructure;
/// <summary>
/// Initializes a new instance of <see cref="TokenService" />, loading the access, refresh, and
/// confirmation token signing secrets from environment variables.
/// </summary>
/// <param name="tokenInfrastructure">The infrastructure component used to generate and validate JWTs.</param>
/// <param name="authRepository">Repository used to look up user accounts during token refresh.</param>
/// <summary>Initializes a new instance of the <see cref="TokenService" /> class.</summary>
/// <exception cref="InvalidOperationException">
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or
/// <c>CONFIRMATION_TOKEN_SECRET</c> environment variables are not set.
@@ -53,12 +48,6 @@ public class TokenService : ITokenService
);
}
/// <summary>
/// Generates an access token for the given user, signed with the access token secret and
/// expiring after <see cref="TokenServiceExpirationHours.AccessTokenHours" /> hours.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed access token string.</returns>
public string GenerateAccessToken(UserAccount user)
{
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
@@ -70,12 +59,6 @@ public class TokenService : ITokenService
);
}
/// <summary>
/// Generates a refresh token for the given user, signed with the refresh token secret and
/// expiring after <see cref="TokenServiceExpirationHours.RefreshTokenHours" /> hours.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed refresh token string.</returns>
public string GenerateRefreshToken(UserAccount user)
{
DateTime expiresAt = DateTime.UtcNow.AddHours(
@@ -89,12 +72,6 @@ public class TokenService : ITokenService
);
}
/// <summary>
/// Generates a confirmation token for the given user, signed with the confirmation token secret and
/// expiring after <see cref="TokenServiceExpirationHours.ConfirmationTokenHours" /> hours.
/// </summary>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed confirmation token string.</returns>
public string GenerateConfirmationToken(UserAccount user)
{
DateTime expiresAt = DateTime.UtcNow.AddHours(
@@ -111,11 +88,9 @@ public class TokenService : ITokenService
/// <summary>
/// Generates a token of the kind specified by <typeparamref name="T" />, dispatching to
/// <see cref="GenerateAccessToken" />, <see cref="GenerateRefreshToken" />, or
/// <see cref="GenerateConfirmationToken" /> based on the corresponding <see cref="TokenType" /> value.
/// <see cref="GenerateConfirmationToken" />.
/// </summary>
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType" />.</typeparam>
/// <param name="user">The user account to generate the token for.</param>
/// <returns>The signed token string corresponding to the requested token type.</returns>
/// <typeparam name="T">Must be <see cref="TokenType" />.</typeparam>
/// <exception cref="InvalidOperationException">
/// Thrown when <typeparamref name="T" /> is not <see cref="TokenType" /> or does not resolve to a known token type.
/// </exception>
@@ -139,11 +114,6 @@ public class TokenService : ITokenService
};
}
/// <summary>
/// Validates an access token against the access token secret.
/// </summary>
/// <param name="token">The access token string to validate.</param>
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception>
@@ -152,11 +122,6 @@ public class TokenService : ITokenService
return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
}
/// <summary>
/// Validates a refresh token against the refresh token secret.
/// </summary>
/// <param name="token">The refresh token string to validate.</param>
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception>
@@ -165,11 +130,6 @@ public class TokenService : ITokenService
return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
}
/// <summary>
/// Validates a confirmation token against the confirmation token secret.
/// </summary>
/// <param name="token">The confirmation token string to validate.</param>
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
/// </exception>
@@ -178,12 +138,6 @@ public class TokenService : ITokenService
return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
}
/// <summary>
/// Validates the given refresh token, looks up the associated user, and issues a fresh
/// access/refresh token pair.
/// </summary>
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
/// <returns>A <see cref="RefreshTokenResult" /> containing the user and newly issued tokens.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists.
/// </exception>
@@ -204,10 +158,7 @@ public class TokenService : ITokenService
/// Performs the shared validation logic for access, refresh, and confirmation tokens:
/// validates the JWT signature/expiration, then extracts and parses the user ID and username claims.
/// </summary>
/// <param name="token">The token string to validate.</param>
/// <param name="secret">The secret key to validate the token's signature against.</param>
/// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param>
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
/// <exception cref="Domain.Exceptions.UnauthorizedException">
/// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid" />,
/// or the underlying token validation fails for any other reason (e.g. expired or invalid signature).