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