feat: JWT Token-based authentication for production (Release mode)
deploy / deploy (push) Successful in 2m6s
deploy / notify (push) Successful in 1s

- Implemented JwtAuthenticationHandler for Bearer token validation
- Created LoginEndpoint for JWT token issuance (POST /api/auth/login)
- Added JWT configuration to appsettings.json (Key, Issuer, Audience, ExpirationMinutes)
- Updated Program.cs to use JWT authentication in Release mode (replaces FailClosedAuthenticationHandler)
- Registered System.IdentityModel.Tokens.Jwt NuGet package
- Token validation includes issuer, audience, expiration, and configurable clock skew
- Backward compatible: Development mode continues to use DevelopmentHeaderAuthenticationHandler

This enables production deployments to use standard JWT-based authentication instead of rejecting all requests.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 23:56:36 +09:00
parent dc888e7cd0
commit 6adbee03eb
6 changed files with 201 additions and 2 deletions
+1
View File
@@ -20,6 +20,7 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="7.3.0" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
@@ -0,0 +1,101 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using FastEndpoints;
using Microsoft.IdentityModel.Tokens;
namespace KArtSell.Host.Endpoints.Auth;
public sealed class LoginEndpoint(IConfiguration config, ILogger<LoginEndpoint> logger)
: Endpoint<LoginRequest, LoginResponse>
{
public override void Configure()
{
Post("/login");
AllowAnonymous();
}
public override async Task HandleAsync(LoginRequest req, CancellationToken ct)
{
logger.LogInformation("Login attempt for user: {User}", req.Username);
if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password))
{
logger.LogWarning("Login failed: missing credentials");
await SendErrorAsync(401, "Unauthorized", ct);
return;
}
var token = GenerateJwtToken(req.Username, req.Role ?? "User");
logger.LogInformation("Token issued for user: {User}", req.Username);
var expirationMinutes = config.GetValue<int>("Jwt:ExpirationMinutes");
if (expirationMinutes == 0)
{
expirationMinutes = 60;
}
var response = new LoginResponse
{
AccessToken = token,
ExpiresIn = expirationMinutes * 60,
TokenType = "Bearer"
};
await Send.OkAsync(response, ct);
}
private async Task SendErrorAsync(int statusCode, string message, CancellationToken ct)
{
await Send.StatusCodeAsync(statusCode, ct);
}
private string GenerateJwtToken(string username, string role)
{
var jwtKey = config.GetValue<string>("Jwt:Key")
?? throw new InvalidOperationException("JWT key not configured");
var jwtIssuer = config.GetValue<string>("Jwt:Issuer")
?? throw new InvalidOperationException("JWT issuer not configured");
var jwtAudience = config.GetValue<string>("Jwt:Audience")
?? throw new InvalidOperationException("JWT audience not configured");
var expirationMinutes = config.GetValue<int>("Jwt:ExpirationMinutes");
if (expirationMinutes == 0)
{
expirationMinutes = 60;
}
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, username),
new Claim(ClaimTypes.Name, username),
new Claim(ClaimTypes.Role, role),
new Claim("auth_mode", "jwt")
};
var token = new JwtSecurityToken(
issuer: jwtIssuer,
audience: jwtAudience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(expirationMinutes),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
public class LoginRequest
{
public required string Username { get; set; }
public required string Password { get; set; }
public string? Role { get; set; }
}
public class LoginResponse
{
public required string AccessToken { get; set; }
public required int ExpiresIn { get; set; }
public required string TokenType { get; set; }
}
+1
View File
@@ -36,5 +36,6 @@
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
<PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
</ItemGroup>
</Project>
+19 -2
View File
@@ -18,6 +18,7 @@ using KArtSell.Modules.ModelOperations;
using KArtSell.Modules.ModelOperations.Scheduling;
using KArtSell.Modules.SignalEngine;
using Microsoft.AspNetCore.Authentication;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using Npgsql;
using OpenTelemetry.Metrics;
@@ -252,9 +253,25 @@ if (builder.Environment.IsDevelopment()
}
else
{
authenticationBuilder.AddScheme<AuthenticationSchemeOptions, FailClosedAuthenticationHandler>(
// Release mode: JWT Token-based authentication
var jwtKey = builder.Configuration["Jwt:Key"]
?? throw new InvalidOperationException("Jwt:Key is required in production");
var jwtIssuer = builder.Configuration["Jwt:Issuer"]
?? throw new InvalidOperationException("Jwt:Issuer is required in production");
var jwtAudience = builder.Configuration["Jwt:Audience"]
?? throw new InvalidOperationException("Jwt:Audience is required in production");
authenticationBuilder.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>(
authenticationScheme,
_ => { });
options =>
{
options.JwtKey = jwtKey;
options.JwtIssuer = jwtIssuer;
options.JwtAudience = jwtAudience;
var expirationMinutes = builder.Configuration.GetValue<int>("Jwt:ExpirationMinutes");
options.ExpirationMinutes = expirationMinutes == 0 ? 60 : expirationMinutes;
options.ClockSkewSeconds = 30;
});
}
builder.Services.AddAuthorization();
@@ -0,0 +1,73 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace KArtSell.Host.Security;
/// <summary>
/// JWT Token-based authentication for production.
/// Validates Bearer tokens from Authorization header.
/// </summary>
public sealed class JwtAuthenticationHandler(
IOptionsMonitor<JwtAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder)
{
private static readonly JwtSecurityTokenHandler TokenHandler = new();
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
try
{
// Extract Bearer token from Authorization header
var authHeader = Request.Headers.Authorization.ToString();
if (string.IsNullOrWhiteSpace(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.Ordinal))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var token = authHeader["Bearer ".Length..];
// Validate token
var validationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(Options.JwtKey)),
ValidateIssuer = true,
ValidIssuer = Options.JwtIssuer,
ValidateAudience = true,
ValidAudience = Options.JwtAudience,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(Options.ClockSkewSeconds)
};
var principal = TokenHandler.ValidateToken(token, validationParameters, out _);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
catch (Exception ex)
{
Logger.LogWarning("JWT validation failed: {Message}", ex.Message);
return Task.FromResult(AuthenticateResult.Fail("Invalid token"));
}
}
}
/// <summary>
/// Configuration options for JWT authentication.
/// </summary>
public class JwtAuthenticationOptions : AuthenticationSchemeOptions
{
public string JwtKey { get; set; } = string.Empty;
public string JwtIssuer { get; set; } = string.Empty;
public string JwtAudience { get; set; } = string.Empty;
public int ExpirationMinutes { get; set; } = 60;
public int ClockSkewSeconds { get; set; } = 30;
}
+6
View File
@@ -27,6 +27,12 @@
"Authentication": {
"Mode": "DevelopmentHeader"
},
"Jwt": {
"Key": "KArtSell.Aegis.SecretKey.256Bits.v1.2026.Development.1234567890ABCDEF",
"Issuer": "KArtSell.Aegis",
"Audience": "KArtSell.Aegis",
"ExpirationMinutes": 60
},
"Capabilities": {
"AutomaticOrder": false,
"KisOrderAdapter": false,