From 36b293aa1526b72d8616827705e1f0bc7c2a48fd Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 2 Feb 2026 06:32:14 -0600 Subject: [PATCH] feat: implement token generation and issuing on successful login --- src/Directory.Packages.props | 1 + src/FiscalOS.API/FiscalOS.API.csproj | 1 + src/FiscalOS.API/Login/Endpoint.cs | 42 ++++++++------ src/FiscalOS.API/Login/JwtOptions.cs | 27 +++++++++ src/FiscalOS.API/Login/Response.cs | 3 + src/FiscalOS.API/Login/TokenService.cs | 67 ++++++++++++++++++++++ src/FiscalOS.Core/Identity/RefreshToken.cs | 16 ++++++ 7 files changed, 139 insertions(+), 18 deletions(-) create mode 100644 src/FiscalOS.API/Login/JwtOptions.cs create mode 100644 src/FiscalOS.API/Login/Response.cs create mode 100644 src/FiscalOS.API/Login/TokenService.cs diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 660dd14..92a593a 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -3,6 +3,7 @@ true + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/FiscalOS.API/FiscalOS.API.csproj b/src/FiscalOS.API/FiscalOS.API.csproj index abc943c..7648209 100644 --- a/src/FiscalOS.API/FiscalOS.API.csproj +++ b/src/FiscalOS.API/FiscalOS.API.csproj @@ -1,6 +1,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/FiscalOS.API/Login/Endpoint.cs b/src/FiscalOS.API/Login/Endpoint.cs index d0273b5..c6880bf 100644 --- a/src/FiscalOS.API/Login/Endpoint.cs +++ b/src/FiscalOS.API/Login/Endpoint.cs @@ -14,26 +14,12 @@ internal static class Endpoint private static async Task HandleAsync( [FromBody] LoginRequest loginRequest, + [FromServices] HttpContext httpContext, [FromServices] AppDbContext appDbContext, - [FromServices] IPasswordHasher passwordHasher + [FromServices] IPasswordHasher passwordHasher, + [FromServices] TokenService tokenService ) { - // TODO: Implement actual auth flow - // 1. We want to make sure that - // the user exists - // 2. We want to make sure that - // tha the password is correct - // 3. We want to issue an access token - // with a refresh token - // 4. We want to store the refresh - // token - // 5. We want to set the refresh - // token in a cookie - - // TODO: Things we need - // 1. We need a user model - // 2. We need a refresh token model - var user = await appDbContext.Users.SingleOrDefaultAsync(u => u.Username == loginRequest.Username); if (user is null) @@ -48,6 +34,26 @@ internal static class Endpoint return Results.Unauthorized(); } - return Results.Ok(); + var accessToken = tokenService.GenerateAccessToken(user); + + var refreshToken = tokenService.GenerateRefreshToken(user); + await appDbContext.RefreshTokens.AddAsync(refreshToken); + await appDbContext.SaveChangesAsync(); + + httpContext.Response.Cookies.Append( + "fiscalos_refresh_cookie", + refreshToken.Token, + new CookieOptions + { + HttpOnly = true, + Expires = refreshToken.ExpiresAt, + // TODO: Revisit when decided + // on hosting setup + SameSite = SameSiteMode.None, + Secure = true + } + ); + + return Results.Ok(new Response(accessToken)); } } \ No newline at end of file diff --git a/src/FiscalOS.API/Login/JwtOptions.cs b/src/FiscalOS.API/Login/JwtOptions.cs new file mode 100644 index 0000000..63323fe --- /dev/null +++ b/src/FiscalOS.API/Login/JwtOptions.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Options; + +namespace FiscalOS.API.Login; + +internal sealed record JwtOptions +{ + public string Issuer { get; init; } = string.Empty; + public string Audience { get; init; } = string.Empty; + public string Secret { get; init; } = string.Empty; + public int ExpiryInMinutes { get; init; } = 5; +} + +internal sealed record JwtOptionsSetup : IConfigureOptions +{ + private const string SectionName = nameof(JwtOptions); + private readonly IConfiguration _configuration; + + public JwtOptionsSetup(IConfiguration configuration) + { + _configuration = configuration; + } + + public void Configure(JwtOptions options) + { + _configuration.GetSection(SectionName).Bind(options); + } +} \ No newline at end of file diff --git a/src/FiscalOS.API/Login/Response.cs b/src/FiscalOS.API/Login/Response.cs new file mode 100644 index 0000000..d4f0523 --- /dev/null +++ b/src/FiscalOS.API/Login/Response.cs @@ -0,0 +1,3 @@ +namespace FiscalOS.API.Login; + +internal sealed record Response(string AccessToken); \ No newline at end of file diff --git a/src/FiscalOS.API/Login/TokenService.cs b/src/FiscalOS.API/Login/TokenService.cs new file mode 100644 index 0000000..f4d736f --- /dev/null +++ b/src/FiscalOS.API/Login/TokenService.cs @@ -0,0 +1,67 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; + +using FiscalOS.Core.Identity; + +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace FiscalOS.API.Login; + +internal sealed class TokenService( + TimeProvider timeProvider, + IOptions jwtOptions +) +{ + private const int RefreshTokenExpiryInHours = 12; + private readonly JwtOptions _jwtOptions = jwtOptions.Value; + private readonly TimeProvider _timeProvider = timeProvider; + + public string GenerateAccessToken(User user) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var secretKeyBytes = Encoding.UTF8.GetBytes(_jwtOptions.Secret); + var issuedAt = _timeProvider.GetUtcNow(); + var expiresAt = issuedAt.AddMinutes(_jwtOptions.ExpiryInMinutes); + List claims = [ + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + ]; + + var descriptor = new SecurityTokenDescriptor() + { + Subject = new(claims), + IssuedAt = issuedAt.UtcDateTime, + Expires = expiresAt.UtcDateTime, + Issuer = _jwtOptions.Issuer, + Audience = _jwtOptions.Audience, + SigningCredentials = new( + new SymmetricSecurityKey(secretKeyBytes), + SecurityAlgorithms.HmacSha256Signature + ), + }; + + var securityToken = tokenHandler.CreateJwtSecurityToken(descriptor); + var jwtToken = tokenHandler.WriteToken(securityToken); + + return jwtToken; + } + + public RefreshToken GenerateRefreshToken(User user) + { + var expiresAt = _timeProvider + .GetUtcNow() + .AddHours(RefreshTokenExpiryInHours); + + return RefreshToken.From(user.Id, GenerateToken(), expiresAt); + } + + private static string GenerateToken() + { + var randomBytes = RandomNumberGenerator.GetBytes(32); + var token = Convert.ToBase64String(randomBytes); + return token; + } +} diff --git a/src/FiscalOS.Core/Identity/RefreshToken.cs b/src/FiscalOS.Core/Identity/RefreshToken.cs index 9f41367..69ddce1 100644 --- a/src/FiscalOS.Core/Identity/RefreshToken.cs +++ b/src/FiscalOS.Core/Identity/RefreshToken.cs @@ -2,6 +2,22 @@ namespace FiscalOS.Core.Identity; public sealed class RefreshToken : Entity { + public string Token { get; init; } = string.Empty; + public DateTimeOffset ExpiresAt { get; init; } public Guid UserId { get; init; } public User User { get; init; } = new(); + + private RefreshToken() + { + } + + public static RefreshToken From(Guid userId, string token, DateTimeOffset expiresAt) + { + return new() + { + UserId = userId, + Token = token, + ExpiresAt = expiresAt, + }; + } } \ No newline at end of file