Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed 20250910_130832_35784.gcdump
Binary file not shown.
60 changes: 60 additions & 0 deletions MySvelteApp.Server/Infrastructure/Authentication/JwtOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.ComponentModel.DataAnnotations;
using System.Text;

namespace MySvelteApp.Server.Infrastructure.Authentication;

/// <summary>
/// Public validator class for JwtOptions to support DataAnnotations validation.
/// </summary>
public static class JwtOptionsValidator
{
/// <summary>
/// Validates that a string value is not null, empty, or whitespace-only.
/// </summary>
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;

/// <summary>
/// Lifetime of the access token in hours.
/// </summary>
[Range(1, 168, ErrorMessage = "Jwt:AccessTokenLifetimeHours must be between 1 and 168 hours.")]
public int AccessTokenLifetimeHours { get; set; } = 24;
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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> 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);
}
7 changes: 7 additions & 0 deletions MySvelteApp.Server/MySvelteApp.Server.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>MySvelteApp.Server</RootNamespace>
<AssemblyName>$(AssemblyName.Replace(' ', '_'))</AssemblyName>
<!-- Enable docs + suppress "missing XML comment" only for Release -->
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<NoWarn>$(NoWarn)</NoWarn>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>

<SpaRoot>../MySvelteApp.Client</SpaRoot>
<SpaProxyLaunchCommand>npm run dev</SpaProxyLaunchCommand>
Expand Down
69 changes: 64 additions & 5 deletions MySvelteApp.Server/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);

Expand All @@ -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")
Copy link

Copilot AI Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate origin 'http://localhost:5173' was removed, but this may indicate a configuration issue. Consider using a configuration-based approach for CORS origins to avoid hardcoded values and potential duplicates.

Copilot uses AI. Check for mistakes.
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});

// Bind and validate JwtOptions
builder.Services.AddOptions<JwtOptions>()
.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<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
.Configure<IOptions<JwtOptions>>((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 =>
{
Expand Down Expand Up @@ -86,6 +114,13 @@
Array.Empty<string>()
}
});

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";
Expand Down Expand Up @@ -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<IExceptionHandlerFeature>();
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())
Expand All @@ -147,4 +205,5 @@
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health").AllowAnonymous();
app.Run();