diff --git a/VigilCareRecordsAPI/Configurations/JwtOptions.cs b/VigilCareRecordsAPI/Configurations/JwtOptions.cs
index 95f7c06..e723f4a 100644
--- a/VigilCareRecordsAPI/Configurations/JwtOptions.cs
+++ b/VigilCareRecordsAPI/Configurations/JwtOptions.cs
@@ -4,5 +4,6 @@ public class JwtOptions
public string Secret { get; set; } = null!;
public string Issuer { get; set; } = null!;
public string Audience { get; set; } = null!;
- public int ExpiryMinutes { get; set; } = 480;
+ public int ExpiryMinutes { get; set; } = 15;
+ public int RefreshTokenExpiryDays { get; set; } = 7;
}
\ No newline at end of file
diff --git a/VigilCareRecordsAPI/Controllers/AuthController.cs b/VigilCareRecordsAPI/Controllers/AuthController.cs
index 9c8c4e1..8dd68fa 100644
--- a/VigilCareRecordsAPI/Controllers/AuthController.cs
+++ b/VigilCareRecordsAPI/Controllers/AuthController.cs
@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Mvc;
///
-/// JWT authentication and current user info.
+/// JWT authentication, token refresh, logout, and current user info.
///
[ApiController]
[Route("api/v1/auth")]
@@ -16,7 +16,7 @@ public class AuthController : ControllerBase
public AuthController(IAuthService auth) => _auth = auth;
///
- /// Authenticates a user and returns a JWT with role claims.
+ /// Authenticates a user and returns a JWT access token, refresh token, and profile.
///
[HttpPost("login")]
[AllowAnonymous]
@@ -28,6 +28,32 @@ public class AuthController : ControllerBase
return Ok(ApiResponse.Ok(result));
}
+ ///
+ /// Rotates the refresh token and issues a new access token.
+ ///
+ [HttpPost("refresh")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)]
+ public async Task Refresh([FromBody] RefreshRequest req)
+ {
+ var result = await _auth.RefreshAsync(req);
+ return Ok(ApiResponse.Ok(result));
+ }
+
+ ///
+ /// Revokes the refresh token server-side.
+ ///
+ [HttpPost("logout")]
+ [AllowAnonymous]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)]
+ public async Task Logout([FromBody] LogoutRequest req)
+ {
+ await _auth.LogoutAsync(req);
+ return NoContent();
+ }
+
///
/// Returns the current authenticated user's profile.
///
@@ -42,4 +68,4 @@ public class AuthController : ControllerBase
return Ok(ApiResponse.Ok(new UserProfileResponse(
user.Id, user.Username, user.FullName, user.Role.ToDbString())));
}
-}
\ No newline at end of file
+}
diff --git a/VigilCareRecordsAPI/Data/AppDbContext.cs b/VigilCareRecordsAPI/Data/AppDbContext.cs
index 1f5c91e..c50750d 100644
--- a/VigilCareRecordsAPI/Data/AppDbContext.cs
+++ b/VigilCareRecordsAPI/Data/AppDbContext.cs
@@ -11,6 +11,8 @@ public class AppDbContext : DbContext
public DbSet DraftEncounters => Set();
public DbSet DraftObservations => Set();
public DbSet DigitizationEvents => Set();
+ public DbSet RefreshTokens => Set();
+ public DbSet AuthAuditEvents => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
diff --git a/VigilCareRecordsAPI/Data/Configurations/AuthAuditEventConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/AuthAuditEventConfiguration.cs
new file mode 100644
index 0000000..8fa1ca5
--- /dev/null
+++ b/VigilCareRecordsAPI/Data/Configurations/AuthAuditEventConfiguration.cs
@@ -0,0 +1,30 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+public class AuthAuditEventConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("auth_audit_events", t =>
+ {
+ t.HasCheckConstraint("chk_auth_audit_events_event_type",
+ "event_type IN ('USER_LOGOUT', 'TOKEN_REFRESHED')");
+ });
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
+ builder.Property(e => e.UserId).HasColumnName("user_id").IsRequired();
+ builder.Property(e => e.EventType)
+ .HasColumnName("event_type")
+ .HasMaxLength(30)
+ .HasConversion(
+ v => v.ToDbString(),
+ v => AuthAuditEventTypeExtensions.FromDbString(v))
+ .IsRequired();
+ builder.Property(e => e.OccurredAt).HasColumnName("occurred_at").HasDefaultValueSql("NOW()");
+ builder.Property(e => e.MetadataJson).HasColumnName("metadata_json").HasColumnType("jsonb");
+
+ builder.HasOne(e => e.User).WithMany().HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasIndex(e => new { e.UserId, e.OccurredAt });
+ }
+}
diff --git a/VigilCareRecordsAPI/Data/Configurations/RefreshTokenConfiguration.cs b/VigilCareRecordsAPI/Data/Configurations/RefreshTokenConfiguration.cs
new file mode 100644
index 0000000..9b8c5b7
--- /dev/null
+++ b/VigilCareRecordsAPI/Data/Configurations/RefreshTokenConfiguration.cs
@@ -0,0 +1,25 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+public class RefreshTokenConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("refresh_tokens");
+ builder.HasKey(t => t.Id);
+ builder.Property(t => t.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
+ builder.Property(t => t.UserId).HasColumnName("user_id").IsRequired();
+ builder.Property(t => t.TokenHash).HasColumnName("token_hash").HasMaxLength(64).IsRequired();
+ builder.Property(t => t.ExpiresAt).HasColumnName("expires_at").IsRequired();
+ builder.Property(t => t.RevokedAt).HasColumnName("revoked_at");
+ builder.Property(t => t.ReplacedByTokenId).HasColumnName("replaced_by_token_id");
+ builder.Property(t => t.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
+
+ builder.HasOne(t => t.User).WithMany().HasForeignKey(t => t.UserId).OnDelete(DeleteBehavior.Cascade);
+ builder.HasOne(t => t.ReplacedByToken).WithMany().HasForeignKey(t => t.ReplacedByTokenId)
+ .OnDelete(DeleteBehavior.SetNull);
+
+ builder.HasIndex(t => t.TokenHash).IsUnique();
+ builder.HasIndex(t => new { t.UserId, t.RevokedAt });
+ }
+}
diff --git a/VigilCareRecordsAPI/Data/Migrations/20260625202947_AddRefreshTokensAndAuthAudit.Designer.cs b/VigilCareRecordsAPI/Data/Migrations/20260625202947_AddRefreshTokensAndAuthAudit.Designer.cs
new file mode 100644
index 0000000..f2cf9fa
--- /dev/null
+++ b/VigilCareRecordsAPI/Data/Migrations/20260625202947_AddRefreshTokensAndAuthAudit.Designer.cs
@@ -0,0 +1,713 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace VigilCareRecordsAPI.Data.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260625202947_AddRefreshTokensAndAuthAudit")]
+ partial class AddRefreshTokensAndAuthAudit
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.4")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("AuthAuditEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("EventType")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("event_type");
+
+ b.Property("MetadataJson")
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata_json");
+
+ b.Property("OccurredAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("occurred_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "OccurredAt");
+
+ b.ToTable("auth_audit_events", null, t =>
+ {
+ t.HasCheckConstraint("chk_auth_audit_events_event_type", "event_type IN ('USER_LOGOUT', 'TOKEN_REFRESHED')");
+ });
+ });
+
+ modelBuilder.Entity("DigitizationBatch", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("ApprovedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("approved_by_user_id");
+
+ b.Property("BatchType")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("batch_type");
+
+ b.Property("ClinicianAttestation")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("clinician_attestation");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("DocumentRef")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("document_ref");
+
+ b.Property("DocumentSha256")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("document_sha256");
+
+ b.Property("EnableRetroactiveAlerts")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("enable_retroactive_alerts");
+
+ b.Property("EncounterDraftId")
+ .HasColumnType("uuid")
+ .HasColumnName("encounter_draft_id");
+
+ b.Property("EnteredByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("entered_by_user_id");
+
+ b.Property("PatientId")
+ .HasColumnType("uuid")
+ .HasColumnName("patient_id");
+
+ b.Property("PromotedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("promoted_at");
+
+ b.Property("PromotionEncounterId")
+ .HasColumnType("uuid")
+ .HasColumnName("promotion_encounter_id");
+
+ b.Property("RejectionReason")
+ .HasColumnType("text")
+ .HasColumnName("rejection_reason");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("status")
+ .HasDefaultValueSql("'UPLOADED'");
+
+ b.Property("SupersedesBatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("supersedes_batch_id");
+
+ b.Property("Track")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("track")
+ .HasDefaultValueSql("'BACKFILL'");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("VerifiedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("verified_by_user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApprovedByUserId");
+
+ b.HasIndex("EnteredByUserId");
+
+ b.HasIndex("Status");
+
+ b.HasIndex("SupersedesBatchId")
+ .HasFilter("supersedes_batch_id IS NOT NULL");
+
+ b.HasIndex("VerifiedByUserId");
+
+ b.HasIndex("DocumentSha256", "PatientId", "CreatedAt");
+
+ b.ToTable("digitization_batches", null, t =>
+ {
+ t.HasCheckConstraint("chk_batches_batch_type", "batch_type IN ('PATIENT_REGISTRATION', 'ENCOUNTER_SUMMARY', 'VITALS_SHEET', 'LAB_RESULTS', 'MEDICATION_LIST', 'ALLERGY_UPDATE', 'MIXED')");
+
+ t.HasCheckConstraint("chk_batches_status", "status IN ('UPLOADED', 'IN_ENTRY', 'PENDING_VERIFICATION', 'REJECTED', 'VERIFIED', 'AWAITING_CLINICAL_APPROVAL', 'APPROVED', 'PROMOTED')");
+
+ t.HasCheckConstraint("chk_batches_track", "track IN ('BACKFILL', 'LIVE_CAPTURE')");
+ });
+ });
+
+ modelBuilder.Entity("DigitizationEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("ActorUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("actor_user_id");
+
+ b.Property("BatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("batch_id");
+
+ b.Property("EventType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("event_type");
+
+ b.Property("MetadataJson")
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata_json");
+
+ b.Property("OccurredAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("occurred_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ActorUserId");
+
+ b.HasIndex("BatchId", "OccurredAt");
+
+ b.ToTable("digitization_events", null, t =>
+ {
+ t.HasCheckConstraint("chk_digitization_events_event_type", "event_type IN ('uploaded', 'entry_started', 'submitted_for_verification', 'rejected', 'verified', 'verified_pending_clinical', 'awaiting_clinical_approval', 'approved', 'promoted', 'live_capture_attested', 'correction_requested', 'verification_failed', 'superseded', 'correction_promoted', 'correction_uploaded', 'promotion_retry_succeeded', 'promotion_retry_failed', 'promotion_retry_exhausted', 'promotion_failed')");
+ });
+ });
+
+ modelBuilder.Entity("DraftEncounter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("AdmissionDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("admission_date");
+
+ b.Property("AdmissionReason")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("admission_reason");
+
+ b.Property("BatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("batch_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("Department")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("department");
+
+ b.Property("DischargeDiagnosis")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("discharge_diagnosis");
+
+ b.Property("RoomBed")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("room_bed");
+
+ b.Property("Status")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("status");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BatchId")
+ .IsUnique();
+
+ b.ToTable("draft_encounters", null, t =>
+ {
+ t.HasCheckConstraint("chk_draft_encounters_department", "department IS NULL OR department IN ('Emergency Department', 'Internal Medicine', 'General Medicine', 'Surgery', 'ICU', 'NICU', 'Medical-Surgical', 'Outpatient Clinic', 'Pediatrics', 'Obstetrics & Gynecology', 'Labor & Delivery', 'Cardiology', 'Orthopedics', 'Neurology', 'Oncology', 'Radiology', 'Laboratory', 'Psychiatry', 'Physical Therapy', 'Anesthesiology')");
+ });
+ });
+
+ modelBuilder.Entity("DraftObservation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("BatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("batch_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("Note")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("note");
+
+ b.Property("ObservationCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("observation_code");
+
+ b.Property("RecordedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("recorded_at");
+
+ b.Property("Unit")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("unit");
+
+ b.Property("Value")
+ .HasColumnType("decimal(10,3)")
+ .HasColumnName("value");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BatchId", "ObservationCode");
+
+ b.ToTable("draft_observations", (string)null);
+ });
+
+ modelBuilder.Entity("DraftPatient", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("AllergiesJson")
+ .HasColumnType("jsonb")
+ .HasColumnName("allergies_json");
+
+ b.Property("BatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("batch_id");
+
+ b.Property("BloodType")
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)")
+ .HasColumnName("blood_type");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("DateOfBirth")
+ .HasColumnType("date")
+ .HasColumnName("date_of_birth");
+
+ b.Property("EmergencyContact")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("emergency_contact");
+
+ b.Property("FullName")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("full_name");
+
+ b.Property("MedicationsJson")
+ .HasColumnType("jsonb")
+ .HasColumnName("medications_json");
+
+ b.Property("NoActiveMedications")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("no_active_medications");
+
+ b.Property("NoKnownAllergies")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("no_known_allergies");
+
+ b.Property("Sex")
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)")
+ .HasColumnName("sex");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BatchId")
+ .IsUnique();
+
+ b.ToTable("draft_patients", null, t =>
+ {
+ t.HasCheckConstraint("chk_draft_patients_blood_type", "blood_type IS NULL OR blood_type IN ('A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-')");
+ });
+ });
+
+ modelBuilder.Entity("RefreshToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("ReplacedByTokenId")
+ .HasColumnType("uuid")
+ .HasColumnName("replaced_by_token_id");
+
+ b.Property("RevokedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("revoked_at");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("token_hash");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ReplacedByTokenId");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.HasIndex("UserId", "RevokedAt");
+
+ b.ToTable("refresh_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("ScannedDocument", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("BatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("batch_id");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("content_type");
+
+ b.Property("FileSizeBytes")
+ .HasColumnType("bigint")
+ .HasColumnName("file_size_bytes");
+
+ b.Property("ObjectKey")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("object_key");
+
+ b.Property("Sha256")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("sha256");
+
+ b.Property("UploadedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("uploaded_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BatchId")
+ .IsUnique();
+
+ b.HasIndex("Sha256");
+
+ b.ToTable("scanned_documents", (string)null);
+ });
+
+ modelBuilder.Entity("User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("full_name");
+
+ b.Property("IsActive")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true)
+ .HasColumnName("is_active");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("password_hash");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("role");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("username");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("users", null, t =>
+ {
+ t.HasCheckConstraint("chk_users_role", "role IN ('INTAKE_CLERK', 'DATA_ENTRY_CLERK', 'VERIFIER', 'CLINICAL_APPROVER', 'CLINICIAN', 'ADMINISTRATOR')");
+ });
+ });
+
+ modelBuilder.Entity("AuthAuditEvent", b =>
+ {
+ b.HasOne("User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("DigitizationBatch", b =>
+ {
+ b.HasOne("User", "ApprovedByUser")
+ .WithMany()
+ .HasForeignKey("ApprovedByUserId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("User", "EnteredByUser")
+ .WithMany()
+ .HasForeignKey("EnteredByUserId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("User", "VerifiedByUser")
+ .WithMany()
+ .HasForeignKey("VerifiedByUserId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.Navigation("ApprovedByUser");
+
+ b.Navigation("EnteredByUser");
+
+ b.Navigation("VerifiedByUser");
+ });
+
+ modelBuilder.Entity("DigitizationEvent", b =>
+ {
+ b.HasOne("User", "Actor")
+ .WithMany()
+ .HasForeignKey("ActorUserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("DigitizationBatch", "Batch")
+ .WithMany("Events")
+ .HasForeignKey("BatchId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Actor");
+
+ b.Navigation("Batch");
+ });
+
+ modelBuilder.Entity("DraftEncounter", b =>
+ {
+ b.HasOne("DigitizationBatch", "Batch")
+ .WithOne("DraftEncounter")
+ .HasForeignKey("DraftEncounter", "BatchId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Batch");
+ });
+
+ modelBuilder.Entity("DraftObservation", b =>
+ {
+ b.HasOne("DigitizationBatch", "Batch")
+ .WithMany("DraftObservations")
+ .HasForeignKey("BatchId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Batch");
+ });
+
+ modelBuilder.Entity("DraftPatient", b =>
+ {
+ b.HasOne("DigitizationBatch", "Batch")
+ .WithOne("DraftPatient")
+ .HasForeignKey("DraftPatient", "BatchId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Batch");
+ });
+
+ modelBuilder.Entity("RefreshToken", b =>
+ {
+ b.HasOne("RefreshToken", "ReplacedByToken")
+ .WithMany()
+ .HasForeignKey("ReplacedByTokenId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("ReplacedByToken");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("ScannedDocument", b =>
+ {
+ b.HasOne("DigitizationBatch", "Batch")
+ .WithOne("Document")
+ .HasForeignKey("ScannedDocument", "BatchId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Batch");
+ });
+
+ modelBuilder.Entity("DigitizationBatch", b =>
+ {
+ b.Navigation("Document");
+
+ b.Navigation("DraftEncounter");
+
+ b.Navigation("DraftObservations");
+
+ b.Navigation("DraftPatient");
+
+ b.Navigation("Events");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/VigilCareRecordsAPI/Data/Migrations/20260625202947_AddRefreshTokensAndAuthAudit.cs b/VigilCareRecordsAPI/Data/Migrations/20260625202947_AddRefreshTokensAndAuthAudit.cs
new file mode 100644
index 0000000..64de243
--- /dev/null
+++ b/VigilCareRecordsAPI/Data/Migrations/20260625202947_AddRefreshTokensAndAuthAudit.cs
@@ -0,0 +1,97 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace VigilCareRecordsAPI.Data.Migrations
+{
+ ///
+ public partial class AddRefreshTokensAndAuthAudit : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "auth_audit_events",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
+ user_id = table.Column(type: "uuid", nullable: false),
+ event_type = table.Column(type: "character varying(30)", maxLength: 30, nullable: false),
+ occurred_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
+ metadata_json = table.Column(type: "jsonb", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_auth_audit_events", x => x.id);
+ table.CheckConstraint("chk_auth_audit_events_event_type", "event_type IN ('USER_LOGOUT', 'TOKEN_REFRESHED')");
+ table.ForeignKey(
+ name: "FK_auth_audit_events_users_user_id",
+ column: x => x.user_id,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "refresh_tokens",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
+ user_id = table.Column(type: "uuid", nullable: false),
+ token_hash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false),
+ expires_at = table.Column(type: "timestamp with time zone", nullable: false),
+ revoked_at = table.Column(type: "timestamp with time zone", nullable: true),
+ replaced_by_token_id = table.Column(type: "uuid", nullable: true),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_refresh_tokens", x => x.id);
+ table.ForeignKey(
+ name: "FK_refresh_tokens_refresh_tokens_replaced_by_token_id",
+ column: x => x.replaced_by_token_id,
+ principalTable: "refresh_tokens",
+ principalColumn: "id",
+ onDelete: ReferentialAction.SetNull);
+ table.ForeignKey(
+ name: "FK_refresh_tokens_users_user_id",
+ column: x => x.user_id,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_auth_audit_events_user_id_occurred_at",
+ table: "auth_audit_events",
+ columns: new[] { "user_id", "occurred_at" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_refresh_tokens_replaced_by_token_id",
+ table: "refresh_tokens",
+ column: "replaced_by_token_id");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_refresh_tokens_token_hash",
+ table: "refresh_tokens",
+ column: "token_hash",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_refresh_tokens_user_id_revoked_at",
+ table: "refresh_tokens",
+ columns: new[] { "user_id", "revoked_at" });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "auth_audit_events");
+
+ migrationBuilder.DropTable(
+ name: "refresh_tokens");
+ }
+ }
+}
diff --git a/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs b/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs
index df20805..3e01b44 100644
--- a/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs
+++ b/VigilCareRecordsAPI/Data/Migrations/AppDbContextModelSnapshot.cs
@@ -21,6 +21,44 @@ namespace VigilCareRecordsAPI.Data.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+ modelBuilder.Entity("AuthAuditEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("EventType")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("event_type");
+
+ b.Property("MetadataJson")
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata_json");
+
+ b.Property("OccurredAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("occurred_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "OccurredAt");
+
+ b.ToTable("auth_audit_events", null, t =>
+ {
+ t.HasCheckConstraint("chk_auth_audit_events_event_type", "event_type IN ('USER_LOGOUT', 'TOKEN_REFRESHED')");
+ });
+ });
+
modelBuilder.Entity("DigitizationBatch", b =>
{
b.Property("Id")
@@ -385,6 +423,54 @@ namespace VigilCareRecordsAPI.Data.Migrations
});
});
+ modelBuilder.Entity("RefreshToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("NOW()");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("ReplacedByTokenId")
+ .HasColumnType("uuid")
+ .HasColumnName("replaced_by_token_id");
+
+ b.Property("RevokedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("revoked_at");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("token_hash");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ReplacedByTokenId");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.HasIndex("UserId", "RevokedAt");
+
+ b.ToTable("refresh_tokens", (string)null);
+ });
+
modelBuilder.Entity("ScannedDocument", b =>
{
b.Property("Id")
@@ -490,6 +576,17 @@ namespace VigilCareRecordsAPI.Data.Migrations
});
});
+ modelBuilder.Entity("AuthAuditEvent", b =>
+ {
+ b.HasOne("User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
modelBuilder.Entity("DigitizationBatch", b =>
{
b.HasOne("User", "ApprovedByUser")
@@ -566,6 +663,24 @@ namespace VigilCareRecordsAPI.Data.Migrations
b.Navigation("Batch");
});
+ modelBuilder.Entity("RefreshToken", b =>
+ {
+ b.HasOne("RefreshToken", "ReplacedByToken")
+ .WithMany()
+ .HasForeignKey("ReplacedByTokenId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("ReplacedByToken");
+
+ b.Navigation("User");
+ });
+
modelBuilder.Entity("ScannedDocument", b =>
{
b.HasOne("DigitizationBatch", "Batch")
diff --git a/VigilCareRecordsAPI/Domain/Entities/AuthAuditEvent.cs b/VigilCareRecordsAPI/Domain/Entities/AuthAuditEvent.cs
new file mode 100644
index 0000000..85d7a82
--- /dev/null
+++ b/VigilCareRecordsAPI/Domain/Entities/AuthAuditEvent.cs
@@ -0,0 +1,10 @@
+public class AuthAuditEvent
+{
+ public Guid Id { get; set; }
+ public Guid UserId { get; set; }
+ public AuthAuditEventType EventType { get; set; }
+ public DateTimeOffset OccurredAt { get; set; }
+ public string? MetadataJson { get; set; }
+
+ public User User { get; set; } = null!;
+}
diff --git a/VigilCareRecordsAPI/Domain/Entities/RefreshToken.cs b/VigilCareRecordsAPI/Domain/Entities/RefreshToken.cs
new file mode 100644
index 0000000..9f9aef0
--- /dev/null
+++ b/VigilCareRecordsAPI/Domain/Entities/RefreshToken.cs
@@ -0,0 +1,13 @@
+public class RefreshToken
+{
+ public Guid Id { get; set; }
+ public Guid UserId { get; set; }
+ public string TokenHash { get; set; } = null!;
+ public DateTimeOffset ExpiresAt { get; set; }
+ public DateTimeOffset? RevokedAt { get; set; }
+ public Guid? ReplacedByTokenId { get; set; }
+ public DateTimeOffset CreatedAt { get; set; }
+
+ public User User { get; set; } = null!;
+ public RefreshToken? ReplacedByToken { get; set; }
+}
diff --git a/VigilCareRecordsAPI/Domain/Enums/AuthAuditEventType.cs b/VigilCareRecordsAPI/Domain/Enums/AuthAuditEventType.cs
new file mode 100644
index 0000000..081940c
--- /dev/null
+++ b/VigilCareRecordsAPI/Domain/Enums/AuthAuditEventType.cs
@@ -0,0 +1,22 @@
+public enum AuthAuditEventType
+{
+ UserLogout,
+ TokenRefreshed
+}
+
+public static class AuthAuditEventTypeExtensions
+{
+ public static string ToDbString(this AuthAuditEventType t) => t switch
+ {
+ AuthAuditEventType.UserLogout => "USER_LOGOUT",
+ AuthAuditEventType.TokenRefreshed => "TOKEN_REFRESHED",
+ _ => throw new ArgumentOutOfRangeException(nameof(t))
+ };
+
+ public static AuthAuditEventType FromDbString(string v) => v switch
+ {
+ "USER_LOGOUT" => AuthAuditEventType.UserLogout,
+ "TOKEN_REFRESHED" => AuthAuditEventType.TokenRefreshed,
+ _ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown auth audit event type: '{v}'")
+ };
+}
diff --git a/VigilCareRecordsAPI/Models/Records/Auth/LoginResponse.cs b/VigilCareRecordsAPI/Models/Records/Auth/LoginResponse.cs
index 0aa7446..bbbe12d 100644
--- a/VigilCareRecordsAPI/Models/Records/Auth/LoginResponse.cs
+++ b/VigilCareRecordsAPI/Models/Records/Auth/LoginResponse.cs
@@ -1 +1,7 @@
-public record LoginResponse(string Token, Guid UserId, string Username, string FullName, string Role);
\ No newline at end of file
+public record LoginResponse(
+ string Token,
+ string RefreshToken,
+ Guid UserId,
+ string Username,
+ string FullName,
+ string Role);
\ No newline at end of file
diff --git a/VigilCareRecordsAPI/Models/Records/Auth/LogoutRequest.cs b/VigilCareRecordsAPI/Models/Records/Auth/LogoutRequest.cs
new file mode 100644
index 0000000..89ec01d
--- /dev/null
+++ b/VigilCareRecordsAPI/Models/Records/Auth/LogoutRequest.cs
@@ -0,0 +1 @@
+public record LogoutRequest(string RefreshToken);
diff --git a/VigilCareRecordsAPI/Models/Records/Auth/RefreshRequest.cs b/VigilCareRecordsAPI/Models/Records/Auth/RefreshRequest.cs
new file mode 100644
index 0000000..02d785e
--- /dev/null
+++ b/VigilCareRecordsAPI/Models/Records/Auth/RefreshRequest.cs
@@ -0,0 +1 @@
+public record RefreshRequest(string RefreshToken);
diff --git a/VigilCareRecordsAPI/Models/Records/Auth/TokenResponse.cs b/VigilCareRecordsAPI/Models/Records/Auth/TokenResponse.cs
new file mode 100644
index 0000000..43fd9cf
--- /dev/null
+++ b/VigilCareRecordsAPI/Models/Records/Auth/TokenResponse.cs
@@ -0,0 +1 @@
+public record TokenResponse(string Token, string RefreshToken);
diff --git a/VigilCareRecordsAPI/Program.cs b/VigilCareRecordsAPI/Program.cs
index 771cb83..609a5e6 100644
--- a/VigilCareRecordsAPI/Program.cs
+++ b/VigilCareRecordsAPI/Program.cs
@@ -1,8 +1,10 @@
-using System.Text;using Microsoft.AspNetCore.Authentication.JwtBearer;
+using System.Text;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Minio;
using Serilog;
+using StackExchange.Redis;
try
{
@@ -19,6 +21,10 @@ try
builder.Services.AddDbContext(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
+ // Redis
+ builder.Services.AddSingleton(
+ ConnectionMultiplexer.Connect(builder.Configuration["Redis:ConnectionString"]!));
+
// MinIO
var minioOptions = builder.Configuration.GetSection(MinioOptions.Section).Get()!;
builder.Services.AddSingleton(new MinioClient()
diff --git a/VigilCareRecordsAPI/Services/AuthService.cs b/VigilCareRecordsAPI/Services/AuthService.cs
index eff01b8..21032a4 100644
--- a/VigilCareRecordsAPI/Services/AuthService.cs
+++ b/VigilCareRecordsAPI/Services/AuthService.cs
@@ -1,5 +1,6 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
+using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@@ -25,8 +26,37 @@ public class AuthService : IAuthService
if (!user.IsActive)
throw new ConflictException("Account is disabled.", "ACCOUNT_DISABLED");
- var token = GenerateJwt(user);
- return new LoginResponse(token, user.Id, user.Username, user.FullName, user.Role.ToDbString());
+ var (accessToken, refreshToken) = await IssueTokenPairAsync(user);
+ return new LoginResponse(
+ accessToken,
+ refreshToken,
+ user.Id,
+ user.Username,
+ user.FullName,
+ user.Role.ToDbString());
+ }
+
+ public async Task RefreshAsync(RefreshRequest req)
+ {
+ var storedToken = await FindValidRefreshTokenAsync(req.RefreshToken);
+ var user = await _db.Users.FindAsync(storedToken.UserId);
+ if (user is null || !user.IsActive)
+ throw new ValidationException("Invalid refresh token.", "INVALID_REFRESH_TOKEN");
+
+ storedToken.RevokedAt = DateTimeOffset.UtcNow;
+
+ var (accessToken, refreshToken) = await IssueTokenPairAsync(user, storedToken);
+ await WriteAuthAuditEventAsync(user.Id, AuthAuditEventType.TokenRefreshed);
+
+ return new TokenResponse(accessToken, refreshToken);
+ }
+
+ public async Task LogoutAsync(LogoutRequest req)
+ {
+ var storedToken = await FindValidRefreshTokenAsync(req.RefreshToken);
+ storedToken.RevokedAt = DateTimeOffset.UtcNow;
+ await WriteAuthAuditEventAsync(storedToken.UserId, AuthAuditEventType.UserLogout);
+ await _db.SaveChangesAsync();
}
public async Task GetCurrentUserAsync(Guid userId)
@@ -37,6 +67,60 @@ public class AuthService : IAuthService
return user;
}
+ private async Task<(string AccessToken, string RefreshToken)> IssueTokenPairAsync(
+ User user,
+ RefreshToken? replacedToken = null)
+ {
+ var accessToken = GenerateJwt(user);
+ var refreshTokenValue = GenerateRefreshToken();
+ var refreshTokenEntity = new RefreshToken
+ {
+ Id = Guid.NewGuid(),
+ UserId = user.Id,
+ TokenHash = HashToken(refreshTokenValue),
+ ExpiresAt = DateTimeOffset.UtcNow.AddDays(_jwtOptions.RefreshTokenExpiryDays),
+ CreatedAt = DateTimeOffset.UtcNow
+ };
+
+ if (replacedToken is not null)
+ {
+ replacedToken.ReplacedByTokenId = refreshTokenEntity.Id;
+ }
+
+ _db.RefreshTokens.Add(refreshTokenEntity);
+ await _db.SaveChangesAsync();
+
+ return (accessToken, refreshTokenValue);
+ }
+
+ private async Task FindValidRefreshTokenAsync(string refreshToken)
+ {
+ var tokenHash = HashToken(refreshToken);
+ var storedToken = await _db.RefreshTokens
+ .FirstOrDefaultAsync(t => t.TokenHash == tokenHash);
+
+ if (storedToken is null
+ || storedToken.RevokedAt is not null
+ || storedToken.ExpiresAt <= DateTimeOffset.UtcNow)
+ {
+ throw new ValidationException("Invalid refresh token.", "INVALID_REFRESH_TOKEN");
+ }
+
+ return storedToken;
+ }
+
+ private async Task WriteAuthAuditEventAsync(Guid userId, AuthAuditEventType eventType)
+ {
+ _db.AuthAuditEvents.Add(new AuthAuditEvent
+ {
+ Id = Guid.NewGuid(),
+ UserId = userId,
+ EventType = eventType,
+ OccurredAt = DateTimeOffset.UtcNow
+ });
+ await _db.SaveChangesAsync();
+ }
+
private string GenerateJwt(User user)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.Secret));
@@ -59,4 +143,16 @@ public class AuthService : IAuthService
return new JwtSecurityTokenHandler().WriteToken(token);
}
-}
\ No newline at end of file
+
+ private static string GenerateRefreshToken()
+ {
+ var bytes = RandomNumberGenerator.GetBytes(64);
+ return Convert.ToBase64String(bytes);
+ }
+
+ private static string HashToken(string token)
+ {
+ var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token));
+ return Convert.ToHexString(bytes).ToLowerInvariant();
+ }
+}
diff --git a/VigilCareRecordsAPI/Services/Interfaces/IAuthService.cs b/VigilCareRecordsAPI/Services/Interfaces/IAuthService.cs
index afea19b..d0fb8c4 100644
--- a/VigilCareRecordsAPI/Services/Interfaces/IAuthService.cs
+++ b/VigilCareRecordsAPI/Services/Interfaces/IAuthService.cs
@@ -1,5 +1,7 @@
public interface IAuthService
{
Task LoginAsync(LoginRequest req);
+ Task RefreshAsync(RefreshRequest req);
+ Task LogoutAsync(LogoutRequest req);
Task GetCurrentUserAsync(Guid userId);
-}
\ No newline at end of file
+}
diff --git a/VigilCareRecordsAPI/appsettings.json b/VigilCareRecordsAPI/appsettings.json
index 95294a6..d85fca3 100644
--- a/VigilCareRecordsAPI/appsettings.json
+++ b/VigilCareRecordsAPI/appsettings.json
@@ -20,7 +20,8 @@
"Secret": "VigilCareRecordsDevSecretKeyAtLeast32Chars!",
"Issuer": "VigilCareRecords",
"Audience": "VigilCareRecords",
- "ExpiryMinutes": 480
+ "ExpiryMinutes": 15,
+ "RefreshTokenExpiryDays": 7
},
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.Seq" ],
diff --git a/docs/vigilcare-records-prd.md b/docs/vigilcare-records-prd.md
index 2b9daa3..d0bc428 100644
--- a/docs/vigilcare-records-prd.md
+++ b/docs/vigilcare-records-prd.md
@@ -464,10 +464,13 @@ Reject is allowed from `pending_verification` (verifier; separation of duties ap
**Description:** JWT auth with role claims. Every state transition writes a `DigitizationEvent`. Document access logged.
**Endpoints:**
-- `POST /api/v1/auth/login`
+- `POST /api/v1/auth/login` — returns access token (15 min), refresh token (7 days), and user profile
+- `POST /api/v1/auth/refresh` — rotates refresh token and issues new access token
+- `POST /api/v1/auth/logout` — revokes refresh token server-side
- `GET /api/v1/auth/me`
**Audit requirements:**
+- Auth events (`USER_LOGOUT`, `TOKEN_REFRESHED`) persisted in `auth_audit_events`
- Who viewed a scan and when
- Who changed which draft field (field-level diff in event metadata on save)
- Who approved promotion and which live record IDs were created