using Dapper; using DexDemoBackend; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Tokens; using Npgsql; using System.Security.Claims; var builder = WebApplication.CreateBuilder(args); // Configure AppConfig using IConfiguration builder.Services.Configure(builder.Configuration.GetSection("AppConfig")); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); var config = builder.Configuration.GetSection("AppConfig").Get() ?? new AppConfig(); ValidateConfiguration(config); ConfigureJwtAuthentication(builder.Services, config); builder.Services.AddAuthorization(); builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.SnakeCaseLower; }); var app = builder.Build(); Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; // Get current config from DI (supports reload) var currentConfig = app.Services.GetRequiredService(); app.UseCors(policy => { policy.WithOrigins(currentConfig.AllowedOrigins) .AllowAnyMethod() .AllowAnyHeader() .AllowCredentials(); }); app.UseAuthentication(); app.UseAuthorization(); // Global error handling middleware app.Use(async (context, next) => { try { await next(); } catch (UnauthorizedAccessException) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; await context.Response.WriteAsJsonAsync(new { error = "Unauthorized" }); } catch (Exception) { context.Response.StatusCode = StatusCodes.Status500InternalServerError; await context.Response.WriteAsJsonAsync(new { error = "Internal server error" }); } }); app.MapGet("/api/health", () => Results.Ok(new HealthResponse("ok"))); app.MapGet("/api/user-identity", [Authorize] (HttpContext context) => { return Results.Ok(new UserIdentityResponse( context.User.Identity?.Name, context.User.Identity?.IsAuthenticated ?? false, context.User.Identity?.AuthenticationType, context.User.Claims.Select(c => new ClaimResponse(c.Type, c.Value)).ToList() )); }); app.MapGet("/api/user-info", [Authorize] async (HttpContext context, [FromServices] AppConfig cfg) => { try { var email = await GetUserEmail(context, cfg); await using var conn = new NpgsqlConnection(cfg.ConnectionString); await conn.OpenAsync(); var userData = await conn.QueryAsync(@" SELECT u.email, u.full_name, o.id as org_id, o.name as org_name, r.id as role_id, r.name as role_name, r.description as role_description, l.id as link_id, l.title as link_title, l.url as link_url, l.description as link_description FROM users u LEFT JOIN organizations o ON u.organization_id = o.id LEFT JOIN user_roles ur ON u.id = ur.user_id LEFT JOIN roles r ON ur.role_id = r.id LEFT JOIN role_links rl ON r.id = rl.role_id LEFT JOIN links l ON rl.link_id = l.id WHERE u.email = @email ORDER BY l.id", new { email }); if (!userData.Any()) { return Results.NotFound(new { detail = "User not found in database" }); } var firstRecord = userData.First(); var organization = firstRecord.OrgId.HasValue && firstRecord.OrgName != null ? new Organization(firstRecord.OrgId.Value, firstRecord.OrgName) : null; var roles = userData .Where(x => x.RoleId.HasValue) .GroupBy(x => x.RoleId) .Select(g => g.First()) .Select(x => new Role(x.RoleId!.Value, x.RoleName!, x.RoleDescription)) .ToList(); var links = userData .Where(x => x.LinkId.HasValue) .GroupBy(x => x.LinkId) .Select(g => g.First()) .Select(x => new Link(x.LinkId!.Value, x.LinkTitle!, x.LinkUrl!, x.LinkDescription)) .ToList(); return Results.Ok(new UserInfo(firstRecord.Email, firstRecord.FullName, organization, roles, links)); } catch (NpgsqlException ex) when (ex.IsTransient) { return Results.Json(new { error = "Database temporarily unavailable" }, statusCode: 503); } catch (NpgsqlException) { return Results.Json(new { error = "Database error" }, statusCode: 500); } }); app.Run(); static void ConfigureJwtAuthentication(IServiceCollection services, AppConfig config) { services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Authority = config.Issuer; options.RequireHttpsMetadata = false; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = config.Issuer, ValidateAudience = false, ValidateLifetime = true, ValidateIssuerSigningKey = true, ClockSkew = OidcConfigConstants.ClockSkew, NameClaimType = OidcConfigConstants.NameClaimType, RoleClaimType = OidcConfigConstants.RoleClaimType }; options.Events = new JwtBearerEvents { OnTokenValidated = context => { var nameClaim = context.Principal?.FindFirst(OidcConfigConstants.NameClaimType); var emailClaim = context.Principal?.FindFirst(OidcConfigConstants.EmailClaimType); if (nameClaim == null && emailClaim != null) { var identity = context.Principal?.Identity as ClaimsIdentity; identity?.AddClaim(new Claim(OidcConfigConstants.NameClaimType, emailClaim.Value)); } return Task.CompletedTask; } }; var httpClientHandler = new HttpClientHandler(); if (config.InsecureDevMode) { httpClientHandler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true; } var discoveryUrl = $"{config.Issuer}.well-known/openid-configuration"; options.ConfigurationManager = new ConfigurationManager( discoveryUrl, new OpenIdConnectConfigurationRetriever(), new HttpDocumentRetriever(new HttpClient(httpClientHandler)) { RequireHttps = !config.InsecureDevMode } ); }); } static void ValidateConfiguration(AppConfig config) { if (string.IsNullOrWhiteSpace(config.DbHost)) throw new InvalidOperationException("Configuration parameter 'AppConfig:DbHost' is required"); if (string.IsNullOrWhiteSpace(config.DbPort)) throw new InvalidOperationException("Configuration parameter 'AppConfig:DbPort' is required"); if (string.IsNullOrWhiteSpace(config.DbName)) throw new InvalidOperationException("Configuration parameter 'AppConfig:DbName' is required"); if (string.IsNullOrWhiteSpace(config.DbUser)) throw new InvalidOperationException("Configuration parameter 'AppConfig:DbUser' is required"); if (string.IsNullOrWhiteSpace(config.DbPassword)) throw new InvalidOperationException("Configuration parameter 'AppConfig:DbPassword' is required"); if (string.IsNullOrWhiteSpace(config.Issuer)) throw new InvalidOperationException("Configuration parameter 'AppConfig:Issuer' is required"); if (!Uri.TryCreate(config.Issuer, UriKind.Absolute, out _)) throw new InvalidOperationException("Configuration parameter 'AppConfig:Issuer' must be a valid URL"); if (config.InsecureDevMode && string.IsNullOrWhiteSpace(config.InsecureDevEmail)) throw new InvalidOperationException("Configuration parameter 'AppConfig:InsecureDevEmail' is required when 'AppConfig:InsecureDevMode' is enabled"); } static Task GetUserEmail(HttpContext context, AppConfig config) { if (config.InsecureDevMode) { return Task.FromResult(config.InsecureDevEmail!); } if (context.User.Identity?.IsAuthenticated == true) { var emailClaim = context.User.FindFirst(OidcConfigConstants.EmailClaimType); if (emailClaim != null) { return Task.FromResult(emailClaim.Value); } } if (context.Request.Headers.TryGetValue(OidcConfigConstants.AuthRequestEmailHeader, out var emailHeader)) { return Task.FromResult(emailHeader.ToString()); } throw new UnauthorizedAccessException("No authentication information found"); } record UserDataResult( string Email, string FullName, int? OrgId, string? OrgName, int? RoleId, string? RoleName, string? RoleDescription, int? LinkId, string? LinkTitle, string? LinkUrl, string? LinkDescription ); // API Response Models record HealthResponse(string Status); record ClaimResponse(string Type, string Value); record UserIdentityResponse( string? Name, bool IsAuthenticated, string? AuthenticationType, List Claims ); static class OidcConfigConstants { // Default environment variable values public const string DefaultDbHost = "postgres"; public const string DefaultDbPort = "5440"; public const string DefaultDbName = "dexdemo"; public const string DefaultDbUser = "dexdemo"; public const string DefaultDbPassword = "dexdemo"; public const string DefaultDexIssuer = "https://dex.127.0.0.1.sslip.io/"; // JWT settings public const string NameClaimType = "name"; public const string RoleClaimType = "role"; public const string EmailClaimType = "email"; public const string AuthRequestEmailHeader = "X-Auth-Request-Email"; // Time settings public static readonly TimeSpan ClockSkew = TimeSpan.FromMinutes(5); }