using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Nexus.Api.Contracts; using Nexus.Api.Domain; using Nexus.Api.Infrastructure; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Security.Cryptography; using System.Text; namespace Nexus.Api.Services; public interface IAuthService { Task LoginAsync(LoginRequest request, CancellationToken ct = default); Task RefreshAsync(string refreshToken, CancellationToken ct = default); Task RevokeAsync(string refreshToken, CancellationToken ct = default); Task GetUserAsync(Guid userId, CancellationToken ct = default); Task UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default); Task ChangePasswordAsync(Guid userId, ChangePasswordRequest request, CancellationToken ct = default); } public sealed record AuthSession( string AccessToken, string RefreshToken, DateTimeOffset ExpiresAt, UserInfo User); public sealed class AuthService : IAuthService { private readonly NexusDbContext _db; private readonly IConfiguration _config; private readonly ILogger _logger; public AuthService(NexusDbContext db, IConfiguration config, ILogger logger) { _db = db; _config = config; _logger = logger; } public async Task LoginAsync(LoginRequest request, CancellationToken ct = default) { var normalizedEmail = NormalizeEmail(request.Email); var user = await _db.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct); if (user is null || !PasswordSecurity.Verify(request.Password, user.PasswordHash, out var needsUpgrade)) { _logger.LogWarning("Rejected login attempt"); return null; } if (needsUpgrade) user.PasswordHash = PasswordSecurity.Hash(request.Password); user.LastLoginAt = DateTimeOffset.UtcNow; user.UpdatedAt = DateTimeOffset.UtcNow; await RemoveExpiredTokensAsync(user.Id, ct); return await CreateSessionAsync(user, Guid.NewGuid(), null, ct); } public async Task RefreshAsync(string refreshToken, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(refreshToken)) return null; var tokenHash = HashToken(refreshToken); var token = await _db.RefreshTokens .Include(r => r.User) .FirstOrDefaultAsync(r => r.TokenHash == tokenHash, ct); if (token is null) return null; if (token.RevokedAt is not null) { await RevokeFamilyAsync(token.FamilyId, ct); _logger.LogWarning("Refresh token reuse detected for family {FamilyId}", token.FamilyId); return null; } if (token.ExpiresAt <= DateTimeOffset.UtcNow) return null; return await CreateSessionAsync(token.User, token.FamilyId, token, ct); } public async Task RevokeAsync(string refreshToken, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(refreshToken)) return; var tokenHash = HashToken(refreshToken); var token = await _db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash, ct); if (token is null || token.RevokedAt is not null) return; token.RevokedAt = DateTimeOffset.UtcNow; token.ConcurrencyStamp = Guid.NewGuid(); await _db.SaveChangesAsync(ct); } public Task GetUserAsync(Guid userId, CancellationToken ct = default) => _db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == userId, ct); public async Task UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default) { var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct); if (user is null) return null; if (!string.IsNullOrWhiteSpace(request.DisplayName)) { user.DisplayName = request.DisplayName.Trim(); } user.UpdatedAt = DateTimeOffset.UtcNow; await _db.SaveChangesAsync(ct); return user; } public async Task ChangePasswordAsync(Guid userId, ChangePasswordRequest request, CancellationToken ct = default) { var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct); if (user is null) return false; if (!PasswordSecurity.Verify(request.CurrentPassword, user.PasswordHash, out _)) return false; user.PasswordHash = PasswordSecurity.Hash(request.NewPassword); user.UpdatedAt = DateTimeOffset.UtcNow; await _db.SaveChangesAsync(ct); return true; } private async Task CreateSessionAsync( NexusUser user, Guid familyId, RefreshToken? replacedToken, CancellationToken ct) { var accessExpiresAt = DateTimeOffset.UtcNow.AddMinutes(GetAccessTokenExpirationMinutes()); var rawRefreshToken = GenerateRefreshToken(); var refreshTokenHash = HashToken(rawRefreshToken); if (replacedToken is not null) { replacedToken.RevokedAt = DateTimeOffset.UtcNow; replacedToken.ReplacedByTokenHash = refreshTokenHash; replacedToken.ConcurrencyStamp = Guid.NewGuid(); } _db.RefreshTokens.Add(new RefreshToken { UserId = user.Id, TokenHash = refreshTokenHash, FamilyId = familyId, ExpiresAt = DateTimeOffset.UtcNow.AddDays(GetRefreshTokenExpirationDays()) }); try { await _db.SaveChangesAsync(ct); } catch (DbUpdateConcurrencyException) { _logger.LogWarning("Concurrent refresh token rotation rejected"); return null; } return new AuthSession( GenerateAccessToken(user, accessExpiresAt), rawRefreshToken, accessExpiresAt, ToUserInfo(user)); } private string GenerateAccessToken(NexusUser user, DateTimeOffset expiresAt) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(GetRequiredConfig("Jwt:Key"))); var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var claims = new[] { new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), new Claim(JwtRegisteredClaimNames.Email, user.Email), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(ClaimTypes.Role, user.Role), new Claim("display_name", user.DisplayName) }; var token = new JwtSecurityToken( issuer: GetRequiredConfig("Jwt:Issuer"), audience: GetRequiredConfig("Jwt:Audience"), claims: claims, notBefore: DateTime.UtcNow, expires: expiresAt.UtcDateTime, signingCredentials: credentials); return new JwtSecurityTokenHandler().WriteToken(token); } private async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct) { var activeTokens = await _db.RefreshTokens .Where(r => r.FamilyId == familyId && r.RevokedAt == null) .ToListAsync(ct); var now = DateTimeOffset.UtcNow; foreach (var token in activeTokens) { token.RevokedAt = now; token.ConcurrencyStamp = Guid.NewGuid(); } await _db.SaveChangesAsync(ct); } private async Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct) { var cutoff = DateTimeOffset.UtcNow.AddDays(-30); var oldTokens = await _db.RefreshTokens .Where(r => r.UserId == userId && (r.ExpiresAt < DateTimeOffset.UtcNow || r.RevokedAt < cutoff)) .ToListAsync(ct); if (oldTokens.Count > 0) _db.RefreshTokens.RemoveRange(oldTokens); } private static string GenerateRefreshToken() { var value = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); return value.TrimEnd('=').Replace('+', '-').Replace('/', '_'); } private static string HashToken(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); public static string NormalizeEmail(string email) => email.Trim().ToUpperInvariant(); private static UserInfo ToUserInfo(NexusUser user) => new() { Id = user.Id, Email = user.Email, DisplayName = user.DisplayName, Role = user.Role }; private string GetRequiredConfig(string key) => _config[key] ?? throw new InvalidOperationException($"Missing required configuration: {key}"); private int GetAccessTokenExpirationMinutes() => _config.GetValue("Jwt:AccessTokenExpirationMinutes") ?? 15; private int GetRefreshTokenExpirationDays() => _config.GetValue("Jwt:RefreshTokenExpirationDays") ?? 7; }