diff --git a/Directory.Packages.props b/Directory.Packages.props
index aa10fbb0..3950c297 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -20,6 +20,7 @@
+
diff --git a/src/KArtSell.Host/Endpoints/Auth/LoginEndpoint.cs b/src/KArtSell.Host/Endpoints/Auth/LoginEndpoint.cs
new file mode 100644
index 00000000..64957db6
--- /dev/null
+++ b/src/KArtSell.Host/Endpoints/Auth/LoginEndpoint.cs
@@ -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 logger)
+ : Endpoint
+{
+ 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("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("Jwt:Key")
+ ?? throw new InvalidOperationException("JWT key not configured");
+ var jwtIssuer = config.GetValue("Jwt:Issuer")
+ ?? throw new InvalidOperationException("JWT issuer not configured");
+ var jwtAudience = config.GetValue("Jwt:Audience")
+ ?? throw new InvalidOperationException("JWT audience not configured");
+ var expirationMinutes = config.GetValue("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; }
+}
diff --git a/src/KArtSell.Host/KArtSell.Host.csproj b/src/KArtSell.Host/KArtSell.Host.csproj
index e0e431c8..f40a2e72 100644
--- a/src/KArtSell.Host/KArtSell.Host.csproj
+++ b/src/KArtSell.Host/KArtSell.Host.csproj
@@ -36,5 +36,6 @@
+
diff --git a/src/KArtSell.Host/Program.cs b/src/KArtSell.Host/Program.cs
index fba8cf2d..d5a296ad 100644
--- a/src/KArtSell.Host/Program.cs
+++ b/src/KArtSell.Host/Program.cs
@@ -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(
+ // 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(
authenticationScheme,
- _ => { });
+ options =>
+ {
+ options.JwtKey = jwtKey;
+ options.JwtIssuer = jwtIssuer;
+ options.JwtAudience = jwtAudience;
+ var expirationMinutes = builder.Configuration.GetValue("Jwt:ExpirationMinutes");
+ options.ExpirationMinutes = expirationMinutes == 0 ? 60 : expirationMinutes;
+ options.ClockSkewSeconds = 30;
+ });
}
builder.Services.AddAuthorization();
diff --git a/src/KArtSell.Host/Security/JwtAuthenticationHandler.cs b/src/KArtSell.Host/Security/JwtAuthenticationHandler.cs
new file mode 100644
index 00000000..1d9e06bf
--- /dev/null
+++ b/src/KArtSell.Host/Security/JwtAuthenticationHandler.cs
@@ -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;
+
+///
+/// JWT Token-based authentication for production.
+/// Validates Bearer tokens from Authorization header.
+///
+public sealed class JwtAuthenticationHandler(
+ IOptionsMonitor options,
+ ILoggerFactory logger,
+ UrlEncoder encoder)
+ : AuthenticationHandler(options, logger, encoder)
+{
+ private static readonly JwtSecurityTokenHandler TokenHandler = new();
+
+ protected override Task 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"));
+ }
+ }
+}
+
+///
+/// Configuration options for JWT authentication.
+///
+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;
+}
diff --git a/src/KArtSell.Host/appsettings.json b/src/KArtSell.Host/appsettings.json
index 8a961f07..ae59cd6a 100644
--- a/src/KArtSell.Host/appsettings.json
+++ b/src/KArtSell.Host/appsettings.json
@@ -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,