diff --git a/20250910_130832_35784.gcdump b/20250910_130832_35784.gcdump
deleted file mode 100755
index e861cf6..0000000
Binary files a/20250910_130832_35784.gcdump and /dev/null differ
diff --git a/MySvelteApp.Server/Infrastructure/Authentication/JwtOptions.cs b/MySvelteApp.Server/Infrastructure/Authentication/JwtOptions.cs
new file mode 100644
index 0000000..a57f1f1
--- /dev/null
+++ b/MySvelteApp.Server/Infrastructure/Authentication/JwtOptions.cs
@@ -0,0 +1,60 @@
+using System.ComponentModel.DataAnnotations;
+using System.Text;
+
+namespace MySvelteApp.Server.Infrastructure.Authentication;
+
+///
+/// Public validator class for JwtOptions to support DataAnnotations validation.
+///
+public static class JwtOptionsValidator
+{
+ ///
+ /// Validates that a string value is not null, empty, or whitespace-only.
+ ///
+ public static ValidationResult ValidateNotWhitespace(object? value, ValidationContext context)
+ {
+ var str = value as string;
+ if (string.IsNullOrWhiteSpace(str))
+ {
+ var displayName = context.DisplayName ?? context.MemberName ?? "Value";
+ return new ValidationResult($"{displayName} cannot be blank or whitespace-only.");
+ }
+ return ValidationResult.Success!;
+ }
+
+ public static ValidationResult ValidateKeyStrength(object? value, ValidationContext context)
+ {
+ var s = value as string ?? string.Empty;
+ byte[] bytes = DeriveKeyBytes(s);
+ return bytes.Length >= 32
+ ? ValidationResult.Success!
+ : new ValidationResult("Jwt:Key must be at least 32 bytes (256-bit) after decoding.");
+ }
+
+ private static byte[] DeriveKeyBytes(string key) =>
+ key.StartsWith("base64:", StringComparison.Ordinal)
+ ? Convert.FromBase64String(key["base64:".Length..])
+ : Encoding.UTF8.GetBytes(key);
+}
+
+public sealed class JwtOptions
+{
+ [Required]
+ [MinLength(32, ErrorMessage = "Jwt:Key must be at least 32 characters long.")]
+ [CustomValidation(typeof(JwtOptionsValidator), nameof(JwtOptionsValidator.ValidateKeyStrength))]
+ public string Key { get; set; } = string.Empty;
+
+ [Required]
+ [CustomValidation(typeof(JwtOptionsValidator), nameof(JwtOptionsValidator.ValidateNotWhitespace))]
+ public string Issuer { get; set; } = string.Empty;
+
+ [Required]
+ [CustomValidation(typeof(JwtOptionsValidator), nameof(JwtOptionsValidator.ValidateNotWhitespace))]
+ public string Audience { get; set; } = string.Empty;
+
+ ///
+ /// Lifetime of the access token in hours.
+ ///
+ [Range(1, 168, ErrorMessage = "Jwt:AccessTokenLifetimeHours must be between 1 and 168 hours.")]
+ public int AccessTokenLifetimeHours { get; set; } = 24;
+}
diff --git a/MySvelteApp.Server/Infrastructure/Authentication/JwtTokenGenerator.cs b/MySvelteApp.Server/Infrastructure/Authentication/JwtTokenGenerator.cs
index 17cef23..4627718 100644
--- a/MySvelteApp.Server/Infrastructure/Authentication/JwtTokenGenerator.cs
+++ b/MySvelteApp.Server/Infrastructure/Authentication/JwtTokenGenerator.cs
@@ -1,6 +1,8 @@
+using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
+using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using MySvelteApp.Server.Application.Common.Interfaces;
using MySvelteApp.Server.Domain.Entities;
@@ -9,32 +11,40 @@ namespace MySvelteApp.Server.Infrastructure.Authentication;
public class JwtTokenGenerator : IJwtTokenGenerator
{
- private readonly IConfiguration _configuration;
+ private readonly JwtOptions _jwtOptions;
- public JwtTokenGenerator(IConfiguration configuration)
+ public JwtTokenGenerator(IOptions jwtOptions)
{
- _configuration = configuration;
+ _jwtOptions = jwtOptions.Value;
}
public string GenerateToken(User user)
{
- var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:Key"] ?? "your-secret-key-here"));
+ var keyBytes = DeriveKeyBytes(_jwtOptions.Key);
+ var securityKey = new SymmetricSecurityKey(keyBytes);
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
- new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
+ new Claim(ClaimTypes.NameIdentifier, user.Id.ToString(CultureInfo.InvariantCulture)),
+ new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString(CultureInfo.InvariantCulture)),
new Claim(ClaimTypes.Name, user.Username),
- new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
+ new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
+ new Claim(JwtRegisteredClaimNames.Iat, EpochTime.GetIntDate(DateTime.UtcNow).ToString(CultureInfo.InvariantCulture), ClaimValueTypes.Integer64)
};
var token = new JwtSecurityToken(
- issuer: _configuration["Jwt:Issuer"] ?? "your-issuer",
- audience: _configuration["Jwt:Audience"] ?? "your-audience",
+ issuer: _jwtOptions.Issuer,
+ audience: _jwtOptions.Audience,
claims: claims,
- expires: DateTime.UtcNow.AddHours(24),
+ expires: DateTime.UtcNow.AddHours(_jwtOptions.AccessTokenLifetimeHours),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
+
+ private static byte[] DeriveKeyBytes(string key) =>
+ key.StartsWith("base64:", StringComparison.Ordinal)
+ ? Convert.FromBase64String(key["base64:".Length..])
+ : Encoding.UTF8.GetBytes(key);
}
diff --git a/MySvelteApp.Server/MySvelteApp.Server.csproj b/MySvelteApp.Server/MySvelteApp.Server.csproj
index 419d4b0..5117a36 100755
--- a/MySvelteApp.Server/MySvelteApp.Server.csproj
+++ b/MySvelteApp.Server/MySvelteApp.Server.csproj
@@ -6,6 +6,13 @@
enable
MySvelteApp.Server
$(AssemblyName.Replace(' ', '_'))
+
+ false
+ $(NoWarn)
+
+
+ true
+ $(NoWarn);1591
../MySvelteApp.Client
npm run dev
diff --git a/MySvelteApp.Server/Program.cs b/MySvelteApp.Server/Program.cs
index d308766..4b70fb6 100755
--- a/MySvelteApp.Server/Program.cs
+++ b/MySvelteApp.Server/Program.cs
@@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using MySvelteApp.Server.Application.Authentication;
@@ -19,6 +20,9 @@
using OpenTelemetry.Trace;
using Serilog;
using Serilog.Sinks.Grafana.Loki;
+using System.ComponentModel.DataAnnotations;
+using Microsoft.AspNetCore.Diagnostics;
+using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
@@ -29,34 +33,58 @@
options.AddPolicy(WebsiteClientOrigin, policy =>
{
policy
- .WithOrigins("http://localhost:5173", "http://localhost:3000", "http://web:3000", "http://localhost:5173")
+ .WithOrigins("http://localhost:5173", "http://localhost:3000", "http://web:3000")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
+// Bind and validate JwtOptions
+builder.Services.AddOptions()
+ .Bind(builder.Configuration.GetSection("Jwt"))
+ .ValidateDataAnnotations()
+ .Validate(o => !string.IsNullOrWhiteSpace(o.Key), "Jwt:Key cannot be blank/whitespace.")
+ .Validate(o => !string.IsNullOrWhiteSpace(o.Issuer), "Jwt:Issuer cannot be blank/whitespace.")
+ .Validate(o => !string.IsNullOrWhiteSpace(o.Audience), "Jwt:Audience cannot be blank/whitespace.")
+ .ValidateOnStart();
+
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
- .AddJwtBearer(options =>
+ .AddJwtBearer();
+
+builder.Services
+ .AddOptions(JwtBearerDefaults.AuthenticationScheme)
+ .Configure>((options, jwt) =>
{
+ var o = jwt.Value;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
- ValidIssuer = builder.Configuration["Jwt:Issuer"] ?? "your-issuer",
- ValidAudience = builder.Configuration["Jwt:Audience"] ?? "your-audience",
- IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"] ?? "your-secret-key-here"))
+ ValidIssuer = o.Issuer,
+ ValidAudience = o.Audience,
+ IssuerSigningKey = new SymmetricSecurityKey(DeriveKeyBytes(o.Key)),
+ // Optional hardening: stricter expiry validation
+ ClockSkew = TimeSpan.Zero
};
});
+// Shared key derivation helper to prevent duplication
+static byte[] DeriveKeyBytes(string key) =>
+ key.StartsWith("base64:", StringComparison.Ordinal)
+ ? Convert.FromBase64String(key["base64:".Length..])
+ : Encoding.UTF8.GetBytes(key);
+
builder.Services.AddAuthorizationBuilder()
.SetFallbackPolicy(new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build());
builder.Services.AddControllers();
+builder.Services.AddProblemDetails();
+builder.Services.AddHealthChecks();
builder.Services.AddSwaggerGen(c =>
{
@@ -86,6 +114,13 @@
Array.Empty()
}
});
+
+ var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
+ var xmlPath = System.IO.Path.Combine(AppContext.BaseDirectory, xmlFile);
+ if (System.IO.File.Exists(xmlPath))
+ {
+ c.IncludeXmlComments(xmlPath);
+ }
});
var promtailUrl = builder.Configuration["LOKI_PUSH_URL"] ?? "http://localhost:3101/loki/api/v1/push";
@@ -134,6 +169,29 @@
var app = builder.Build();
+// Global exception handling with ProblemDetails - must be first middleware
+app.UseExceptionHandler(errorApp =>
+{
+ errorApp.Run(async context =>
+ {
+ var exceptionHandler = context.Features.Get();
+ if (exceptionHandler is not null)
+ {
+ var problemDetails = new ProblemDetails
+ {
+ Status = StatusCodes.Status500InternalServerError,
+ Title = "An unexpected error occurred.",
+ Detail = app.Environment.IsDevelopment() ? exceptionHandler.Error.Message : null,
+ Instance = context.Request.Path
+ };
+
+ context.Response.StatusCode = problemDetails.Status.Value;
+ context.Response.ContentType = "application/problem+json";
+ await context.Response.WriteAsJsonAsync(problemDetails);
+ }
+ });
+});
+
app.UseCors(WebsiteClientOrigin);
if (app.Environment.IsDevelopment())
@@ -147,4 +205,5 @@
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
+app.MapHealthChecks("/health").AllowAnonymous();
app.Run();