feat: implement token generation and issuing on successful login

This commit is contained in:
Stevan Freeborn
2026-02-02 06:32:14 -06:00
parent 822da7368c
commit b535c0a121
7 changed files with 139 additions and 18 deletions
+1
View File
@@ -3,6 +3,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+1
View File
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+24 -18
View File
@@ -14,26 +14,12 @@ internal static class Endpoint
private static async Task<IResult> 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));
}
}
+27
View File
@@ -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<JwtOptions>
{
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);
}
}
+3
View File
@@ -0,0 +1,3 @@
namespace FiscalOS.API.Login;
internal sealed record Response(string AccessToken);
+67
View File
@@ -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> 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<Claim> 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;
}
}
@@ -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,
};
}
}