From 57c7a3a701fbf7874d05eb20857cc66e7fbf76c5 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:08:36 -0600 Subject: [PATCH] chore: configure auth schemes and add refresh token revocation migration --- .../Http/HttpContextExtensions.cs | 34 ++++++ src/FiscalOS.API/Refresh/Endpoint.cs | 60 ++++++++++ src/FiscalOS.API/Refresh/Response.cs | 22 ++++ src/FiscalOS.Infra/Authentication/Schemes.cs | 9 ++ ...dRevokedPropertyToRefreshToken.Designer.cs | 104 ++++++++++++++++++ ...164938_AddRevokedPropertyToRefreshToken.cs | 29 +++++ 6 files changed, 258 insertions(+) create mode 100644 src/FiscalOS.API/Http/HttpContextExtensions.cs create mode 100644 src/FiscalOS.API/Refresh/Endpoint.cs create mode 100644 src/FiscalOS.API/Refresh/Response.cs create mode 100644 src/FiscalOS.Infra/Authentication/Schemes.cs create mode 100644 src/FiscalOS.Infra/Migrations/20260203164938_AddRevokedPropertyToRefreshToken.Designer.cs create mode 100644 src/FiscalOS.Infra/Migrations/20260203164938_AddRevokedPropertyToRefreshToken.cs diff --git a/src/FiscalOS.API/Http/HttpContextExtensions.cs b/src/FiscalOS.API/Http/HttpContextExtensions.cs new file mode 100644 index 0000000..0f410cb --- /dev/null +++ b/src/FiscalOS.API/Http/HttpContextExtensions.cs @@ -0,0 +1,34 @@ +using FiscalOS.Core.Identity; + +namespace FiscalOS.API.Http; + +internal static class HttpContextExtensions +{ + private const string RefreshTokenCookieName = "fiscalos_refresh_cookie"; + + public static Guid GetUserId(this HttpContext context) + { + var id = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty; + return Guid.TryParse(id, out var userId) ? userId : Guid.Empty; + } + + public static void SetRefreshTokenCookie(this HttpContext context, RefreshToken token) + { + context.Response.Cookies.Append( + RefreshTokenCookieName, + token.Token, + new CookieOptions + { + HttpOnly = true, + Expires = token.ExpiresAt, + SameSite = SameSiteMode.Strict, + Secure = true + } + ); + } + + public static string GetRefreshTokenFromCookie(this HttpContext context) + { + return context.Request.Cookies[RefreshTokenCookieName] ?? string.Empty; + } +} \ No newline at end of file diff --git a/src/FiscalOS.API/Refresh/Endpoint.cs b/src/FiscalOS.API/Refresh/Endpoint.cs new file mode 100644 index 0000000..99660fd --- /dev/null +++ b/src/FiscalOS.API/Refresh/Endpoint.cs @@ -0,0 +1,60 @@ +namespace FiscalOS.API.Refresh; + +internal static class Endpoint +{ + private const string Route = "/refresh"; + + public static RouteHandlerBuilder MapRefreshEndpoint(this WebApplication app) + { + return app.MapPost(Route, HandleAsync); + } + + private static async Task HandleAsync( + HttpContext httpContext, + [FromServices] AppDbContext appDbContext, + [FromServices] TimeProvider timeProvider, + [FromServices] ITokenGenerator tokenGenerator + ) + { + var requestUserId = httpContext.GetUserId(); + var token = httpContext.GetRefreshTokenFromCookie(); + + var refreshToken = await appDbContext.RefreshTokens + .Include(t => t.User) + .SingleOrDefaultAsync(t => t.Token == token); + + if (refreshToken is null || refreshToken.User is null) + { + return Results.BadRequest(); + } + + if (refreshToken.UserId != requestUserId) + { + await appDbContext.RefreshTokens + .Where(t => t.UserId == refreshToken.UserId) + .ExecuteUpdateAsync(t => t.SetProperty(t => t.Revoked, true)); + + await appDbContext.SaveChangesAsync(); + + return Results.Forbid(); + } + + var now = timeProvider.GetUtcNow(); + + if (refreshToken.Revoked || refreshToken.IsExpired(now)) + { + return Results.BadRequest(); + } + + var accessToken = tokenGenerator.GenerateAccessToken(refreshToken.User); + var newRefreshToken = tokenGenerator.GenerateRefreshToken(refreshToken.User); + + refreshToken.Revoke(); + await appDbContext.RefreshTokens.AddAsync(newRefreshToken); + await appDbContext.SaveChangesAsync(); + + httpContext.SetRefreshTokenCookie(newRefreshToken); + + return Results.Ok(Response.From(accessToken)); + } +} \ No newline at end of file diff --git a/src/FiscalOS.API/Refresh/Response.cs b/src/FiscalOS.API/Refresh/Response.cs new file mode 100644 index 0000000..99a5c48 --- /dev/null +++ b/src/FiscalOS.API/Refresh/Response.cs @@ -0,0 +1,22 @@ +namespace FiscalOS.API.Refresh; + +internal sealed record Response +{ + public string AccessToken { get; init; } + + [JsonConstructor] + private Response() + { + AccessToken = string.Empty; + } + + private Response(string accessToken) + { + AccessToken = accessToken; + } + + public static Response From(string accessToken) + { + return new(accessToken); + } +} \ No newline at end of file diff --git a/src/FiscalOS.Infra/Authentication/Schemes.cs b/src/FiscalOS.Infra/Authentication/Schemes.cs new file mode 100644 index 0000000..f8cfcf1 --- /dev/null +++ b/src/FiscalOS.Infra/Authentication/Schemes.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace FiscalOS.Infra.Authentication; + +public static class Schemes +{ + public const string Default = JwtBearerDefaults.AuthenticationScheme; + public const string AllowExpiredTokens = "AllowExpiredTokens"; +} \ No newline at end of file diff --git a/src/FiscalOS.Infra/Migrations/20260203164938_AddRevokedPropertyToRefreshToken.Designer.cs b/src/FiscalOS.Infra/Migrations/20260203164938_AddRevokedPropertyToRefreshToken.Designer.cs new file mode 100644 index 0000000..20abd4b --- /dev/null +++ b/src/FiscalOS.Infra/Migrations/20260203164938_AddRevokedPropertyToRefreshToken.Designer.cs @@ -0,0 +1,104 @@ +// +using System; +using FiscalOS.Infra.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FiscalOS.Infra.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260203164938_AddRevokedPropertyToRefreshToken")] + partial class AddRevokedPropertyToRefreshToken + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("Revoked") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HashedPassword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b => + { + b.HasOne("FiscalOS.Core.Identity.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.User", b => + { + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FiscalOS.Infra/Migrations/20260203164938_AddRevokedPropertyToRefreshToken.cs b/src/FiscalOS.Infra/Migrations/20260203164938_AddRevokedPropertyToRefreshToken.cs new file mode 100644 index 0000000..736a3db --- /dev/null +++ b/src/FiscalOS.Infra/Migrations/20260203164938_AddRevokedPropertyToRefreshToken.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FiscalOS.Infra.Migrations +{ + /// + public partial class AddRevokedPropertyToRefreshToken : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Revoked", + table: "RefreshTokens", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Revoked", + table: "RefreshTokens"); + } + } +}