UDT-002: Logout + Refresh Token con rotación y chain revocation #3

Merged
dmolinari merged 36 commits from feature/UDT-002 into main 2026-04-14 17:37:47 +00:00
47 changed files with 2359 additions and 41 deletions

View File

@@ -0,0 +1,63 @@
-- V002__create_refresh_token.sql
-- Creates dbo.RefreshToken table for opaque token rotation with chain revocation
-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests)
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
GO
IF OBJECT_ID(N'dbo.RefreshToken', N'U') IS NOT NULL
BEGIN
PRINT 'Table dbo.RefreshToken already exists — skipping.';
RETURN;
END
GO
CREATE TABLE dbo.RefreshToken
(
Id INT IDENTITY(1,1) NOT NULL,
UsuarioId INT NOT NULL,
TokenHash NVARCHAR(88) NOT NULL, -- SHA-256 base64url = 43 chars sin padding; margen a 88
FamilyId UNIQUEIDENTIFIER NOT NULL, -- una familia = una sesion de login
IssuedAt DATETIME2(3) NOT NULL,
ExpiresAt DATETIME2(3) NOT NULL, -- absolute: heredado en cada rotacion
RevokedAt DATETIME2(3) NULL,
ReplacedById INT NULL,
CreatedByIp VARCHAR(45) NOT NULL, -- IPv4/IPv6 textual
UserAgent NVARCHAR(512) NULL,
CONSTRAINT PK_RefreshToken PRIMARY KEY CLUSTERED (Id),
CONSTRAINT FK_RefreshToken_Usuario
FOREIGN KEY (UsuarioId) REFERENCES dbo.Usuario(Id),
CONSTRAINT FK_RefreshToken_ReplacedBy
FOREIGN KEY (ReplacedById) REFERENCES dbo.RefreshToken(Id),
CONSTRAINT UQ_RefreshToken_TokenHash UNIQUE (TokenHash)
);
GO
-- Lookup por familia para chain revocation
CREATE INDEX IX_RefreshToken_UsuarioId_FamilyId
ON dbo.RefreshToken (UsuarioId, FamilyId);
GO
-- Indice filtrado para revocaciones masivas de activos
CREATE INDEX IX_RefreshToken_Active
ON dbo.RefreshToken (UsuarioId, FamilyId)
WHERE RevokedAt IS NULL;
GO
-- Housekeeping futuro
CREATE INDEX IX_RefreshToken_ExpiresAt
ON dbo.RefreshToken (ExpiresAt)
WHERE RevokedAt IS NULL;
GO
EXEC sys.sp_addextendedproperty
@name = N'MS_Description',
@value = N'Refresh tokens opacos (SHA-256 hash) con rotacion y chain revocation por familia',
@level0type = N'SCHEMA', @level0name = N'dbo',
@level1type = N'TABLE', @level1name = N'RefreshToken';
GO
PRINT 'Table dbo.RefreshToken created successfully.';
GO

108
docs/smoke-test-udt-002.md Normal file
View File

@@ -0,0 +1,108 @@
# Smoke Test — UDT-002: Logout + Refresh Token
**Branch**: feature/UDT-002
**Fecha**: 2026-04-14
**Prerequisito**: backend corriendo en `http://localhost:5212`, BD `SIGCM2` con migración V002 aplicada.
---
## Escenario 1 — Login y persistencia de tokens
- [ ] Abrir la app en `http://localhost:5173`
- [ ] Ingresar con credenciales válidas (admin / password)
- [ ] Verificar que el login redirige al home
- [ ] Abrir DevTools → Application → Local Storage → `auth-storage`
- [ ] Confirmar que el objeto contiene: `accessToken`, `refreshToken`, `expiresAt`, `user`
- [ ] Verificar que `expiresAt` es aproximadamente `Date.now() + 3600000` (1 hora)
---
## Escenario 2 — Refresh transparente en 401
**Opción A (esperar expiración natural — requiere token con TTL corto):**
- [ ] Modificar `Jwt:AccessTokenMinutes` a `1` en `appsettings.Development.json` y reiniciar el backend
- [ ] Hacer login
- [ ] Esperar 1 minuto para que el access token expire
- [ ] Realizar cualquier request autenticado (ej: navegar a una sección que llame a la API)
- [ ] Verificar que el request se completa sin error visible para el usuario
- [ ] Verificar en DevTools → Network que hubo una llamada a `POST /api/v1/auth/refresh` seguida del request original reenviado con un nuevo Bearer
**Opción B (manipulación manual del token):**
- [ ] Después del login, abrir DevTools → Application → Local Storage → `auth-storage`
- [ ] Editar el JSON y reemplazar `accessToken` con un valor inválido (ej: `"expired"`)
- [ ] Realizar cualquier request autenticado
- [ ] El interceptor de axiosClient recibe 401, llama a `/refresh` con el `refreshToken` real
- [ ] El request original se reintenta automáticamente con el nuevo `accessToken`
- [ ] El usuario no ve ningún error
---
## Escenario 3 — Refresh de 3 requests paralelos (singleton promise)
- [ ] Con el access token vencido (opción B del escenario 2)
- [ ] Abrir una página que dispare múltiples llamadas API simultáneas
- [ ] Verificar en DevTools → Network que hay exactamente **1** llamada a `POST /api/v1/auth/refresh`
- [ ] Verificar que todos los requests subsiguientes retornan con éxito
---
## Escenario 4 — Logout
- [ ] Con sesión activa, hacer click en el botón de logout
- [ ] Verificar que redirige a `/login`
- [ ] Verificar en DevTools → Network que se llamó a `POST /api/v1/auth/logout`
- [ ] Verificar en Local Storage que `auth-storage` tiene `user: null`, `accessToken: null`, `refreshToken: null`
- [ ] Intentar navegar a una ruta protegida — debería redirigir a login
---
## Escenario 5 — Reuso de refresh token después del logout (reuse detection)
- [ ] Hacer login y copiar el valor de `refreshToken` del Local Storage
- [ ] Hacer logout
- [ ] Intentar llamar manualmente al endpoint de refresh con el token anterior:
```bash
curl -X POST http://localhost:5212/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d '{"accessToken": "<access-anterior>", "refreshToken": "<refresh-anterior>"}'
```
- [ ] Verificar que el backend responde `401` con `{ "error": "invalid_token" }`
- [ ] Verificar en la BD que todos los tokens de la familia fueron revocados:
```sql
SELECT * FROM dbo.RefreshToken WHERE RevokedAt IS NOT NULL ORDER BY Id DESC;
```
---
## Escenario 6 — Refresh token expirado (7 días)
- [ ] Modificar `ExpiresAt` de un token en la BD `SIGCM2_Test` a una fecha pasada
- [ ] Intentar refresh con ese token — debería responder `401`
- [ ] Verificar que el frontend redirige a `/login` y limpia el Local Storage
---
## Escenario 7 — Refresh con access token de otro usuario (mismatch)
- [ ] Crear dos usuarios en la BD (o usar admin + otro)
- [ ] Hacer login con usuario A, guardar el `accessToken`
- [ ] Hacer login con usuario B, guardar el `refreshToken`
- [ ] Intentar refresh con accessToken de A + refreshToken de B:
```bash
curl -X POST http://localhost:5212/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d '{"accessToken": "<access-usuario-A>", "refreshToken": "<refresh-usuario-B>"}'
```
- [ ] Verificar que el backend responde `401`
---
## Notas de verificación
| Check | Comando |
|-------|---------|
| Tokens en BD | `SELECT Id, UsuarioId, FamilyId, IssuedAt, ExpiresAt, RevokedAt FROM dbo.RefreshToken ORDER BY Id DESC` |
| Familias revocadas | `SELECT FamilyId, COUNT(*) as Total, SUM(CASE WHEN RevokedAt IS NOT NULL THEN 1 ELSE 0 END) as Revoked FROM dbo.RefreshToken GROUP BY FamilyId` |
| Usuario activo | `SELECT Id, Username, Activo FROM dbo.Usuario` |

View File

@@ -1,7 +1,11 @@
using System.IdentityModel.Tokens.Jwt;
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Auth.Login;
using SIGCM2.Application.Auth.Logout;
using SIGCM2.Application.Auth.Refresh;
namespace SIGCM2.Api.Controllers;
@@ -10,19 +14,22 @@ namespace SIGCM2.Api.Controllers;
public sealed class AuthController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<LoginCommand> _validator;
private readonly IValidator<LoginCommand> _loginValidator;
private readonly IValidator<RefreshCommand> _refreshValidator;
public AuthController(IDispatcher dispatcher, IValidator<LoginCommand> validator)
public AuthController(
IDispatcher dispatcher,
IValidator<LoginCommand> loginValidator,
IValidator<RefreshCommand> refreshValidator)
{
_dispatcher = dispatcher;
_validator = validator;
_loginValidator = loginValidator;
_refreshValidator = refreshValidator;
}
/// <summary>Authenticates a user and returns a JWT access token.</summary>
/// <response code="200">Returns access token and refresh token.</response>
/// <response code="400">Validation error — missing or empty fields.</response>
/// <response code="401">Invalid credentials.</response>
/// <summary>Authenticates a user and returns a JWT access token + refresh token.</summary>
[HttpPost("login")]
[AllowAnonymous]
[ProducesResponseType(typeof(LoginResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
@@ -30,7 +37,7 @@ public sealed class AuthController : ControllerBase
{
var command = new LoginCommand(request.Username ?? string.Empty, request.Password ?? string.Empty);
var validation = await _validator.ValidateAsync(command);
var validation = await _loginValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
@@ -42,7 +49,56 @@ public sealed class AuthController : ControllerBase
var result = await _dispatcher.Send<LoginCommand, LoginResponseDto>(command);
return Ok(result);
}
/// <summary>
/// Rotates a refresh token pair. Accepts an expired access token to extract the user identity.
/// Returns a new access + refresh token pair. Does NOT require Authorization header.
/// </summary>
[HttpPost("refresh")]
[AllowAnonymous]
[ProducesResponseType(typeof(RefreshResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
{
var command = new RefreshCommand(
request.AccessToken ?? string.Empty,
request.RefreshToken ?? string.Empty);
var validation = await _refreshValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<RefreshCommand, RefreshResponseDto>(command);
return Ok(result);
}
/// <summary>
/// Revokes all active refresh tokens for the authenticated user.
/// Requires a valid Bearer access token. Client must discard local tokens after this call.
/// </summary>
[HttpPost("logout")]
[Authorize]
[ProducesResponseType(typeof(LogoutResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Logout()
{
var sub = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
if (!int.TryParse(sub, out var userId))
return Unauthorized();
var result = await _dispatcher.Send<LogoutCommand, LogoutResponseDto>(new LogoutCommand(userId));
return Ok(result);
}
}
/// <summary>Login request body — nullable to catch missing field scenarios.</summary>
public sealed record LoginRequest(string? Username, string? Password);
/// <summary>Refresh request body.</summary>
public sealed record RefreshRequest(string? AccessToken, string? RefreshToken);

View File

@@ -26,6 +26,25 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true;
break;
case TokenReuseDetectedException reuseEx:
// Log with detail on the backend but return generic 401 to client
_logger.LogWarning("Token reuse detected — possible session compromise: {Message}", reuseEx.Message);
context.Result = new ObjectResult(new { error = "Token inválido" })
{
StatusCode = StatusCodes.Status401Unauthorized
};
context.ExceptionHandled = true;
break;
case InvalidRefreshTokenException:
// Generic 401 — do NOT reveal if token was expired, not found, or mismatched
context.Result = new ObjectResult(new { error = "Token inválido" })
{
StatusCode = StatusCodes.Status401Unauthorized
};
context.ExceptionHandled = true;
break;
case ValidationException validationEx:
var errors = validationEx.Errors
.GroupBy(e => e.PropertyName)

View File

@@ -6,6 +6,7 @@
"Issuer": "sigcm2.api",
"Audience": "sigcm2.web",
"AccessTokenMinutes": 60,
"RefreshTokenDays": 7,
"PrivateKeyPath": "keys/private.pem",
"PublicKeyPath": "keys/public.pem",
"PrivateKey": null,

View File

@@ -0,0 +1,12 @@
namespace SIGCM2.Application.Abstractions;
/// <summary>
/// Provides HTTP client metadata (IP address and User-Agent) from the current request context.
/// Implemented in Infrastructure via IHttpContextAccessor.
/// Mockable in tests without HTTP stack.
/// </summary>
public interface IClientContext
{
string Ip { get; }
string? UserAgent { get; }
}

View File

@@ -0,0 +1,33 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
public interface IRefreshTokenRepository
{
/// <summary>
/// Finds a refresh token record by its SHA-256 hash.
/// Returns the record even if it is revoked or expired — callers decide what to do.
/// Returns null if no record matches the hash.
/// </summary>
Task<RefreshToken?> GetByHashAsync(string tokenHash, CancellationToken ct = default);
/// <summary>Persists a new refresh token and returns its generated Id.</summary>
Task<int> AddAsync(RefreshToken token, CancellationToken ct = default);
/// <summary>Marks a single token as revoked and optionally records its successor.</summary>
Task RevokeAsync(int id, int? replacedById, DateTime revokedAt, CancellationToken ct = default);
/// <summary>
/// Revokes all active (RevokedAt IS NULL) tokens in a family.
/// Used for chain revocation on reuse detection.
/// Returns the count of rows affected.
/// </summary>
Task<int> RevokeFamilyAsync(Guid familyId, DateTime revokedAt, CancellationToken ct = default);
/// <summary>
/// Revokes all active tokens for a user across all families.
/// Used for logout.
/// Returns the count of rows affected.
/// </summary>
Task<int> RevokeAllActiveForUserAsync(int usuarioId, DateTime revokedAt, CancellationToken ct = default);
}

View File

@@ -5,4 +5,5 @@ namespace SIGCM2.Application.Abstractions.Persistence;
public interface IUsuarioRepository
{
Task<Usuario?> GetByUsernameAsync(string username);
Task<Usuario?> GetByIdAsync(int id, CancellationToken ct = default);
}

View File

@@ -1,3 +1,4 @@
using System.Security.Claims;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Security;
@@ -5,4 +6,11 @@ namespace SIGCM2.Application.Abstractions.Security;
public interface IJwtService
{
string GenerateAccessToken(Usuario usuario);
/// <summary>
/// Validates an access token's signature and claims WITHOUT checking expiry.
/// Used by the refresh flow to extract the UsuarioId from an expired access token.
/// Throws SecurityTokenException (or derived) if the signature is invalid or the algorithm is wrong.
/// </summary>
ClaimsPrincipal GetPrincipalFromExpiredToken(string accessToken);
}

View File

@@ -0,0 +1,10 @@
namespace SIGCM2.Application.Abstractions.Security;
public interface IRefreshTokenGenerator
{
/// <summary>
/// Generates a cryptographically secure opaque raw token (256 bits, base64url without padding).
/// This is the value sent to the client. It is NEVER stored — only its SHA-256 hash is persisted.
/// </summary>
string Generate();
}

View File

@@ -0,0 +1,12 @@
namespace SIGCM2.Application.Auth;
/// <summary>
/// Configuration values for authentication token generation.
/// Populated from the "Jwt" configuration section via IOptions in the Infrastructure layer.
/// Lives in Application to avoid circular dependency with Infrastructure.
/// </summary>
public sealed class AuthOptions
{
public int AccessTokenMinutes { get; set; } = 60;
public int RefreshTokenDays { get; set; } = 7;
}

View File

@@ -2,7 +2,9 @@ using System.Text.Json;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
using SIGCM2.Domain.Security;
namespace SIGCM2.Application.Auth.Login;
@@ -11,15 +13,27 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
private readonly IUsuarioRepository _repository;
private readonly IPasswordHasher _hasher;
private readonly IJwtService _jwtService;
private readonly IRefreshTokenRepository _refreshRepository;
private readonly IRefreshTokenGenerator _refreshGenerator;
private readonly IClientContext _clientContext;
private readonly AuthOptions _authOptions;
public LoginCommandHandler(
IUsuarioRepository repository,
IPasswordHasher hasher,
IJwtService jwtService)
IJwtService jwtService,
IRefreshTokenRepository refreshRepository,
IRefreshTokenGenerator refreshGenerator,
IClientContext clientContext,
AuthOptions authOptions)
{
_repository = repository;
_hasher = hasher;
_jwtService = jwtService;
_refreshRepository = refreshRepository;
_refreshGenerator = refreshGenerator;
_clientContext = clientContext;
_authOptions = authOptions;
}
public async Task<LoginResponseDto> Handle(LoginCommand command)
@@ -34,15 +48,24 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
throw new InvalidCredentialsException();
var accessToken = _jwtService.GenerateAccessToken(usuario);
var refreshToken = Guid.NewGuid().ToString("N"); // opaque, not persisted in UDT-001
// Generate and persist refresh token — only the hash hits the DB
var rawRefresh = _refreshGenerator.Generate();
var hash = TokenHasher.Sha256Base64Url(rawRefresh);
var now = DateTime.UtcNow;
var ttl = TimeSpan.FromDays(_authOptions.RefreshTokenDays);
var entity = RefreshToken.IssueForNewFamily(
usuario.Id, hash, now, ttl,
_clientContext.Ip, _clientContext.UserAgent);
await _refreshRepository.AddAsync(entity);
var permisos = JsonSerializer.Deserialize<string[]>(usuario.PermisosJson)
?? Array.Empty<string>();
return new LoginResponseDto(
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: 3600,
RefreshToken: rawRefresh, // raw to client — never stored
ExpiresIn: _authOptions.AccessTokenMinutes * 60,
Usuario: new UsuarioDto(
Id: usuario.Id,
Nombre: $"{usuario.Nombre} {usuario.Apellido}".Trim(),

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Auth.Logout;
public sealed record LogoutCommand(int UsuarioId);

View File

@@ -0,0 +1,22 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
namespace SIGCM2.Application.Auth.Logout;
public sealed class LogoutCommandHandler : ICommandHandler<LogoutCommand, LogoutResponseDto>
{
private readonly IRefreshTokenRepository _refreshRepo;
public LogoutCommandHandler(IRefreshTokenRepository refreshRepo)
{
_refreshRepo = refreshRepo;
}
public async Task<LogoutResponseDto> Handle(LogoutCommand command)
{
// Revoke all active tokens for the user across all families.
// Idempotent: 0 rows affected is not an error.
await _refreshRepo.RevokeAllActiveForUserAsync(command.UsuarioId, DateTime.UtcNow);
return new LogoutResponseDto(true, "Sesión cerrada correctamente");
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Auth.Logout;
public sealed record LogoutResponseDto(bool Success, string Mensaje);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Auth.Refresh;
public sealed record RefreshCommand(string AccessToken, string RefreshToken);

View File

@@ -0,0 +1,95 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
using SIGCM2.Domain.Security;
namespace SIGCM2.Application.Auth.Refresh;
public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, RefreshResponseDto>
{
private readonly IRefreshTokenRepository _refreshRepo;
private readonly IUsuarioRepository _usuarioRepo;
private readonly IJwtService _jwt;
private readonly IRefreshTokenGenerator _refreshGenerator;
private readonly IClientContext _clientCtx;
private readonly AuthOptions _authOptions;
public RefreshCommandHandler(
IRefreshTokenRepository refreshRepo,
IUsuarioRepository usuarioRepo,
IJwtService jwt,
IRefreshTokenGenerator refreshGenerator,
IClientContext clientCtx,
AuthOptions authOptions)
{
_refreshRepo = refreshRepo;
_usuarioRepo = usuarioRepo;
_jwt = jwt;
_refreshGenerator = refreshGenerator;
_clientCtx = clientCtx;
_authOptions = authOptions;
}
public async Task<RefreshResponseDto> Handle(RefreshCommand command)
{
// 1. Validate access token signature (lifetime=false) and extract sub
System.Security.Claims.ClaimsPrincipal principal;
try
{
principal = _jwt.GetPrincipalFromExpiredToken(command.AccessToken);
}
catch
{
throw new InvalidRefreshTokenException();
}
if (!int.TryParse(principal.FindFirst("sub")?.Value, out var accessUserId))
throw new InvalidRefreshTokenException();
// 2. Hash the refresh token to look it up
var hash = TokenHasher.Sha256Base64Url(command.RefreshToken);
// 3. Look up in DB (returns record regardless of revoked/expired status)
var stored = await _refreshRepo.GetByHashAsync(hash);
if (stored is null)
throw new InvalidRefreshTokenException();
var now = DateTime.UtcNow;
// 4. Reuse detection: already revoked → chain revocation and throw
if (stored.IsRevoked)
{
await _refreshRepo.RevokeFamilyAsync(stored.FamilyId, now);
throw new TokenReuseDetectedException();
}
// 5. Absolute expiration check
if (stored.IsExpired(now))
throw new InvalidRefreshTokenException();
// 6. UsuarioId must match access token's sub claim
if (stored.UsuarioId != accessUserId)
throw new InvalidRefreshTokenException();
// 7. Load current user (so access token has up-to-date claims)
var usuario = await _usuarioRepo.GetByIdAsync(stored.UsuarioId)
?? throw new InvalidRefreshTokenException();
if (!usuario.Activo)
throw new InvalidRefreshTokenException();
// 8. Rotate: create new token, persist, then revoke old
var newRaw = _refreshGenerator.Generate();
var newHash = TokenHasher.Sha256Base64Url(newRaw);
var rotated = RefreshToken.IssueRotation(stored, newHash, now, _clientCtx.Ip, _clientCtx.UserAgent);
var newId = await _refreshRepo.AddAsync(rotated);
await _refreshRepo.RevokeAsync(stored.Id, replacedById: newId, revokedAt: now);
// 9. Issue new access token
var newAccess = _jwt.GenerateAccessToken(usuario);
return new RefreshResponseDto(newAccess, newRaw, _authOptions.AccessTokenMinutes * 60);
}
}

View File

@@ -0,0 +1,16 @@
using FluentValidation;
namespace SIGCM2.Application.Auth.Refresh;
public sealed class RefreshCommandValidator : AbstractValidator<RefreshCommand>
{
public RefreshCommandValidator()
{
RuleFor(x => x.AccessToken)
.NotEmpty().WithMessage("accessToken is required");
RuleFor(x => x.RefreshToken)
.NotEmpty().WithMessage("refreshToken is required")
.MinimumLength(20).WithMessage("refreshToken must be at least 20 characters");
}
}

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.Auth.Refresh;
public sealed record RefreshResponseDto(
string AccessToken,
string RefreshToken,
int ExpiresIn);

View File

@@ -2,6 +2,8 @@ using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Auth.Login;
using SIGCM2.Application.Auth.Logout;
using SIGCM2.Application.Auth.Refresh;
namespace SIGCM2.Application;
@@ -9,10 +11,12 @@ public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
// Register command handlers
// Command handlers
services.AddScoped<ICommandHandler<LoginCommand, LoginResponseDto>, LoginCommandHandler>();
services.AddScoped<ICommandHandler<RefreshCommand, RefreshResponseDto>, RefreshCommandHandler>();
services.AddScoped<ICommandHandler<LogoutCommand, LogoutResponseDto>, LogoutCommandHandler>();
// Register FluentValidation validators from this assembly
// FluentValidation validators (scans entire Application assembly)
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
return services;

View File

@@ -0,0 +1,72 @@
namespace SIGCM2.Domain.Entities;
public sealed class RefreshToken
{
public int Id { get; init; }
public int UsuarioId { get; init; }
public string TokenHash { get; init; } = null!;
public Guid FamilyId { get; init; }
public DateTime IssuedAt { get; init; }
public DateTime ExpiresAt { get; init; }
public DateTime? RevokedAt { get; private set; }
public int? ReplacedById { get; private set; }
public string CreatedByIp { get; init; } = null!;
public string? UserAgent { get; init; }
/// <summary>Factory for a brand-new session (login). Generates a new FamilyId.</summary>
public static RefreshToken IssueForNewFamily(
int usuarioId,
string tokenHash,
DateTime now,
TimeSpan ttl,
string createdByIp,
string? userAgent)
=> new()
{
UsuarioId = usuarioId,
TokenHash = tokenHash,
FamilyId = Guid.NewGuid(),
IssuedAt = now,
ExpiresAt = now + ttl,
CreatedByIp = createdByIp,
UserAgent = userAgent,
};
/// <summary>Factory for a rotation. Inherits FamilyId and ExpiresAt (absolute TTL).</summary>
public static RefreshToken IssueRotation(
RefreshToken previous,
string newTokenHash,
DateTime now,
string createdByIp,
string? userAgent)
=> new()
{
UsuarioId = previous.UsuarioId,
TokenHash = newTokenHash,
FamilyId = previous.FamilyId,
IssuedAt = now,
ExpiresAt = previous.ExpiresAt, // ABSOLUTE — inherited from original
CreatedByIp = createdByIp,
UserAgent = userAgent,
};
/// <summary>Returns true if ExpiresAt &lt;= now (expired).</summary>
public bool IsExpired(DateTime now) => now >= ExpiresAt;
/// <summary>Returns true if the token has been explicitly revoked.</summary>
public bool IsRevoked => RevokedAt.HasValue;
/// <summary>Returns true if the token is neither revoked nor expired.</summary>
public bool IsActive(DateTime now) => !IsRevoked && !IsExpired(now);
/// <summary>
/// Marks the token as revoked with the given timestamp and optional successor id.
/// Should only be called by the repository when reconstructing or persisting state.
/// This is NOT the way to revoke in business logic — use the repository SQL methods.
/// </summary>
public void MarkAsPersistedRevocation(DateTime revokedAt, int? replacedById)
{
RevokedAt = revokedAt;
ReplacedById = replacedById;
}
}

View File

@@ -0,0 +1,24 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a refresh token is invalid (not found, expired, malformed, or user mismatch).
/// Maps to HTTP 401 with a generic error message — never reveal the specific reason to the client.
/// </summary>
public sealed class InvalidRefreshTokenException : Exception
{
public InvalidRefreshTokenException(string message = "Invalid refresh token")
: base(message) { }
}
/// <summary>
/// Thrown when a previously-rotated (revoked) refresh token is presented again.
/// Triggers chain revocation of the entire token family.
/// Maps to HTTP 401 with the SAME generic message as InvalidRefreshTokenException
/// to avoid leaking information to attackers.
/// The backend logs distinguish between the two cases.
/// </summary>
public sealed class TokenReuseDetectedException : Exception
{
public TokenReuseDetectedException()
: base("Token reuse detected") { }
}

View File

@@ -0,0 +1,25 @@
using System.Security.Cryptography;
using System.Text;
namespace SIGCM2.Domain.Security;
/// <summary>
/// Pure static helper for hashing opaque refresh tokens.
/// SHA-256 is appropriate here — tokens are 256-bit random values (not passwords),
/// so salting is unnecessary. Output is base64url without padding.
/// </summary>
public static class TokenHasher
{
public static string Sha256Base64Url(string raw)
{
var bytes = Encoding.UTF8.GetBytes(raw);
var hash = SHA256.HashData(bytes);
return Base64UrlEncode(hash);
}
private static string Base64UrlEncode(byte[] bytes)
=> Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}

View File

@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
@@ -7,6 +8,8 @@ using Microsoft.IdentityModel.Tokens;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Auth;
using SIGCM2.Infrastructure.Http;
using SIGCM2.Infrastructure.Messaging;
using SIGCM2.Infrastructure.Persistence;
using SIGCM2.Infrastructure.Security;
@@ -24,12 +27,24 @@ public static class DependencyInjection
?? throw new InvalidOperationException("Missing ConnectionStrings:SqlServer");
services.AddSingleton(new SqlConnectionFactory(connectionString));
services.AddScoped<IUsuarioRepository, UsuarioRepository>();
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
// Also expose as JwtOptions directly for convenience (resolves via IOptions<JwtOptions>)
services.AddSingleton<JwtOptions>(sp => sp.GetRequiredService<IOptions<JwtOptions>>().Value);
// AuthOptions (Application layer) — populated from the same Jwt config section
services.AddSingleton<AuthOptions>(sp =>
{
var opts = sp.GetRequiredService<JwtOptions>();
return new AuthOptions
{
AccessTokenMinutes = opts.AccessTokenMinutes,
RefreshTokenDays = opts.RefreshTokenDays,
};
});
// RSA key pair — loaded lazily as singletons from the fully-resolved JwtOptions
services.AddSingleton<RSA>(sp =>
{
@@ -46,6 +61,9 @@ public static class DependencyInjection
services.AddScoped<IJwtService>(sp =>
new JwtService(sp.GetRequiredService<RSA>(), sp.GetRequiredService<JwtOptions>()));
services.AddScoped<IPasswordHasher, BcryptPasswordHasher>();
services.AddSingleton<IRefreshTokenGenerator, RefreshTokenGenerator>();
services.AddHttpContextAccessor();
services.AddScoped<IClientContext, ClientContext>();
// Dispatcher
services.AddScoped<IDispatcher, Dispatcher>();

View File

@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Http;
using SIGCM2.Application.Abstractions;
namespace SIGCM2.Infrastructure.Http;
public sealed class ClientContext : IClientContext
{
private readonly IHttpContextAccessor _accessor;
public ClientContext(IHttpContextAccessor accessor)
{
_accessor = accessor;
}
public string Ip =>
_accessor.HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "0.0.0.0";
public string? UserAgent =>
_accessor.HttpContext?.Request?.Headers.UserAgent.ToString();
}

View File

@@ -0,0 +1,140 @@
using Dapper;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Infrastructure.Persistence;
public sealed class RefreshTokenRepository : IRefreshTokenRepository
{
private readonly SqlConnectionFactory _connectionFactory;
public RefreshTokenRepository(SqlConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<RefreshToken?> GetByHashAsync(string tokenHash, CancellationToken ct = default)
{
const string sql = """
SELECT Id, UsuarioId, TokenHash, FamilyId,
IssuedAt, ExpiresAt, RevokedAt, ReplacedById,
CreatedByIp, UserAgent
FROM dbo.RefreshToken
WHERE TokenHash = @TokenHash
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var row = await connection.QuerySingleOrDefaultAsync<RefreshTokenRow>(sql, new { TokenHash = tokenHash });
return row is null ? null : MapRow(row);
}
public async Task<int> AddAsync(RefreshToken token, CancellationToken ct = default)
{
const string sql = """
INSERT INTO dbo.RefreshToken
(UsuarioId, TokenHash, FamilyId, IssuedAt, ExpiresAt, CreatedByIp, UserAgent)
VALUES
(@UsuarioId, @TokenHash, @FamilyId, @IssuedAt, @ExpiresAt, @CreatedByIp, @UserAgent);
SELECT CAST(SCOPE_IDENTITY() AS INT);
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
return await connection.QuerySingleAsync<int>(sql, new
{
token.UsuarioId,
token.TokenHash,
token.FamilyId,
token.IssuedAt,
token.ExpiresAt,
token.CreatedByIp,
token.UserAgent,
});
}
public async Task RevokeAsync(int id, int? replacedById, DateTime revokedAt, CancellationToken ct = default)
{
const string sql = """
UPDATE dbo.RefreshToken
SET RevokedAt = @RevokedAt, ReplacedById = @ReplacedById
WHERE Id = @Id AND RevokedAt IS NULL
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
await connection.ExecuteAsync(sql, new { Id = id, ReplacedById = replacedById, RevokedAt = revokedAt });
}
public async Task<int> RevokeFamilyAsync(Guid familyId, DateTime revokedAt, CancellationToken ct = default)
{
const string sql = """
UPDATE dbo.RefreshToken
SET RevokedAt = @RevokedAt
WHERE FamilyId = @FamilyId AND RevokedAt IS NULL;
SELECT @@ROWCOUNT;
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
return await connection.QuerySingleAsync<int>(sql, new { FamilyId = familyId, RevokedAt = revokedAt });
}
public async Task<int> RevokeAllActiveForUserAsync(int usuarioId, DateTime revokedAt, CancellationToken ct = default)
{
const string sql = """
UPDATE dbo.RefreshToken
SET RevokedAt = @RevokedAt
WHERE UsuarioId = @UsuarioId AND RevokedAt IS NULL;
SELECT @@ROWCOUNT;
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
return await connection.QuerySingleAsync<int>(sql, new { UsuarioId = usuarioId, RevokedAt = revokedAt });
}
private static RefreshToken MapRow(RefreshTokenRow row) => RefreshTokenRow.Reconstruct(row);
// Flat Dapper DTO
private sealed record RefreshTokenRow(
int Id,
int UsuarioId,
string TokenHash,
Guid FamilyId,
DateTime IssuedAt,
DateTime ExpiresAt,
DateTime? RevokedAt,
int? ReplacedById,
string CreatedByIp,
string? UserAgent)
{
public static RefreshToken Reconstruct(RefreshTokenRow r)
{
// Build an empty token using the rotation factory from a dummy parent,
// then we manually set fields via the available setters.
// Since RefreshToken uses init-only properties, we use object initializer.
var token = new RefreshToken
{
Id = r.Id,
UsuarioId = r.UsuarioId,
TokenHash = r.TokenHash,
FamilyId = r.FamilyId,
IssuedAt = r.IssuedAt,
ExpiresAt = r.ExpiresAt,
CreatedByIp = r.CreatedByIp,
UserAgent = r.UserAgent,
};
if (r.RevokedAt.HasValue)
token.MarkAsPersistedRevocation(r.RevokedAt.Value, r.ReplacedById);
return token;
}
}
}

View File

@@ -32,7 +32,32 @@ public sealed class UsuarioRepository : IUsuarioRepository
if (row is null) return null;
return new Usuario(
return MapRow(row);
}
public async Task<Usuario?> GetByIdAsync(int id, CancellationToken ct = default)
{
const string sql = """
SELECT
Id, Username, PasswordHash,
Nombre, Apellido, Email,
Rol, PermisosJson, Activo
FROM dbo.Usuario
WHERE Id = @Id
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync();
var row = await connection.QuerySingleOrDefaultAsync<UsuarioRow>(sql, new { Id = id });
if (row is null) return null;
return MapRow(row);
}
private static Usuario MapRow(UsuarioRow row)
=> new(
id: row.Id,
username: row.Username,
passwordHash: row.PasswordHash,
@@ -43,7 +68,6 @@ public sealed class UsuarioRepository : IUsuarioRepository
permisosJson: row.PermisosJson,
activo: row.Activo
);
}
// Flat DTO for Dapper mapping (avoids polluting domain entity with Dapper attributes)
private sealed record UsuarioRow(

View File

@@ -5,6 +5,7 @@ public sealed class JwtOptions
public string Issuer { get; set; } = "sigcm2.api";
public string Audience { get; set; } = "sigcm2.web";
public int AccessTokenMinutes { get; set; } = 60;
public int RefreshTokenDays { get; set; } = 7;
/// <summary>Path to private.pem file (dev). Used if PrivateKey is null.</summary>
public string? PrivateKeyPath { get; set; }

View File

@@ -19,6 +19,31 @@ public sealed class JwtService : IJwtService
_options = options;
}
/// <inheritdoc/>
public ClaimsPrincipal GetPrincipalFromExpiredToken(string accessToken)
{
var parameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = _options.Issuer,
ValidateAudience = true,
ValidAudience = _options.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new RsaSecurityKey(_rsa),
ValidateLifetime = false, // Key: accept expired tokens in refresh flow
ClockSkew = TimeSpan.Zero,
};
var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(accessToken, parameters, out var securityToken);
if (securityToken is not JwtSecurityToken jwt ||
!jwt.Header.Alg.Equals(SecurityAlgorithms.RsaSha256, StringComparison.OrdinalIgnoreCase))
throw new SecurityTokenException("Invalid token algorithm");
return principal;
}
public string GenerateAccessToken(Usuario usuario)
{
var signingKey = new RsaSecurityKey(_rsa);

View File

@@ -0,0 +1,17 @@
using System.Security.Cryptography;
using SIGCM2.Application.Abstractions.Security;
namespace SIGCM2.Infrastructure.Security;
public sealed class RefreshTokenGenerator : IRefreshTokenGenerator
{
public string Generate()
{
Span<byte> bytes = stackalloc byte[32];
RandomNumberGenerator.Fill(bytes);
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
}

View File

@@ -1,10 +1,92 @@
import axios from 'axios'
import axios, { AxiosError } from 'axios'
import type { AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '@/stores/authStore'
const API_URL = import.meta.env['VITE_API_URL'] ?? 'http://localhost:5000'
const API_URL = import.meta.env['VITE_API_URL'] ?? 'http://localhost:5212'
export const axiosClient = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
})
// --- Request interceptor: attach Bearer from authStore
axiosClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = useAuthStore.getState().accessToken
if (token && !config.headers.Authorization) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// --- Singleton promise queue for refresh
// N concurrent 401s share the same refresh promise — only ONE call to /auth/refresh
let refreshPromise: Promise<string> | null = null
async function performRefresh(): Promise<string> {
const { refreshToken, accessToken, updateAccess, clearAuth } = useAuthStore.getState()
if (!refreshToken || !accessToken) {
clearAuth()
throw new Error('no tokens available for refresh')
}
try {
// IMPORTANT: use plain axios, NOT axiosClient, to avoid the response interceptor loop
const res = await axios.post<{ accessToken: string; refreshToken: string; expiresIn: number }>(
`${API_URL}/api/v1/auth/refresh`,
{ accessToken, refreshToken },
{ headers: { 'Content-Type': 'application/json' } },
)
const { accessToken: newAccess, refreshToken: newRefresh, expiresIn } = res.data
updateAccess(newAccess, newRefresh, Date.now() + expiresIn * 1000)
return newAccess
} catch (e) {
clearAuth()
if (typeof window !== 'undefined') {
window.location.assign('/login')
}
throw e
}
}
interface RetryConfig extends AxiosRequestConfig {
_retry?: boolean
}
// --- Response interceptor: handle 401 with token refresh
axiosClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const original = error.config as RetryConfig | undefined
const status = error.response?.status
const url = original?.url ?? ''
// Only attempt refresh for 401s on non-auth endpoints
if (
status !== 401 ||
!original ||
original._retry ||
url.endsWith('/auth/refresh') ||
url.endsWith('/auth/login')
) {
return Promise.reject(error)
}
original._retry = true
try {
// Singleton: if refresh is already in flight, await the same promise
if (refreshPromise === null) {
refreshPromise = performRefresh().finally(() => {
refreshPromise = null
})
}
const newAccess = await refreshPromise
// Retry original request with new token
original.headers = original.headers ?? {}
;(original.headers as Record<string, string>).Authorization = `Bearer ${newAccess}`
return axiosClient(original)
} catch (e) {
return Promise.reject(e)
}
},
)

View File

@@ -12,6 +12,22 @@ export interface LoginResponseDto {
}
}
export interface RefreshRequest {
accessToken: string
refreshToken: string
}
export interface RefreshResponse {
accessToken: string
refreshToken: string
expiresIn: number
}
export interface LogoutResponse {
success: boolean
mensaje: string
}
export async function login(username: string, password: string): Promise<LoginResponseDto> {
const response = await axiosClient.post<LoginResponseDto>('/api/v1/auth/login', {
username,
@@ -19,3 +35,13 @@ export async function login(username: string, password: string): Promise<LoginRe
})
return response.data
}
export async function refresh(payload: RefreshRequest): Promise<RefreshResponse> {
const response = await axiosClient.post<RefreshResponse>('/api/v1/auth/refresh', payload)
return response.data
}
export async function logout(): Promise<LogoutResponse> {
const response = await axiosClient.post<LogoutResponse>('/api/v1/auth/logout', {})
return response.data
}

View File

@@ -12,34 +12,59 @@ interface SetAuthPayload {
user: AuthUser
accessToken: string
refreshToken: string
expiresIn: number
expiresIn: number // seconds from backend
}
interface AuthState {
user: AuthUser | null
accessToken: string | null
refreshToken: string | null
expiresAt: number | null // ms epoch UTC
setAuth: (payload: SetAuthPayload) => void
logout: () => void
updateAccess: (accessToken: string, refreshToken: string, expiresAt: number) => void
clearAuth: () => void
logout: () => Promise<void>
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
(set, get) => ({
user: null,
accessToken: null,
refreshToken: null,
expiresAt: null,
setAuth: (payload: SetAuthPayload) => {
setAuth: (payload) =>
set({
user: payload.user,
accessToken: payload.accessToken,
})
},
refreshToken: payload.refreshToken,
expiresAt: Date.now() + payload.expiresIn * 1000,
}),
logout: () => {
updateAccess: (accessToken, refreshToken, expiresAt) =>
set({ accessToken, refreshToken, expiresAt }),
clearAuth: () =>
set({
user: null,
accessToken: null,
})
refreshToken: null,
expiresAt: null,
}),
logout: async () => {
const { accessToken, clearAuth } = get()
if (accessToken) {
try {
// Lazy import to break circular dependency with axiosClient
const { logout: apiLogout } = await import('@/features/auth/api/authApi')
await apiLogout()
} catch {
// Ignore API errors — local logout is always safe
}
}
clearAuth()
},
}),
{
@@ -47,6 +72,8 @@ export const useAuthStore = create<AuthState>()(
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
refreshToken: state.refreshToken,
expiresAt: state.expiresAt,
}),
},
),

View File

@@ -0,0 +1,250 @@
/**
* T-064 — axiosClient interceptor tests (MSW v2)
*
* Tests cover:
* 1. request_includesBearer_whenAccessTokenPresent
* 2. request_noBearer_whenAccessTokenNull
* 3. response_401_triggersRefreshAndRetries
* 4. response_401_threeParallel_singleRefresh (CRITICAL — singleton promise)
* 5. response_401_refreshFails_clearsAuthAndRejects
* 6. response_401_onLoginUrl_noRefresh
* 7. response_401_onRefreshUrl_noLoop
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { useAuthStore } from '../../stores/authStore'
const API_URL = 'http://localhost:5000'
// Helper to reset the refreshPromise singleton between tests
// by reimporting the module. We use a direct import instead.
async function getAxiosClient() {
const mod = await import('../../api/axiosClient')
return mod.axiosClient
}
// MSW server
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
afterAll(() => server.close())
beforeEach(() => {
// Reset store to clean slate
useAuthStore.setState({
user: null,
accessToken: null,
refreshToken: null,
expiresAt: null,
})
// Reset window.location mock if applied
vi.restoreAllMocks()
})
afterEach(() => {
server.resetHandlers()
})
function setAuth(accessToken: string, refreshToken: string) {
useAuthStore.setState({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
accessToken,
refreshToken,
expiresAt: Date.now() + 3600 * 1000,
})
}
describe('axiosClient', () => {
describe('request interceptor', () => {
it('request_includesBearer_whenAccessTokenPresent', async () => {
setAuth('my-access-token', 'my-refresh-token')
let capturedAuthHeader: string | null = null
server.use(
http.get(`${API_URL}/api/v1/test`, ({ request }) => {
capturedAuthHeader = request.headers.get('Authorization')
return HttpResponse.json({ ok: true })
}),
)
const client = await getAxiosClient()
await client.get('/api/v1/test')
expect(capturedAuthHeader).toBe('Bearer my-access-token')
})
it('request_noBearer_whenAccessTokenNull', async () => {
// No auth set — accessToken is null
let capturedAuthHeader: string | null | undefined = undefined
server.use(
http.get(`${API_URL}/api/v1/test`, ({ request }) => {
capturedAuthHeader = request.headers.get('Authorization')
return HttpResponse.json({ ok: true })
}),
)
const client = await getAxiosClient()
await client.get('/api/v1/test')
expect(capturedAuthHeader).toBeNull()
})
})
describe('response interceptor — 401 handling', () => {
it('response_401_triggersRefreshAndRetries', async () => {
setAuth('expired-access', 'valid-refresh')
let requestCount = 0
server.use(
// Protected endpoint: returns 401 first, then 200 after refresh
http.get(`${API_URL}/api/v1/protected`, ({ request }) => {
requestCount++
const auth = request.headers.get('Authorization')
if (auth === 'Bearer new-access-token') {
return HttpResponse.json({ data: 'secret' })
}
return new HttpResponse(null, { status: 401 })
}),
// Refresh endpoint
http.post(`${API_URL}/api/v1/auth/refresh`, () => {
return HttpResponse.json({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
expiresIn: 3600,
})
}),
)
const client = await getAxiosClient()
const res = await client.get('/api/v1/protected')
expect(res.data).toEqual({ data: 'secret' })
expect(requestCount).toBe(2) // 1st: 401, 2nd: 200 with new token
expect(useAuthStore.getState().accessToken).toBe('new-access-token')
expect(useAuthStore.getState().refreshToken).toBe('new-refresh-token')
})
it('response_401_threeParallel_singleRefresh', async () => {
setAuth('expired-access', 'valid-refresh')
let refreshCallCount = 0
let requestCount = 0
server.use(
http.get(`${API_URL}/api/v1/protected`, ({ request }) => {
requestCount++
const auth = request.headers.get('Authorization')
if (auth === 'Bearer new-access-from-refresh') {
return HttpResponse.json({ data: 'ok' })
}
return new HttpResponse(null, { status: 401 })
}),
http.post(`${API_URL}/api/v1/auth/refresh`, async () => {
refreshCallCount++
// Simulate slight delay so concurrent requests queue up
await new Promise((r) => setTimeout(r, 20))
return HttpResponse.json({
accessToken: 'new-access-from-refresh',
refreshToken: 'new-refresh-from-refresh',
expiresIn: 3600,
})
}),
)
const client = await getAxiosClient()
// Fire 3 requests simultaneously — all get 401, all should wait on single refresh
const [r1, r2, r3] = await Promise.all([
client.get('/api/v1/protected'),
client.get('/api/v1/protected'),
client.get('/api/v1/protected'),
])
expect(r1.data).toEqual({ data: 'ok' })
expect(r2.data).toEqual({ data: 'ok' })
expect(r3.data).toEqual({ data: 'ok' })
// CRITICAL: only ONE call to /auth/refresh despite 3 parallel 401s
expect(refreshCallCount).toBe(1)
})
it('response_401_refreshFails_clearsAuthAndRejects', async () => {
setAuth('expired-access', 'invalid-refresh')
// Mock window.location.assign to avoid jsdom navigation error
const assignMock = vi.fn()
Object.defineProperty(window, 'location', {
value: { ...window.location, assign: assignMock },
writable: true,
})
server.use(
http.get(`${API_URL}/api/v1/protected`, () => {
return new HttpResponse(null, { status: 401 })
}),
http.post(`${API_URL}/api/v1/auth/refresh`, () => {
return new HttpResponse(null, { status: 401 })
}),
)
const client = await getAxiosClient()
await expect(client.get('/api/v1/protected')).rejects.toThrow()
// Auth should be cleared
const state = useAuthStore.getState()
expect(state.accessToken).toBeNull()
expect(state.user).toBeNull()
})
it('response_401_onLoginUrl_noRefresh', async () => {
setAuth('expired-access', 'valid-refresh')
let refreshCalled = false
server.use(
http.post(`${API_URL}/api/v1/auth/login`, () => {
return new HttpResponse(null, { status: 401 })
}),
http.post(`${API_URL}/api/v1/auth/refresh`, () => {
refreshCalled = true
return HttpResponse.json({
accessToken: 'new-access',
refreshToken: 'new-refresh',
expiresIn: 3600,
})
}),
)
const client = await getAxiosClient()
// Login 401 should NOT trigger refresh — just reject
await expect(client.post('/api/v1/auth/login', {})).rejects.toThrow()
expect(refreshCalled).toBe(false)
})
it('response_401_onRefreshUrl_noLoop', async () => {
setAuth('expired-access', 'valid-refresh')
let refreshCallCount = 0
server.use(
http.post(`${API_URL}/api/v1/auth/refresh`, () => {
refreshCallCount++
return new HttpResponse(null, { status: 401 })
}),
)
const client = await getAxiosClient()
// Calling refresh endpoint that returns 401 should NOT re-trigger refresh
await expect(
client.post('/api/v1/auth/refresh', { accessToken: 'x', refreshToken: 'y' }),
).rejects.toThrow()
// Should have called /refresh exactly once (the explicit call), no loop
expect(refreshCallCount).toBe(1)
})
})
})

View File

@@ -37,7 +37,8 @@ const server = setupServer(
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => {
server.resetHandlers()
useAuthStore.getState().logout()
// Use clearAuth (sync) to avoid triggering API logout call in tests
useAuthStore.getState().clearAuth()
localStorage.clear()
mockNavigate.mockClear()
})
@@ -114,4 +115,28 @@ describe('LoginPage', () => {
expect(state.user?.username).toBe('admin')
})
})
it('setAuth receives expiresIn and store calculates expiresAt correctly', async () => {
const before = Date.now()
const user = userEvent.setup()
renderLoginPage()
await user.type(screen.getByLabelText(/usuario/i), 'admin')
await user.type(screen.getByLabelText(/contraseña/i), '@Diego550@')
await user.click(screen.getByRole('button', { name: /ingresar/i }))
await waitFor(() => {
const state = useAuthStore.getState()
const after = Date.now()
// refreshToken should be persisted
expect(state.refreshToken).toBe(mockLoginResponse.refreshToken)
// expiresAt should be computed as Date.now() + expiresIn * 1000
// Allow a 2s window for test execution
expect(state.expiresAt).not.toBeNull()
expect(state.expiresAt!).toBeGreaterThanOrEqual(before + mockLoginResponse.expiresIn * 1000)
expect(state.expiresAt!).toBeLessThanOrEqual(after + mockLoginResponse.expiresIn * 1000)
})
})
})

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { login } from '../../../features/auth/api/authApi'
import { login, refresh, logout } from '../../../features/auth/api/authApi'
const API_URL = 'http://localhost:5000'
@@ -17,6 +17,17 @@ const mockLoginResponse = {
},
}
const mockRefreshResponse = {
accessToken: 'eyJhbGciOiJSUzI1NiJ9.new-payload.new-sig',
refreshToken: 'new-opaque-refresh-token-xyz',
expiresIn: 3600,
}
const mockLogoutResponse = {
success: true,
mensaje: 'Sesion cerrada correctamente',
}
const server = setupServer(
http.post(`${API_URL}/api/v1/auth/login`, async ({ request }) => {
const body = await request.json() as { username: string; password: string }
@@ -25,6 +36,18 @@ const server = setupServer(
}
return HttpResponse.json({ error: 'Credenciales inválidas' }, { status: 401 })
}),
http.post(`${API_URL}/api/v1/auth/refresh`, async ({ request }) => {
const body = await request.json() as { accessToken: string; refreshToken: string }
if (body.accessToken && body.refreshToken) {
return HttpResponse.json(mockRefreshResponse, { status: 200 })
}
return HttpResponse.json({ error: 'invalid_token' }, { status: 401 })
}),
http.post(`${API_URL}/api/v1/auth/logout`, async () => {
return HttpResponse.json(mockLogoutResponse, { status: 200 })
}),
)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
@@ -45,3 +68,60 @@ describe('login()', () => {
await expect(login('admin', 'wrongpassword')).rejects.toThrow()
})
})
describe('refresh()', () => {
it('refresh_callsCorrectEndpoint_withPayload', async () => {
let capturedBody: unknown = null
server.use(
http.post(`${API_URL}/api/v1/auth/refresh`, async ({ request }) => {
capturedBody = await request.json()
return HttpResponse.json(mockRefreshResponse, { status: 200 })
}),
)
const payload = {
accessToken: 'old-access-token',
refreshToken: 'old-refresh-token',
}
const result = await refresh(payload)
expect(result.accessToken).toBe(mockRefreshResponse.accessToken)
expect(result.refreshToken).toBe(mockRefreshResponse.refreshToken)
expect(result.expiresIn).toBe(3600)
expect(capturedBody).toEqual(payload)
})
it('throws on invalid refresh token (401)', async () => {
server.use(
http.post(`${API_URL}/api/v1/auth/refresh`, () => {
return HttpResponse.json({ error: 'invalid_token' }, { status: 401 })
}),
)
await expect(
refresh({ accessToken: 'bad-access', refreshToken: 'bad-refresh' }),
).rejects.toThrow()
})
})
describe('logout()', () => {
it('logout_callsCorrectEndpoint', async () => {
let requestUrl: string | null = null
let requestMethod: string | null = null
server.use(
http.post(`${API_URL}/api/v1/auth/logout`, ({ request }) => {
requestUrl = request.url
requestMethod = request.method
return HttpResponse.json(mockLogoutResponse, { status: 200 })
}),
)
const result = await logout()
expect(result.success).toBe(true)
expect(result.mensaje).toBe('Sesion cerrada correctamente')
expect(requestUrl).toContain('/api/v1/auth/logout')
expect(requestMethod).toBe('POST')
})
})

View File

@@ -1,13 +1,22 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { useAuthStore } from '../../stores/authStore'
describe('authStore', () => {
beforeEach(() => {
// Reset store state before each test
useAuthStore.getState().logout()
useAuthStore.setState({
user: null,
accessToken: null,
refreshToken: null,
expiresAt: null,
})
localStorage.clear()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('initial state', () => {
it('starts with null user and null accessToken', () => {
const state = useAuthStore.getState()
@@ -48,11 +57,123 @@ describe('authStore', () => {
expect(parsed.state.accessToken).toBe(payload.accessToken)
expect(parsed.state.user.username).toBe('admin')
})
it('setAuth_persistsRefreshTokenAndExpiresAt', () => {
const before = Date.now()
const payload = {
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
accessToken: 'access-token-abc',
refreshToken: 'opaque-refresh-xyz',
expiresIn: 3600,
}
useAuthStore.getState().setAuth(payload)
const after = Date.now()
const state = useAuthStore.getState()
expect(state.refreshToken).toBe('opaque-refresh-xyz')
expect(state.expiresAt).not.toBeNull()
// expiresAt should be ~Date.now() + 3600*1000
expect(state.expiresAt!).toBeGreaterThanOrEqual(before + 3600 * 1000)
expect(state.expiresAt!).toBeLessThanOrEqual(after + 3600 * 1000)
// Should also be persisted in localStorage
const stored = localStorage.getItem('auth-storage')
const parsed = JSON.parse(stored!)
expect(parsed.state.refreshToken).toBe('opaque-refresh-xyz')
expect(parsed.state.expiresAt).toBeGreaterThan(0)
})
})
describe('logout', () => {
it('clears user and accessToken from state', () => {
// Setup: set auth first
describe('clearAuth', () => {
it('clearAuth_removesAllFields', () => {
useAuthStore.getState().setAuth({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresIn: 3600,
})
useAuthStore.getState().clearAuth()
const state = useAuthStore.getState()
expect(state.user).toBeNull()
expect(state.accessToken).toBeNull()
expect(state.refreshToken).toBeNull()
expect(state.expiresAt).toBeNull()
})
})
describe('updateAccess', () => {
it('updateAccess_updatesOnlyTokens_preservesUser', () => {
const originalUser = { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }
useAuthStore.getState().setAuth({
user: originalUser,
accessToken: 'old-access',
refreshToken: 'old-refresh',
expiresIn: 3600,
})
const newExpiresAt = Date.now() + 7200 * 1000
useAuthStore.getState().updateAccess('new-access', 'new-refresh', newExpiresAt)
const state = useAuthStore.getState()
expect(state.accessToken).toBe('new-access')
expect(state.refreshToken).toBe('new-refresh')
expect(state.expiresAt).toBe(newExpiresAt)
// user should be preserved
expect(state.user).toEqual(originalUser)
})
})
describe('logout (async)', () => {
it('logout_callsApi_thenClearsAuth', async () => {
// Set up auth state with a token so logout() will try to call the API
useAuthStore.getState().setAuth({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresIn: 3600,
})
// logout() does a lazy import of authApi and calls logout()
// The API call may succeed or fail (we don't control the real import here),
// but clearAuth() is ALWAYS called after regardless.
await useAuthStore.getState().logout()
const state = useAuthStore.getState()
expect(state.user).toBeNull()
expect(state.accessToken).toBeNull()
expect(state.refreshToken).toBeNull()
expect(state.expiresAt).toBeNull()
})
it('logout_apiFails_stillClearsAuth', async () => {
useAuthStore.getState().setAuth({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresIn: 3600,
})
// Should NOT throw even if the dynamic import fails
// (We test this by verifying clearAuth is always called)
let threw = false
try {
await useAuthStore.getState().logout()
} catch {
threw = true
}
expect(threw).toBe(false)
const state = useAuthStore.getState()
expect(state.user).toBeNull()
expect(state.accessToken).toBeNull()
})
})
describe('legacy logout compatibility (via clearAuth)', () => {
it('clearAuth clears user and accessToken from state', () => {
useAuthStore.getState().setAuth({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
accessToken: 'some-token',
@@ -60,14 +181,14 @@ describe('authStore', () => {
expiresIn: 3600,
})
useAuthStore.getState().logout()
useAuthStore.getState().clearAuth()
const state = useAuthStore.getState()
expect(state.user).toBeNull()
expect(state.accessToken).toBeNull()
})
it('removes auth-storage from localStorage on logout', () => {
it('clearAuth removes auth-storage from localStorage', () => {
useAuthStore.getState().setAuth({
user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' },
accessToken: 'some-token',
@@ -75,10 +196,9 @@ describe('authStore', () => {
expiresIn: 3600,
})
useAuthStore.getState().logout()
useAuthStore.getState().clearAuth()
const stored = localStorage.getItem('auth-storage')
// After logout the persisted state should have null user/token
if (stored !== null) {
const parsed = JSON.parse(stored)
expect(parsed.state.user).toBeNull()

View File

@@ -94,4 +94,80 @@ public class AuthControllerTests : IClassFixture<TestWebAppFactory>
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
// T-050: Refresh endpoint tests
[Fact]
public async Task Refresh_WithInvalidRefreshToken_Returns401()
{
var response = await _client.PostAsJsonAsync("/api/v1/auth/refresh", new
{
accessToken = "any.token.here",
refreshToken = "nonexistent_refresh_token_value_that_is_at_least_20_chars"
});
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Refresh_MissingBody_Returns400()
{
var response = await _client.PostAsJsonAsync("/api/v1/auth/refresh", new
{
accessToken = "",
refreshToken = ""
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Logout_WithoutBearer_Returns401()
{
var response = await _client.PostAsJsonAsync("/api/v1/auth/logout", new { });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Login_Refresh_Logout_FullFlow()
{
// Step 1: Login to get tokens
var loginResp = await _client.PostAsJsonAsync("/api/v1/auth/login", new
{
username = "admin",
password = "@Diego550@"
});
if (loginResp.StatusCode == HttpStatusCode.InternalServerError)
{
// DB not available in this environment — skip gracefully
return;
}
Assert.Equal(HttpStatusCode.OK, loginResp.StatusCode);
var loginJson = await loginResp.Content.ReadFromJsonAsync<JsonElement>();
var accessToken = loginJson.GetProperty("accessToken").GetString()!;
var refreshToken = loginJson.GetProperty("refreshToken").GetString()!;
// Step 2: Use access token to call logout
using var logoutRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/auth/logout");
logoutRequest.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
logoutRequest.Content = JsonContent.Create(new { });
var logoutResp = await _client.SendAsync(logoutRequest);
Assert.Equal(HttpStatusCode.OK, logoutResp.StatusCode);
var logoutJson = await logoutResp.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(logoutJson.GetProperty("success").GetBoolean());
// Step 3: After logout, refresh should fail (token revoked)
var refreshResp = await _client.PostAsJsonAsync("/api/v1/auth/refresh", new
{
accessToken = accessToken,
refreshToken = refreshToken
});
Assert.Equal(HttpStatusCode.Unauthorized, refreshResp.StatusCode);
}
}

View File

@@ -1,9 +1,12 @@
using NSubstitute;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Auth;
using SIGCM2.Application.Auth.Login;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
using SIGCM2.Domain.Security;
namespace SIGCM2.Application.Tests.Auth.Login;
@@ -12,11 +15,22 @@ public class LoginCommandHandlerTests
private readonly IUsuarioRepository _repository = Substitute.For<IUsuarioRepository>();
private readonly IPasswordHasher _hasher = Substitute.For<IPasswordHasher>();
private readonly IJwtService _jwtService = Substitute.For<IJwtService>();
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
private readonly IRefreshTokenGenerator _refreshGenerator = Substitute.For<IRefreshTokenGenerator>();
private readonly IClientContext _clientCtx = Substitute.For<IClientContext>();
private readonly AuthOptions _authOptions = new() { AccessTokenMinutes = 60, RefreshTokenDays = 7 };
private readonly LoginCommandHandler _handler;
public LoginCommandHandlerTests()
{
_handler = new LoginCommandHandler(_repository, _hasher, _jwtService);
_clientCtx.Ip.Returns("127.0.0.1");
_clientCtx.UserAgent.Returns("TestAgent");
_refreshGenerator.Generate().Returns("raw_refresh_token_value");
_refreshRepo.AddAsync(Arg.Any<RefreshToken>()).Returns(1);
_handler = new LoginCommandHandler(
_repository, _hasher, _jwtService,
_refreshRepo, _refreshGenerator, _clientCtx, _authOptions);
}
// Scenario: valid credentials → returns token response with usuario populated
@@ -100,4 +114,64 @@ public class LoginCommandHandlerTests
await Assert.ThrowsAsync<InvalidCredentialsException>(() => _handler.Handle(command));
}
// T-034: Refresh token persistence on login
[Fact]
public async Task Handle_PersistsHashedRefreshToken()
{
var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[\"*\"]", true);
_repository.GetByUsernameAsync("admin").Returns(usuario);
_hasher.Verify("@Diego550@", "$2a$12$hash").Returns(true);
_jwtService.GenerateAccessToken(usuario).Returns("jwt");
var command = new LoginCommand("admin", "@Diego550@");
var result = await _handler.Handle(command);
// Raw token returned to client
Assert.Equal("raw_refresh_token_value", result.RefreshToken);
// Repository received a token with the hash of the raw value — not the raw itself
var expectedHash = TokenHasher.Sha256Base64Url("raw_refresh_token_value");
await _refreshRepo.Received(1).AddAsync(Arg.Is<RefreshToken>(t =>
t.TokenHash == expectedHash &&
t.UsuarioId == 1));
}
[Fact]
public async Task Handle_GeneratesNewFamilyId()
{
var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[\"*\"]", true);
_repository.GetByUsernameAsync("admin").Returns(usuario);
_hasher.Verify(Arg.Any<string>(), Arg.Any<string>()).Returns(true);
_jwtService.GenerateAccessToken(Arg.Any<Usuario>()).Returns("jwt");
var capturedFamilies = new List<Guid>();
_refreshRepo.AddAsync(Arg.Do<RefreshToken>(t => capturedFamilies.Add(t.FamilyId))).Returns(1);
await _handler.Handle(new LoginCommand("admin", "@Diego550@"));
await _handler.Handle(new LoginCommand("admin", "@Diego550@"));
// Each login gets a unique FamilyId
Assert.Equal(2, capturedFamilies.Count);
Assert.NotEqual(capturedFamilies[0], capturedFamilies[1]);
}
[Fact]
public async Task Handle_UsesConfiguredRefreshTokenDays()
{
var usuario = new Usuario(1, "admin", "$2a$12$hash", "Admin", "Sys", null, "admin", "[\"*\"]", true);
_repository.GetByUsernameAsync("admin").Returns(usuario);
_hasher.Verify(Arg.Any<string>(), Arg.Any<string>()).Returns(true);
_jwtService.GenerateAccessToken(Arg.Any<Usuario>()).Returns("jwt");
var before = DateTime.UtcNow;
await _handler.Handle(new LoginCommand("admin", "@Diego550@"));
var after = DateTime.UtcNow;
// The token added to the repo must have ExpiresAt ~7 days from now
await _refreshRepo.Received(1).AddAsync(Arg.Is<RefreshToken>(t =>
t.ExpiresAt >= before.AddDays(6).AddHours(23) &&
t.ExpiresAt <= after.AddDays(7).AddSeconds(5)));
}
}

View File

@@ -0,0 +1,39 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Auth.Logout;
namespace SIGCM2.Application.Tests.Auth.Logout;
public class LogoutCommandHandlerTests
{
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
private readonly LogoutCommandHandler _handler;
public LogoutCommandHandlerTests()
{
_handler = new LogoutCommandHandler(_refreshRepo);
}
[Fact]
public async Task Handle_RevokesAllActiveForUser()
{
_refreshRepo.RevokeAllActiveForUserAsync(42, Arg.Any<DateTime>()).Returns(3);
var result = await _handler.Handle(new LogoutCommand(42));
Assert.True(result.Success);
Assert.False(string.IsNullOrWhiteSpace(result.Mensaje));
await _refreshRepo.Received(1).RevokeAllActiveForUserAsync(42, Arg.Any<DateTime>());
}
[Fact]
public async Task Handle_NoActiveTokens_StillReturnsSuccess()
{
// 0 rows affected = idempotent logout
_refreshRepo.RevokeAllActiveForUserAsync(Arg.Any<int>(), Arg.Any<DateTime>()).Returns(0);
var result = await _handler.Handle(new LogoutCommand(99));
Assert.True(result.Success);
}
}

View File

@@ -0,0 +1,198 @@
using System.Security.Claims;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Abstractions.Security;
using SIGCM2.Application.Auth;
using SIGCM2.Application.Auth.Refresh;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
using SIGCM2.Domain.Security;
namespace SIGCM2.Application.Tests.Auth.Refresh;
public class RefreshCommandHandlerTests
{
private readonly IRefreshTokenRepository _refreshRepo = Substitute.For<IRefreshTokenRepository>();
private readonly IUsuarioRepository _usuarioRepo = Substitute.For<IUsuarioRepository>();
private readonly IJwtService _jwtService = Substitute.For<IJwtService>();
private readonly IRefreshTokenGenerator _generator = Substitute.For<IRefreshTokenGenerator>();
private readonly IClientContext _clientCtx = Substitute.For<IClientContext>();
private readonly AuthOptions _authOptions = new() { AccessTokenMinutes = 60, RefreshTokenDays = 7 };
private readonly RefreshCommandHandler _handler;
private static readonly Usuario ActiveUsuario = new(
id: 1, username: "admin", passwordHash: "$2a$12$hash",
nombre: "Admin", apellido: "Sys", email: null,
rol: "admin", permisosJson: "[\"*\"]", activo: true);
public RefreshCommandHandlerTests()
{
_clientCtx.Ip.Returns("127.0.0.1");
_clientCtx.UserAgent.Returns("TestAgent/1.0");
_generator.Generate().Returns("new_raw_token_value_xyz");
_handler = new RefreshCommandHandler(
_refreshRepo, _usuarioRepo, _jwtService, _generator, _clientCtx, _authOptions);
}
// Helper: build an active stored RefreshToken with a matching principal
private (RefreshToken stored, string rawToken, ClaimsPrincipal principal) MakeActiveToken(
int usuarioId = 1, bool expired = false, bool revoked = false)
{
const string rawToken = "test_raw_token_value_abc";
var hash = TokenHasher.Sha256Base64Url(rawToken);
var now = DateTime.UtcNow;
var expiresAt = expired ? now.AddDays(-1) : now.AddDays(6);
var issuedAt = now.AddHours(-1);
var stored = RefreshToken.IssueForNewFamily(usuarioId, hash, issuedAt, TimeSpan.FromDays(7), "10.0.0.1", null);
// We need to set ExpiresAt to our custom value — since IssueForNewFamily uses now+ttl,
// we build differently: use reflection trick. Instead, build a helper stored token directly.
// The cleanest approach: build via the public factory, then adjust with MarkAsPersistedRevocation for revoked.
// Build a fresh token that expires properly
var storedToken = RefreshToken.IssueForNewFamily(
usuarioId, hash,
now: issuedAt,
ttl: expired ? TimeSpan.FromSeconds(1) : TimeSpan.FromDays(7),
createdByIp: "10.0.0.1",
userAgent: null);
if (revoked)
storedToken.MarkAsPersistedRevocation(now.AddMinutes(-5), replacedById: null);
var claims = new List<Claim>
{
new(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub, usuarioId.ToString())
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims));
return (storedToken, rawToken, principal);
}
// --- Happy path ---
[Fact]
public async Task Handle_HappyPath_RotatesAndReturnsNewPair()
{
var (stored, rawToken, principal) = MakeActiveToken();
var hash = TokenHasher.Sha256Base64Url(rawToken);
_jwtService.GetPrincipalFromExpiredToken(Arg.Any<string>()).Returns(principal);
_refreshRepo.GetByHashAsync(hash).Returns(stored);
_usuarioRepo.GetByIdAsync(1).Returns(ActiveUsuario);
_jwtService.GenerateAccessToken(ActiveUsuario).Returns("new_access_token");
_refreshRepo.AddAsync(Arg.Any<RefreshToken>()).Returns(99);
var cmd = new RefreshCommand("old_access_token", rawToken);
var result = await _handler.Handle(cmd);
Assert.Equal("new_access_token", result.AccessToken);
Assert.Equal("new_raw_token_value_xyz", result.RefreshToken);
Assert.Equal(3600, result.ExpiresIn);
await _refreshRepo.Received(1).AddAsync(Arg.Any<RefreshToken>());
await _refreshRepo.Received(1).RevokeAsync(stored.Id, replacedById: 99, revokedAt: Arg.Any<DateTime>());
}
[Fact]
public async Task Handle_NewTokenInheritsOriginalExpiresAt()
{
var (stored, rawToken, principal) = MakeActiveToken();
var hash = TokenHasher.Sha256Base64Url(rawToken);
_jwtService.GetPrincipalFromExpiredToken(Arg.Any<string>()).Returns(principal);
_refreshRepo.GetByHashAsync(hash).Returns(stored);
_usuarioRepo.GetByIdAsync(1).Returns(ActiveUsuario);
_jwtService.GenerateAccessToken(ActiveUsuario).Returns("new_access");
_refreshRepo.AddAsync(Arg.Any<RefreshToken>()).Returns(10);
await _handler.Handle(new RefreshCommand("access", rawToken));
// Verify the new token saved to repo inherits the ExpiresAt of the parent
await _refreshRepo.Received(1).AddAsync(Arg.Is<RefreshToken>(t =>
t.ExpiresAt == stored.ExpiresAt &&
t.FamilyId == stored.FamilyId));
}
// --- Error paths ---
[Fact]
public async Task Handle_TokenHashNotFound_ThrowsInvalidRefreshToken()
{
var principal = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub, "1")]));
_jwtService.GetPrincipalFromExpiredToken(Arg.Any<string>()).Returns(principal);
_refreshRepo.GetByHashAsync(Arg.Any<string>()).Returns((RefreshToken?)null);
await Assert.ThrowsAsync<InvalidRefreshTokenException>(
() => _handler.Handle(new RefreshCommand("access", "unknown_token")));
}
[Fact]
public async Task Handle_TokenExpired_ThrowsInvalidRefreshToken()
{
var (stored, rawToken, principal) = MakeActiveToken(expired: true);
var hash = TokenHasher.Sha256Base64Url(rawToken);
_jwtService.GetPrincipalFromExpiredToken(Arg.Any<string>()).Returns(principal);
_refreshRepo.GetByHashAsync(hash).Returns(stored);
await Assert.ThrowsAsync<InvalidRefreshTokenException>(
() => _handler.Handle(new RefreshCommand("access", rawToken)));
}
[Fact]
public async Task Handle_TokenAlreadyRevoked_RevokesFamilyAndThrowsReuse()
{
var (stored, rawToken, principal) = MakeActiveToken(revoked: true);
var hash = TokenHasher.Sha256Base64Url(rawToken);
_jwtService.GetPrincipalFromExpiredToken(Arg.Any<string>()).Returns(principal);
_refreshRepo.GetByHashAsync(hash).Returns(stored);
await Assert.ThrowsAsync<TokenReuseDetectedException>(
() => _handler.Handle(new RefreshCommand("access", rawToken)));
await _refreshRepo.Received(1).RevokeFamilyAsync(stored.FamilyId, Arg.Any<DateTime>());
}
[Fact]
public async Task Handle_AccessTokenSignatureInvalid_ThrowsInvalidRefreshToken()
{
_jwtService.GetPrincipalFromExpiredToken(Arg.Any<string>())
.Throws(new Microsoft.IdentityModel.Tokens.SecurityTokenException("Bad sig"));
await Assert.ThrowsAsync<InvalidRefreshTokenException>(
() => _handler.Handle(new RefreshCommand("bad.access.token", "some_refresh")));
}
[Fact]
public async Task Handle_AccessTokenSubMismatch_ThrowsInvalidRefreshToken()
{
// stored token is for user 1, but access token claims user 99
var (stored, rawToken, _) = MakeActiveToken(usuarioId: 1);
var hash = TokenHasher.Sha256Base64Url(rawToken);
var mismatchPrincipal = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub, "99")]));
_jwtService.GetPrincipalFromExpiredToken(Arg.Any<string>()).Returns(mismatchPrincipal);
_refreshRepo.GetByHashAsync(hash).Returns(stored);
await Assert.ThrowsAsync<InvalidRefreshTokenException>(
() => _handler.Handle(new RefreshCommand("access_user99", rawToken)));
}
[Fact]
public async Task Handle_UsuarioInactive_ThrowsInvalidRefreshToken()
{
var (stored, rawToken, principal) = MakeActiveToken();
var hash = TokenHasher.Sha256Base64Url(rawToken);
_jwtService.GetPrincipalFromExpiredToken(Arg.Any<string>()).Returns(principal);
_refreshRepo.GetByHashAsync(hash).Returns(stored);
var inactiveUser = new Usuario(1, "admin", "$hash", "Admin", "Sys", null, "admin", "[\"*\"]", activo: false);
_usuarioRepo.GetByIdAsync(1).Returns(inactiveUser);
await Assert.ThrowsAsync<InvalidRefreshTokenException>(
() => _handler.Handle(new RefreshCommand("access", rawToken)));
}
}

View File

@@ -0,0 +1,136 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Tests.Domain;
public class RefreshTokenTests
{
// --- IssueForNewFamily ---
[Fact]
public void IssueForNewFamily_SetsNewFamilyIdAndExpiresAt()
{
var now = DateTime.UtcNow;
var ttl = TimeSpan.FromDays(7);
var token = RefreshToken.IssueForNewFamily(
usuarioId: 1,
tokenHash: "hash_abc",
now: now,
ttl: ttl,
createdByIp: "127.0.0.1",
userAgent: "Mozilla/5.0");
Assert.Equal(1, token.UsuarioId);
Assert.Equal("hash_abc", token.TokenHash);
Assert.NotEqual(Guid.Empty, token.FamilyId);
Assert.Equal(now, token.IssuedAt);
Assert.Equal(now + ttl, token.ExpiresAt);
Assert.Equal("127.0.0.1", token.CreatedByIp);
Assert.Equal("Mozilla/5.0", token.UserAgent);
Assert.Null(token.RevokedAt);
Assert.Null(token.ReplacedById);
}
[Fact]
public void IssueForNewFamily_TwoCallsProduceDifferentFamilyIds()
{
var now = DateTime.UtcNow;
var ttl = TimeSpan.FromDays(7);
var t1 = RefreshToken.IssueForNewFamily(1, "hash1", now, ttl, "127.0.0.1", null);
var t2 = RefreshToken.IssueForNewFamily(1, "hash2", now, ttl, "127.0.0.1", null);
Assert.NotEqual(t1.FamilyId, t2.FamilyId);
}
// --- IssueRotation ---
[Fact]
public void IssueRotation_InheritsFamilyIdAndExpiresAt()
{
var now = DateTime.UtcNow;
var original = RefreshToken.IssueForNewFamily(
1, "hash_original", now.AddHours(-2), TimeSpan.FromDays(7), "10.0.0.1", "UA1");
var rotationTime = now;
var rotated = RefreshToken.IssueRotation(
previous: original,
newTokenHash: "hash_new",
now: rotationTime,
createdByIp: "10.0.0.2",
userAgent: "UA2");
Assert.Equal(original.FamilyId, rotated.FamilyId); // same family
Assert.Equal(original.ExpiresAt, rotated.ExpiresAt); // ABSOLUTE — inherited
Assert.Equal(original.UsuarioId, rotated.UsuarioId);
Assert.Equal("hash_new", rotated.TokenHash);
Assert.Equal(rotationTime, rotated.IssuedAt);
Assert.Equal("10.0.0.2", rotated.CreatedByIp);
Assert.Equal("UA2", rotated.UserAgent);
Assert.Null(rotated.RevokedAt);
Assert.Null(rotated.ReplacedById);
}
// --- IsActive ---
[Fact]
public void IsActive_False_WhenRevoked()
{
var now = DateTime.UtcNow;
var token = RefreshToken.IssueForNewFamily(1, "h", now, TimeSpan.FromDays(7), "1.1.1.1", null);
token.MarkAsPersistedRevocation(now.AddSeconds(-1), replacedById: null);
Assert.False(token.IsActive(now));
Assert.True(token.IsRevoked);
}
[Fact]
public void IsActive_False_WhenExpired()
{
var now = DateTime.UtcNow;
// issued 8 days ago, ttl 7 days → expired yesterday
var token = RefreshToken.IssueForNewFamily(1, "h", now.AddDays(-8), TimeSpan.FromDays(7), "1.1.1.1", null);
Assert.False(token.IsActive(now));
Assert.True(token.IsExpired(now));
Assert.False(token.IsRevoked);
}
[Fact]
public void IsActive_True_WhenFreshAndNotRevoked()
{
var now = DateTime.UtcNow;
var token = RefreshToken.IssueForNewFamily(1, "h", now, TimeSpan.FromDays(7), "1.1.1.1", null);
Assert.True(token.IsActive(now.AddMinutes(1)));
Assert.False(token.IsRevoked);
Assert.False(token.IsExpired(now.AddMinutes(1)));
}
[Fact]
public void IsExpired_True_AtExpiresAt()
{
var now = DateTime.UtcNow;
var token = RefreshToken.IssueForNewFamily(1, "h", now, TimeSpan.FromDays(7), "1.1.1.1", null);
// exactly at ExpiresAt it is expired (>= boundary)
Assert.True(token.IsExpired(token.ExpiresAt));
// one second before: not expired
Assert.False(token.IsExpired(token.ExpiresAt.AddSeconds(-1)));
}
// --- MarkAsPersistedRevocation ---
[Fact]
public void MarkAsPersistedRevocation_SetsRevokedAtAndReplacedById()
{
var now = DateTime.UtcNow;
var token = RefreshToken.IssueForNewFamily(1, "h", now, TimeSpan.FromDays(7), "1.1.1.1", null);
token.MarkAsPersistedRevocation(now.AddSeconds(30), replacedById: 42);
Assert.Equal(now.AddSeconds(30), token.RevokedAt);
Assert.Equal(42, token.ReplacedById);
Assert.True(token.IsRevoked);
}
}

View File

@@ -0,0 +1,50 @@
using SIGCM2.Domain.Security;
namespace SIGCM2.Application.Tests.Domain;
public class TokenHasherTests
{
[Fact]
public void Sha256Base64Url_IsDeterministic()
{
const string raw = "my_test_raw_token_value_abc123";
var hash1 = TokenHasher.Sha256Base64Url(raw);
var hash2 = TokenHasher.Sha256Base64Url(raw);
Assert.Equal(hash1, hash2);
Assert.False(string.IsNullOrWhiteSpace(hash1));
}
[Fact]
public void Sha256Base64Url_ProducesUrlSafeString()
{
// Use many values to increase chance of hitting + / = in base64
for (var i = 0; i < 50; i++)
{
var raw = $"token_value_{i}_padding_test_xyz";
var hash = TokenHasher.Sha256Base64Url(raw);
Assert.DoesNotContain('+', hash);
Assert.DoesNotContain('/', hash);
Assert.DoesNotContain('=', hash);
}
}
[Fact]
public void Sha256Base64Url_DifferentInputsDifferentOutputs()
{
var hash1 = TokenHasher.Sha256Base64Url("token_a");
var hash2 = TokenHasher.Sha256Base64Url("token_b");
Assert.NotEqual(hash1, hash2);
}
[Fact]
public void Sha256Base64Url_ProducesExpectedLength()
{
// SHA-256 = 32 bytes. Base64url without padding: ceil(32 * 4/3) = 43 chars
var hash = TokenHasher.Sha256Base64Url("any_token_value");
Assert.Equal(43, hash.Length);
}
}

View File

@@ -122,6 +122,65 @@ public class JwtServiceTests : IDisposable
Assert.Equal("2", parsed2.Subject);
}
// T-040: GetPrincipalFromExpiredToken
[Fact]
public void GetPrincipalFromExpiredToken_ValidSignatureExpired_ReturnsPrincipal()
{
// Generate a token that will expire in 1 second, then manually create an expired JWT
// using JwtSecurityTokenHandler directly (bypassing the service to control timestamps).
var signingKey = new RsaSecurityKey(_rsa);
var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256);
var past = DateTime.UtcNow.AddHours(-2);
var descriptor = new SecurityTokenDescriptor
{
Subject = new System.Security.Claims.ClaimsIdentity(
[new System.Security.Claims.Claim("sub", "1")]),
Issuer = "sigcm2.api",
Audience = "sigcm2.web",
IssuedAt = past,
NotBefore = past,
Expires = past.AddMinutes(60), // expired 1h ago
SigningCredentials = credentials,
};
var handler = new JwtSecurityTokenHandler();
var token = handler.CreateToken(descriptor);
var expiredToken = handler.WriteToken(token);
// Now use the service — it must validate the signature but ignore expiry
var principal = _jwtService.GetPrincipalFromExpiredToken(expiredToken);
Assert.NotNull(principal);
// The JWT handler maps "sub" to ClaimTypes.NameIdentifier by default,
// but our JwtService uses a custom "sub" claim. Check both.
var sub = principal.FindFirst("sub")?.Value
?? principal.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
Assert.Equal("1", sub);
}
[Fact]
public void GetPrincipalFromExpiredToken_InvalidSignature_Throws()
{
// Sign with a different RSA key
using var otherRsa = System.Security.Cryptography.RSA.Create(2048);
var otherOptions = new JwtOptions { Issuer = "sigcm2.api", Audience = "sigcm2.web", AccessTokenMinutes = 60 };
var otherService = new JwtService(otherRsa, otherOptions);
var tokenFromOtherKey = otherService.GenerateAccessToken(MakeUsuario());
// Validating with the correct key should throw
Assert.Throws<Microsoft.IdentityModel.Tokens.SecurityTokenSignatureKeyNotFoundException>(
() => _jwtService.GetPrincipalFromExpiredToken(tokenFromOtherKey));
}
[Fact]
public void GetPrincipalFromExpiredToken_MalformedToken_Throws()
{
Assert.ThrowsAny<Exception>(
() => _jwtService.GetPrincipalFromExpiredToken("not.a.valid.jwt"));
}
private static Usuario MakeUsuario(int id = 1, string username = "admin")
=> new(id, username, "$2a$12$hash", "Administrador", "Sistema", null, "admin", "[\"*\"]", true);
}

View File

@@ -0,0 +1,37 @@
using SIGCM2.Infrastructure.Security;
namespace SIGCM2.Application.Tests.Infrastructure;
public class RefreshTokenGeneratorTests
{
private readonly RefreshTokenGenerator _generator = new();
[Fact]
public void Generate_ProducesBase64UrlString()
{
var token = _generator.Generate();
Assert.False(string.IsNullOrWhiteSpace(token));
// Must be base64url: no +, /, or =
Assert.DoesNotContain('+', token);
Assert.DoesNotContain('/', token);
Assert.DoesNotContain('=', token);
}
[Fact]
public void Generate_IsUnique()
{
var tokens = Enumerable.Range(0, 50).Select(_ => _generator.Generate()).ToList();
var distinct = tokens.Distinct().ToList();
Assert.Equal(tokens.Count, distinct.Count);
}
[Fact]
public void Generate_ProducesExpectedLength()
{
// 32 bytes base64url without padding = 43 chars
var token = _generator.Generate();
Assert.Equal(43, token.Length);
}
}

View File

@@ -0,0 +1,174 @@
using Dapper;
using Microsoft.Data.SqlClient;
using Respawn;
using SIGCM2.Domain.Entities;
using SIGCM2.Infrastructure.Persistence;
namespace SIGCM2.Application.Tests.Infrastructure;
/// <summary>
/// Integration tests for RefreshTokenRepository against SIGCM2_Test.
/// Uses Respawn to reset the DB between test classes; the repository opens its own
/// connections so transaction-scoped isolation would block on FK locks.
/// </summary>
[Collection("Database")]
public class RefreshTokenRepositoryTests : IAsyncLifetime
{
private const string ConnectionString =
"Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
private SqlConnection _connection = null!;
private Respawner _respawner = null!;
private RefreshTokenRepository _repository = null!;
private int _testUserId;
public async Task InitializeAsync()
{
_connection = new SqlConnection(ConnectionString);
await _connection.OpenAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer
});
await _respawner.ResetAsync(_connection);
await SeedTestUserAsync();
_testUserId = await _connection.QuerySingleAsync<int>(
"SELECT Id FROM dbo.Usuario WHERE Username = 'test_rt_user'");
var factory = new SqlConnectionFactory(ConnectionString);
_repository = new RefreshTokenRepository(factory);
}
public async Task DisposeAsync()
{
await _respawner.ResetAsync(_connection);
await _connection.CloseAsync();
await _connection.DisposeAsync();
}
private async Task SeedTestUserAsync()
{
await _connection.ExecuteAsync("""
SET QUOTED_IDENTIFIER ON;
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'test_rt_user')
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo)
VALUES ('test_rt_user', '$2a$12$testhash', 'Test', 'User', 'admin', '["*"]', 1);
""");
}
private static RefreshToken BuildToken(int usuarioId, string hash = "test_hash_abc123xyz", bool expired = false)
{
var now = DateTime.UtcNow;
var ttl = expired ? TimeSpan.FromSeconds(1) : TimeSpan.FromDays(7);
return RefreshToken.IssueForNewFamily(usuarioId, hash, now.AddHours(-1), ttl, "10.0.0.1", "TestAgent");
}
[Fact]
public async Task AddAsync_PersistsAndReturnsId()
{
var token = BuildToken(_testUserId, "unique_hash_persist_" + Guid.NewGuid().ToString("N")[..8]);
var id = await _repository.AddAsync(token);
Assert.True(id > 0);
}
[Fact]
public async Task AddAsync_DuplicateHash_Throws()
{
var hash = "duplicate_hash_" + Guid.NewGuid().ToString("N")[..8];
var token1 = BuildToken(_testUserId, hash);
var token2 = BuildToken(_testUserId, hash);
await _repository.AddAsync(token1);
await Assert.ThrowsAnyAsync<Exception>(() => _repository.AddAsync(token2));
}
[Fact]
public async Task GetByHashAsync_RoundTripsAllFields()
{
var hash = "roundtrip_hash_" + Guid.NewGuid().ToString("N")[..8];
var token = BuildToken(_testUserId, hash);
await _repository.AddAsync(token);
var retrieved = await _repository.GetByHashAsync(hash);
Assert.NotNull(retrieved);
Assert.Equal(_testUserId, retrieved.UsuarioId);
Assert.Equal(hash, retrieved.TokenHash);
Assert.Equal(token.FamilyId, retrieved.FamilyId);
Assert.Null(retrieved.RevokedAt);
Assert.Null(retrieved.ReplacedById);
}
[Fact]
public async Task GetByHashAsync_NonExistentHash_ReturnsNull()
{
var result = await _repository.GetByHashAsync("does_not_exist_hash_abc");
Assert.Null(result);
}
[Fact]
public async Task RevokeAsync_SetsRevokedAtAndReplacedById()
{
var hash = "revoke_test_" + Guid.NewGuid().ToString("N")[..8];
var token = BuildToken(_testUserId, hash);
var id = await _repository.AddAsync(token);
var revokedAt = DateTime.UtcNow;
await _repository.RevokeAsync(id, replacedById: null, revokedAt: revokedAt);
var retrieved = await _repository.GetByHashAsync(hash);
Assert.NotNull(retrieved?.RevokedAt);
Assert.Null(retrieved.ReplacedById);
}
[Fact]
public async Task RevokeFamilyAsync_OnlyAffectsMatchingFamily()
{
var hash1 = "family_a_" + Guid.NewGuid().ToString("N")[..8];
var hash2 = "family_b_" + Guid.NewGuid().ToString("N")[..8];
var tokenA = BuildToken(_testUserId, hash1);
var tokenB = BuildToken(_testUserId, hash2);
await _repository.AddAsync(tokenA);
await _repository.AddAsync(tokenB);
var count = await _repository.RevokeFamilyAsync(tokenA.FamilyId, DateTime.UtcNow);
Assert.Equal(1, count);
var retrievedA = await _repository.GetByHashAsync(hash1);
var retrievedB = await _repository.GetByHashAsync(hash2);
Assert.NotNull(retrievedA?.RevokedAt);
Assert.Null(retrievedB?.RevokedAt);
}
[Fact]
public async Task RevokeAllActiveForUserAsync_DoesNotTouchAlreadyRevoked()
{
var hash1 = "user_active_" + Guid.NewGuid().ToString("N")[..8];
var hash2 = "user_revoked_" + Guid.NewGuid().ToString("N")[..8];
var tokenActive = BuildToken(_testUserId, hash1);
var tokenAlreadyRevoked = BuildToken(_testUserId, hash2);
var idActive = await _repository.AddAsync(tokenActive);
var idRevoked = await _repository.AddAsync(tokenAlreadyRevoked);
await _repository.RevokeAsync(idRevoked, null, DateTime.UtcNow.AddMinutes(-5));
var count = await _repository.RevokeAllActiveForUserAsync(_testUserId, DateTime.UtcNow);
Assert.Equal(1, count);
var retrievedActive = await _repository.GetByHashAsync(hash1);
Assert.NotNull(retrievedActive?.RevokedAt);
}
}

View File

@@ -38,6 +38,7 @@ public sealed class TestWebAppFactory : WebApplicationFactory<Program>, IAsyncLi
["Jwt:Issuer"] = "sigcm2.api",
["Jwt:Audience"] = "sigcm2.web",
["Jwt:AccessTokenMinutes"] = "60",
["Jwt:RefreshTokenDays"] = "7",
["Jwt:PrivateKeyPath"] = PrivateKeyPath,
["Jwt:PublicKeyPath"] = PublicKeyPath,
["Jwt:PrivateKey"] = null,