using System.Security.Cryptography; using System.Text; using Azaion.Common; using Azaion.Common.Configs; using Azaion.Common.Database; using Azaion.Common.Entities; using LinqToDB; using Microsoft.Extensions.Options; namespace Azaion.Services; /// /// AZ-531 — issues, rotates, and validates opaque refresh tokens. Reuse-detection /// kills the entire session family per OAuth 2.1 §6.1. /// public interface IRefreshTokenService { /// /// Mint a fresh refresh token at login; starts a new session family. Returns /// the opaque token (NEVER persisted; only its sha256 lands in the DB) and /// the session row that backs it. is pinned /// to the session so refresh-token rotation inherits the original AMR strength. /// Task<(string OpaqueToken, Session Session)> IssueForNewLogin(Guid userId, bool mfaAuthenticated = false, CancellationToken ct = default); /// /// Rotate . On success returns the new token + /// the new session row. On reuse-detection or invalid token throws /// with ; /// reuse also revokes every active row in the same family. /// Task<(string OpaqueToken, Session Session)> Rotate(string opaqueToken, CancellationToken ct = default); } public class RefreshTokenService(IDbFactory dbFactory, IOptions sessionConfig) : IRefreshTokenService { private const int OpaqueTokenBytes = 32; // 256 bits → 43-char base64url string. private readonly SessionConfig _cfg = sessionConfig.Value; public async Task<(string OpaqueToken, Session Session)> IssueForNewLogin(Guid userId, bool mfaAuthenticated = false, CancellationToken ct = default) { var (opaque, hash) = GenerateToken(); var now = DateTime.UtcNow; var session = new Session { Id = Guid.NewGuid(), UserId = userId, RefreshHash = hash, FamilyId = Guid.NewGuid(), // self-rooted family IssuedAt = now, LastUsedAt = now, ExpiresAt = now.AddHours(_cfg.RefreshSlidingHours), FamilyStartedAt = now, MfaAuthenticated = mfaAuthenticated, }; // family_id should equal id for the root row so SELECT family_id from // any row returns a stable handle even if id is renamed later. session.FamilyId = session.Id; await dbFactory.RunAdmin(async db => await db.InsertAsync(session, token: ct)); return (opaque, session); } public async Task<(string OpaqueToken, Session Session)> Rotate(string opaqueToken, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(opaqueToken)) throw new BusinessException(ExceptionEnum.InvalidRefreshToken); var hash = HashToken(opaqueToken); var now = DateTime.UtcNow; return await dbFactory.RunAdmin(async db => { // Use a serializable transaction so two concurrent refreshes can't both // observe the row as un-rotated and both succeed. await using var tx = await db.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct); var current = await db.Sessions.FirstOrDefaultAsync(s => s.RefreshHash == hash, token: ct); if (current == null) throw new BusinessException(ExceptionEnum.InvalidRefreshToken); // Reuse detection: presenting an already-rotated token kills the family. if (current.RevokedAt.HasValue) { if (current.RevokedReason == SessionRevokedReasons.Rotated) { await db.Sessions .Where(s => s.FamilyId == current.FamilyId && s.RevokedAt == null) .Set(s => s.RevokedAt, now) .Set(s => s.RevokedReason, SessionRevokedReasons.ReuseDetected) .UpdateAsync(token: ct); await tx.CommitAsync(ct); } throw new BusinessException(ExceptionEnum.InvalidRefreshToken); } // Sliding expiry — each rotation restarts the window from `now`. if (current.ExpiresAt < now) throw new BusinessException(ExceptionEnum.InvalidRefreshToken); // Absolute expiry — the family cannot live past this regardless of rotations. if ((now - current.FamilyStartedAt).TotalHours > _cfg.RefreshAbsoluteHours) throw new BusinessException(ExceptionEnum.InvalidRefreshToken); var (newOpaque, newHash) = GenerateToken(); var newSession = new Session { Id = Guid.NewGuid(), UserId = current.UserId, RefreshHash = newHash, FamilyId = current.FamilyId, IssuedAt = now, LastUsedAt = now, ExpiresAt = now.AddHours(_cfg.RefreshSlidingHours), FamilyStartedAt = current.FamilyStartedAt, ParentSessionId = current.Id, MfaAuthenticated = current.MfaAuthenticated, }; await db.Sessions .Where(s => s.Id == current.Id && s.RevokedAt == null) .Set(s => s.RevokedAt, now) .Set(s => s.RevokedReason, SessionRevokedReasons.Rotated) .Set(s => s.LastUsedAt, now) .UpdateAsync(token: ct); await db.InsertAsync(newSession, token: ct); await tx.CommitAsync(ct); return (newOpaque, newSession); }); } private static (string Opaque, string Hash) GenerateToken() { var raw = RandomNumberGenerator.GetBytes(OpaqueTokenBytes); var opaque = Base64Url(raw); var hash = HashToken(opaque); return (opaque, hash); } private static string HashToken(string opaque) { var bytes = Encoding.ASCII.GetBytes(opaque); var digest = SHA256.HashData(bytes); return Convert.ToHexString(digest); } private static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); }