diff --git a/PostFundManagement.Api/Extensions/Api.cs b/PostFundManagement.Api/Extensions/Api.cs new file mode 100644 index 0000000..2f3c3f5 --- /dev/null +++ b/PostFundManagement.Api/Extensions/Api.cs @@ -0,0 +1,124 @@ +using PostFundManagement.Domain.Api.Configuration; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Sdk; +using PostFundManagement.Domain.Services; +using PostFundManagement.Infrastructure.Database; + +namespace PostFundManagement.Api.Extensions; + +public static class Api +{ + public static IServiceCollection AddSecurityApiSdk(this IServiceCollection services, IConfiguration configuration) + { + var configSection = configuration.GetSection(nameof(SecurityClientSettings)); + + var authOptions = new SecurityClientSettings(); + configSection.Bind(authOptions); + + services.Configure(configSection); + + if (string.IsNullOrWhiteSpace(authOptions.Authority)) + return services; + + if (!authOptions.Authority.EndsWith("/", StringComparison.Ordinal)) authOptions.Authority += "/"; + + services.AddRefitClient() + .ConfigureHttpClient(config => + { + config.BaseAddress = new Uri(authOptions.Authority); + config.Timeout = TimeSpan.FromSeconds(15); + }) + .AddStandardResilienceHandler(options => + { + options.Retry.MaxRetryAttempts = 3; + options.Retry.Delay = TimeSpan.FromSeconds(1); + options.Retry.BackoffType = Polly.DelayBackoffType.Exponential; + }); + + services.AddScoped(); + + return services; + } + + public static IServiceCollection AddWebSecurity(this IServiceCollection services, IConfiguration configuration) + { + var certString = configuration["DataProtection:Certificate"] ?? configuration["DataProtection__Certificate"]; + var certPassword = configuration["DataProtection:Password"] ?? configuration["DataProtection__Password"]; + + if (string.IsNullOrEmpty(certString)) + throw new InvalidOperationException("Data Protection Certificate configuration is missing."); + + var certificate = X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(certString), certPassword); + + services.AddDataProtection().PersistKeysToDbContext() + .ProtectKeysWithCertificate(certificate) + .SetApplicationName("LiteCharmsApp"); + + services.Configure(options => options.ApplicationDiscriminator = "LiteCharmsApp"); + + services.ConfigureCookieOidcSameSiteSupport(); + + var configSection = configuration.GetSection(nameof(SecuritySettings)); + + var authOptions = new SecuritySettings(); + configSection.Bind(authOptions); + + services.Configure(configSection); + + services.AddAuthentication(options => + { + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; + }) + .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => + { + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.Name = "LiteCharmsApp.Session"; + }) + .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options => + { + options.Authority = authOptions.Authority; + options.ClientId = authOptions.ClientId; + options.ClientSecret = authOptions.ClientSecret; + options.ResponseType = "code"; + + options.SaveTokens = true; + options.GetClaimsFromUserInfoEndpoint = true; + options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always; + options.CorrelationCookie.SameSite = SameSiteMode.None; + + options.NonceCookie.SecurePolicy = CookieSecurePolicy.Always; + options.NonceCookie.SameSite = SameSiteMode.None; + + options.ForwardSignOut = CookieAuthenticationDefaults.AuthenticationScheme; + + options.Scope.Clear(); + options.Scope.Add("openid"); + options.Scope.Add("profile"); + options.Scope.Add("email"); + + options.Events = new OpenIdConnectEvents + { + OnRedirectToIdentityProviderForSignOut = context => + { + var idToken = context.ProtocolMessage.IdTokenHint; + + if (string.IsNullOrEmpty(idToken)) + { + var tokens = context.Properties.GetTokens(); + var idTokenItem = tokens.FirstOrDefault(t => string.Equals(t.Name, "id_token", StringComparison.Ordinal)); + + if (idTokenItem != null) context.ProtocolMessage.IdTokenHint = idTokenItem.Value; + } + + return Task.CompletedTask; + }, + }; + }); + + services.AddCascadingAuthenticationState(); + + return services; + } +} \ No newline at end of file diff --git a/PostFundManagement.Api/PostFundManagement.Api.csproj b/PostFundManagement.Api/PostFundManagement.Api.csproj index a7e664e..f1c628a 100644 --- a/PostFundManagement.Api/PostFundManagement.Api.csproj +++ b/PostFundManagement.Api/PostFundManagement.Api.csproj @@ -13,4 +13,19 @@ + + + + + + + + + + + + + + + diff --git a/PostFundManagement.Api/Program.cs b/PostFundManagement.Api/Program.cs index 60e8031..23c494a 100644 --- a/PostFundManagement.Api/Program.cs +++ b/PostFundManagement.Api/Program.cs @@ -2,13 +2,10 @@ using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); -// Add services to the container. -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); var app = builder.Build(); -// Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.MapScalarApiReference(options => @@ -16,33 +13,10 @@ if (app.Environment.IsDevelopment()) options.WithTitle("Post-fund Management API") .WithTheme(ScalarTheme.BluePlanet); }); + app.MapOpenApi(); } app.UseHttpsRedirection(); -var summaries = new[] -{ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; - -app.MapGet("/weatherforecast", () => -{ - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; -}) -.WithName("GetWeatherForecast"); - app.Run(); - -record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} diff --git a/PostFundManagement.Domain/Abstractions/EventBase.cs b/PostFundManagement.Domain/Abstractions/EventBase.cs new file mode 100644 index 0000000..af940a7 --- /dev/null +++ b/PostFundManagement.Domain/Abstractions/EventBase.cs @@ -0,0 +1,12 @@ +using static PostFundManagement.Domain.Extensions.Timezones; + +namespace PostFundManagement.Domain.Abstractions; + +public abstract class EventBase +{ + public Guid Id { get; set; } = Guid.CreateVersion7(); + + public DateTimeOffset EnqueueAt { get; set; } = (DateTimeOffset)SouthAfricanTimeZone.UtcNow(); + + public string CorrelationId { get; set; } = Guid.CreateVersion7().ToString(); +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Abstractions/IEvent.cs b/PostFundManagement.Domain/Abstractions/IEvent.cs new file mode 100644 index 0000000..09464e8 --- /dev/null +++ b/PostFundManagement.Domain/Abstractions/IEvent.cs @@ -0,0 +1,12 @@ +namespace PostFundManagement.Domain.Abstractions; + +public interface IEvent : INotification +{ + Guid Id { get; set; } + + string Name { get; set; } + + DateTimeOffset EnqueueAt { get; set; } + + string CorrelationId { get; set; } +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Abstractions/IJobOrchestrator.cs b/PostFundManagement.Domain/Abstractions/IJobOrchestrator.cs new file mode 100644 index 0000000..d92a19e --- /dev/null +++ b/PostFundManagement.Domain/Abstractions/IJobOrchestrator.cs @@ -0,0 +1,12 @@ +namespace PostFundManagement.Domain.Abstractions; + +public interface IJobOrchestrator +{ + ValueTask SendAsync(TNotification notification, CancellationToken cancellationToken = default) + where TNotification : IEvent; + + ValueTask ScheduleAsync(TNotification notification, string cronExpression, CancellationToken cancellationToken = default) + where TNotification : IEvent; + + ValueTask InterruptAsync(string eventName, string? correlationId = null, CancellationToken cancellationToken = default); +} diff --git a/PostFundManagement.Domain/Api/ApiVersionTargetAttribute.cs b/PostFundManagement.Domain/Api/ApiVersionTargetAttribute.cs new file mode 100644 index 0000000..2e0d1d1 --- /dev/null +++ b/PostFundManagement.Domain/Api/ApiVersionTargetAttribute.cs @@ -0,0 +1,7 @@ +namespace PostFundManagement.Domain.Api; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class ApiVersionTargetAttribute(int majorVersion) : Attribute +{ + public int MajorVersion { get; } = majorVersion; +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Api/Configuration/SecurityClientSettings.cs b/PostFundManagement.Domain/Api/Configuration/SecurityClientSettings.cs new file mode 100644 index 0000000..0c383dc --- /dev/null +++ b/PostFundManagement.Domain/Api/Configuration/SecurityClientSettings.cs @@ -0,0 +1,14 @@ +namespace PostFundManagement.Domain.Api.Configuration; + +public sealed class SecurityClientSettings +{ + public string? Authority { get; set; } + + public string? GrantType { get; set; } + + public string? ClientId { get; set; } + + public string? ClientSecret { get; set; } + + public string? Scope { get; set; } +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Api/Configuration/SecuritySettings.cs b/PostFundManagement.Domain/Api/Configuration/SecuritySettings.cs new file mode 100644 index 0000000..349a194 --- /dev/null +++ b/PostFundManagement.Domain/Api/Configuration/SecuritySettings.cs @@ -0,0 +1,12 @@ +namespace PostFundManagement.Domain.Api.Configuration; + +public sealed class SecuritySettings +{ + public string? Authority { get; set; } + + public string? ClientId { get; set; } + + public string? ClientSecret { get; set; } + + public string? Audience { get; set; } +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Api/Models/TokenErrorResponse.cs b/PostFundManagement.Domain/Api/Models/TokenErrorResponse.cs new file mode 100644 index 0000000..00c8e48 --- /dev/null +++ b/PostFundManagement.Domain/Api/Models/TokenErrorResponse.cs @@ -0,0 +1,13 @@ +namespace PostFundManagement.Domain.Api.Models; + +public sealed class TokenErrorResponse +{ + [JsonPropertyName("error")] + public string? Error { get; set; } + + [JsonPropertyName("error_description")] + public string? ErrorDescription { get; set; } + + [JsonPropertyName("error_uri")] + public string? ErrorUri { get; set; } +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Api/Models/TokenRequest.cs b/PostFundManagement.Domain/Api/Models/TokenRequest.cs new file mode 100644 index 0000000..6114592 --- /dev/null +++ b/PostFundManagement.Domain/Api/Models/TokenRequest.cs @@ -0,0 +1,20 @@ +namespace PostFundManagement.Domain.Api.Models; + +public sealed class TokenRequest +{ + [JsonPropertyName("grant_type")] + [AliasAs("grant_type")] + public string? GrantType { get; set; } + + [JsonPropertyName("client_id")] + [AliasAs("client_id")] + public string? ClientId { get; set; } + + [JsonPropertyName("client_secret")] + [AliasAs("client_secret")] + public string? ClientSecret { get; set; } + + [JsonPropertyName("scope")] + [AliasAs("scope")] + public string? Scope { get; set; } +} diff --git a/PostFundManagement.Domain/Api/Models/TokenResponse.cs b/PostFundManagement.Domain/Api/Models/TokenResponse.cs new file mode 100644 index 0000000..f79e736 --- /dev/null +++ b/PostFundManagement.Domain/Api/Models/TokenResponse.cs @@ -0,0 +1,16 @@ +namespace PostFundManagement.Domain.Api.Models; + +public sealed class TokenResponse +{ + [JsonPropertyName("access_token")] + public string? AccessToken { get; set; } + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } + + [JsonPropertyName("token_type")] + public string? TokenType { get; set; } + + [JsonPropertyName("scope")] + public string? Scope { get; set; } +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Api/OpenApiBearerSecuritySchemeTransformer.cs b/PostFundManagement.Domain/Api/OpenApiBearerSecuritySchemeTransformer.cs new file mode 100644 index 0000000..d090efc --- /dev/null +++ b/PostFundManagement.Domain/Api/OpenApiBearerSecuritySchemeTransformer.cs @@ -0,0 +1,16 @@ +namespace PostFundManagement.Domain.Api; + +public sealed class OpenApiBearerSecuritySchemeTransformer : IOpenApiDocumentTransformer +{ + public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + var bearerScheme = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + Description = "JWT Authorization header using the Bearer scheme", + }; + + document.AddComponent("Bearer", bearerScheme); + } +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Extensions/Api.cs b/PostFundManagement.Domain/Extensions/Api.cs new file mode 100644 index 0000000..dd39fa6 --- /dev/null +++ b/PostFundManagement.Domain/Extensions/Api.cs @@ -0,0 +1,177 @@ +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Api; +using PostFundManagement.Domain.Api.Configuration; + +namespace PostFundManagement.Domain.Extensions; + +public static class Api +{ + public static void ConfigureCookieOidcSameSiteSupport(this IServiceCollection services) => + services.Configure(options => + { + options.MinimumSameSitePolicy = SameSiteMode.Unspecified; + options.OnAppendCookie = cookieContext => CheckSameSite(cookieContext.Context, cookieContext.CookieOptions); + options.OnDeleteCookie = cookieContext => CheckSameSite(cookieContext.Context, cookieContext.CookieOptions); + }); + + public static void CheckSameSite(HttpContext httpContext, CookieOptions options) + { + if (options.SameSite == SameSiteMode.None) + { + bool isSecure = httpContext.Request.IsHttps; + + if (!isSecure && httpContext.Request.Headers.TryGetValue("X-Forwarded-Proto", out var proto)) + isSecure = string.Equals(proto, "https", StringComparison.OrdinalIgnoreCase); + + if (!isSecure && httpContext.Request.Headers.TryGetValue("Forwarded", out var forwarded)) + isSecure = forwarded.ToString().Contains("proto=https", StringComparison.OrdinalIgnoreCase); + + if (!isSecure) options.SameSite = SameSiteMode.Unspecified; + } + } + + public static IServiceCollection AddLiteCharmsApiSecurity(this IServiceCollection services, IConfiguration configuration) + { + var configSection = configuration.GetSection(nameof(SecuritySettings)); + + var authOptions = new SecuritySettings(); + configSection.Bind(authOptions); + + services.Configure(configSection); + + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = authOptions.Authority; + options.Audience = authOptions.Audience; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidIssuer = authOptions.Authority, + ValidateAudience = true, + ValidateIssuer = true, + }; + }); + + services.AddAuthorization(); + + return services; + } + + public static WebApplication AddSecurityEndpoints(this WebApplication app) + { + app.MapGet("/login", async (HttpContext context, string redirectUri = "/") => + { + await context.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties + { + RedirectUri = redirectUri, + }); + }); + + app.MapGet("/logout", async (HttpContext context, string? redirectUri = null) => + { + var idToken = await context.GetTokenAsync("id_token"); + + if (string.IsNullOrWhiteSpace(redirectUri)) + { + var host = context.Request.Host.ToUriComponent(); + redirectUri = $"https://{host}/"; + } + + var authProperties = new AuthenticationProperties { RedirectUri = redirectUri, }; + + if (!string.IsNullOrEmpty(idToken)) + authProperties.Parameters.Add("id_token_hint", idToken); + + await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); + }); + + return app; + } + + public static IServiceCollection AddApiServices(this IServiceCollection services, IConfiguration configuration) + { + services.AddHttpClient(); + + services.AddApiVersioning(options => + { + options.ReportApiVersions = true; + options.AssumeDefaultVersionWhenUnspecified = true; + options.ApiVersionReader = ApiVersionReader.Combine(new UrlSegmentApiVersionReader(), + new QueryStringApiVersionReader("version"), + new QueryStringApiVersionReader("version"), + new MediaTypeApiVersionReader("version")); + }) + .AddApiExplorer(options => + { + options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = true; + }); + + var urls = configuration["ASPNETCORE_URLS"] ?? configuration["Urls"]; + var healthUrl = "http://localhost:8080/health"; + + if (!string.IsNullOrWhiteSpace(urls)) + { + string firstUrl = urls.Split(';').FirstOrDefault(s => s.Contains("http://", StringComparison.InvariantCultureIgnoreCase))! + .Replace("0.0.0.0", "localhost") + .Replace("*", "localhost") + .Replace("+", "localhost"); + + healthUrl = $"{firstUrl.TrimEnd('/')}/health"; + } + + services.AddHealthChecksUI(setup => + { + setup.SetNotifyUnHealthyOneTimeUntilChange(); + setup.AddHealthCheckEndpoint("primary, heal", healthUrl); + setup.SetHeaderText("Midrand Books"); + }) + .AddInMemoryStorage(); + + services.AddOutputCache(options => + { + options.AddBasePolicy(builder => builder.Cache()); + options.DefaultExpirationTimeSpan = TimeSpan.FromSeconds(10); + }); + + services.AddOpenApi(options => options.AddDocumentTransformer()); + + return services; + } + + public static IApplicationBuilder MapEndpoints(this WebApplication app, IDictionary versionGroups) + { + var endpoints = app.Services.GetRequiredService>(); + + foreach (var endpoint in endpoints) + { + var versionAttributes = endpoint.GetType().GetCustomAttributes().ToList(); + + if (versionAttributes.Count != 0) + { + foreach (var attr in versionAttributes) + if (versionGroups.TryGetValue(attr.MajorVersion, out var targetGroup)) + endpoint.Map(targetGroup); + } + else + endpoint.Map(app); + } + + return app; + } + + public static IServiceCollection AddEndpoints(this IServiceCollection services, Assembly assembly) + { + ServiceDescriptor[] discriptors = [.. assembly.DefinedTypes + .Where(t => t is { IsInterface: false, IsAbstract: false }) + .Where(t => t.IsAssignableTo(typeof(IEndpoint))) + .Select(t => ServiceDescriptor.Transient(typeof(IEndpoint), t))]; + + services.TryAddEnumerable(discriptors); + + return services; + } + + public static string ToEndpointName(this Type target, string? annotation = "") => + $"{target.Name.Replace("Endpoint", string.Empty)}{annotation}".ToLower(CultureInfo.CurrentCulture); +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Extensions/Mappers.cs b/PostFundManagement.Domain/Extensions/Mappers.cs index d9153cd..104defa 100644 --- a/PostFundManagement.Domain/Extensions/Mappers.cs +++ b/PostFundManagement.Domain/Extensions/Mappers.cs @@ -21,7 +21,6 @@ public static class Mappers UpdatedAt = entity.UpdatedAt, UpdatedBy = entity.UpdatedBy }; - public static AuditLog Map(this Entities.AuditLog entity) => new() { diff --git a/PostFundManagement.Domain/Extensions/Timezones.cs b/PostFundManagement.Domain/Extensions/Timezones.cs new file mode 100644 index 0000000..407d816 --- /dev/null +++ b/PostFundManagement.Domain/Extensions/Timezones.cs @@ -0,0 +1,27 @@ +namespace PostFundManagement.Domain.Extensions; + +public static class Timezones +{ + public static TimeZoneInfo SouthAfricanTimeZone => TimeZoneInfo.FindSystemTimeZoneById("South Africa Standard Time"); + + public static string? LocaliseDateTime(this DateTime dateTime, TimeSpan offset) => offset.Hours > 0 + ? $"{dateTime:yyyy-MM-ddTHH:mm:ss.fff}+{offset.Hours:00}:{offset.Minutes:00}" + : $"{dateTime:yyyy-MM-ddTHH:mm:ss.fff}{offset.Hours:00}:{offset.Minutes:00}"; + + public static string? LocaliseDateTimeOffset(this DateTimeOffset dateTime, TimeSpan offset) => LocaliseDateTime(dateTime.DateTime, offset); + + public static DateTimeOffset ToDateTimeWithTimeZone(this DateTime source, TimeZoneInfo? timezone = null) + { + DateTime sourceDateAdjusted = source.Kind != DateTimeKind.Utc + ? new(source.Ticks, DateTimeKind.Utc) + : source; + + var localised = timezone is null + ? new DateTimeOffset(sourceDateAdjusted.Ticks, SouthAfricanTimeZone.BaseUtcOffset).LocaliseDateTimeOffset(SouthAfricanTimeZone.BaseUtcOffset) + : new DateTimeOffset(sourceDateAdjusted.Ticks, timezone!.BaseUtcOffset).LocaliseDateTimeOffset(timezone.BaseUtcOffset); + + return DateTimeOffset.Parse(localised!, CultureInfo.InvariantCulture); + } + + public static DateTime UtcNow(this TimeZoneInfo timezone) => ToDateTimeWithTimeZone(DateTime.Now, timezone).UtcDateTime; +} \ No newline at end of file diff --git a/PostFundManagement.Domain/PostFundManagement.Domain.csproj b/PostFundManagement.Domain/PostFundManagement.Domain.csproj index ab9ebe2..a0ee4f6 100644 --- a/PostFundManagement.Domain/PostFundManagement.Domain.csproj +++ b/PostFundManagement.Domain/PostFundManagement.Domain.csproj @@ -27,6 +27,7 @@ + diff --git a/PostFundManagement.Domain/Sdk/ISecurityConnectApi.cs b/PostFundManagement.Domain/Sdk/ISecurityConnectApi.cs new file mode 100644 index 0000000..eac858b --- /dev/null +++ b/PostFundManagement.Domain/Sdk/ISecurityConnectApi.cs @@ -0,0 +1,7 @@ +namespace PostFundManagement.Domain.Sdk; + +public interface ISecurityConnectApi +{ + [Post("/connect/token")] + ValueTask GetToken([Body(BodySerializationMethod.UrlEncoded)] Api.Models.TokenRequest request, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Services/TokenService.cs b/PostFundManagement.Domain/Services/TokenService.cs new file mode 100644 index 0000000..d4fcc56 --- /dev/null +++ b/PostFundManagement.Domain/Services/TokenService.cs @@ -0,0 +1,66 @@ +using PostFundManagement.Domain.Api.Configuration; +using PostFundManagement.Domain.Api.Models; +using PostFundManagement.Domain.Sdk; + +namespace PostFundManagement.Domain.Services; + +public sealed class TokenService(ISecurityConnectApi connectApi, IOptions clientOptions) +{ + private readonly SecurityClientSettings clientSettings = clientOptions.Value; + + public async Task> GenerateAsync(CancellationToken cancellationToken = default) + { + try + { + var request = new Api.Models.TokenRequest + { + ClientId = clientSettings.ClientId, + ClientSecret = clientSettings.ClientSecret, + GrantType = clientSettings.GrantType, + Scope = clientSettings.Scope, + }; + + using var response = await connectApi.GetToken(request, cancellationToken); + + var contentRaw = await response.Content.ReadAsStringAsync(cancellationToken); + + if (string.IsNullOrWhiteSpace(contentRaw)) + return Result.Fail(new Error($"The authentication endpoint returned an empty payload. Status code: {response.StatusCode}")); + + if (response.IsSuccessStatusCode) + { + var tokenResponse = JsonSerializer.Deserialize(contentRaw); + + return !string.IsNullOrWhiteSpace(tokenResponse?.AccessToken) + ? Result.Ok(tokenResponse) + : Result.Fail(new Error("Authentication succeeded, but no access token was found in the response payload.")); + } + + try + { + var errorResult = JsonSerializer.Deserialize(contentRaw); + + if (errorResult != null) + { + string summary = $"{errorResult.Error}: {errorResult.ErrorDescription}"; + + return Result.Fail(new Error(summary)); + } + } + catch + { + return Result.Fail(new Error($"Authentication failed: {contentRaw}")); + } + + return Result.Fail(new Error($"Authentication failed with status code: {response.StatusCode}")); + } + catch (OperationCanceledException ex) + { + return Result.Fail(new Error("The token generation request was canceled.").CausedBy(ex)); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } +} \ No newline at end of file diff --git a/PostFundManagement.Infrastructure/Database/DataProtectionDbContext.cs b/PostFundManagement.Infrastructure/Database/DataProtectionDbContext.cs new file mode 100644 index 0000000..d74a0c6 --- /dev/null +++ b/PostFundManagement.Infrastructure/Database/DataProtectionDbContext.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; + +namespace PostFundManagement.Infrastructure.Database; + +public sealed class DataProtectionDbContext(DbContextOptions options) : DbContext(options), IDataProtectionKeyContext +{ + public DbSet DataProtectionKeys { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => entity.ToTable(nameof(DataProtectionKeys), schema: "security")); + } +} \ No newline at end of file diff --git a/PostFundManagement.Infrastructure/Database/ApplicationDbContextFactory.cs b/PostFundManagement.Infrastructure/Database/Factories/ApplicationDbContextFactory.cs similarity index 87% rename from PostFundManagement.Infrastructure/Database/ApplicationDbContextFactory.cs rename to PostFundManagement.Infrastructure/Database/Factories/ApplicationDbContextFactory.cs index a11f435..a0f4aea 100644 --- a/PostFundManagement.Infrastructure/Database/ApplicationDbContextFactory.cs +++ b/PostFundManagement.Infrastructure/Database/Factories/ApplicationDbContextFactory.cs @@ -1,6 +1,6 @@ -using static PostFundManagement.Infrastructure.Extensions.Constants; +using static PostFundManagement.Infrastructure.Extensions.Postgres; -namespace PostFundManagement.Infrastructure.Database; +namespace PostFundManagement.Infrastructure.Database.Factories; public sealed class ApplicationDbContextFactory : IDesignTimeDbContextFactory { diff --git a/PostFundManagement.Infrastructure/Database/Factories/DataProtectionDbContextFactory.cs b/PostFundManagement.Infrastructure/Database/Factories/DataProtectionDbContextFactory.cs new file mode 100644 index 0000000..d71cad2 --- /dev/null +++ b/PostFundManagement.Infrastructure/Database/Factories/DataProtectionDbContextFactory.cs @@ -0,0 +1,20 @@ +using static PostFundManagement.Infrastructure.Extensions.Postgres; + +namespace PostFundManagement.Infrastructure.Database.Factories; + +public sealed class DataProtectionDbContextFactory : IDesignTimeDbContextFactory +{ + public DataProtectionDbContext CreateDbContext(string[] args) + { + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddUserSecrets(typeof(DataProtectionDbContext).Assembly) + .AddEnvironmentVariables() + .Build(); + + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql(configuration.GetConnectionString(DatabaseConfigName)); + + return new DataProtectionDbContext(optionsBuilder.Options); + } +} \ No newline at end of file diff --git a/PostFundManagement.Infrastructure/Database/SecurityMigrations/20260820130901_Init.Designer.cs b/PostFundManagement.Infrastructure/Database/SecurityMigrations/20260820130901_Init.Designer.cs new file mode 100644 index 0000000..7b5f27f --- /dev/null +++ b/PostFundManagement.Infrastructure/Database/SecurityMigrations/20260820130901_Init.Designer.cs @@ -0,0 +1,48 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PostFundManagement.Infrastructure.Database; + +#nullable disable + +namespace PostFundManagement.Infrastructure.Database.SecurityMigrations +{ + [DbContext(typeof(DataProtectionDbContext))] + [Migration("20260820130901_Init")] + partial class Init + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys", "security"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PostFundManagement.Infrastructure/Database/SecurityMigrations/20260820130901_Init.cs b/PostFundManagement.Infrastructure/Database/SecurityMigrations/20260820130901_Init.cs new file mode 100644 index 0000000..4eda272 --- /dev/null +++ b/PostFundManagement.Infrastructure/Database/SecurityMigrations/20260820130901_Init.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace PostFundManagement.Infrastructure.Database.SecurityMigrations +{ + /// + public partial class Init : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "security"); + + migrationBuilder.CreateTable( + name: "DataProtectionKeys", + schema: "security", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + FriendlyName = table.Column(type: "text", nullable: true), + Xml = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_DataProtectionKeys", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DataProtectionKeys", + schema: "security"); + } + } +} diff --git a/PostFundManagement.Infrastructure/Database/SecurityMigrations/DataProtectionDbContextModelSnapshot.cs b/PostFundManagement.Infrastructure/Database/SecurityMigrations/DataProtectionDbContextModelSnapshot.cs new file mode 100644 index 0000000..7b20fad --- /dev/null +++ b/PostFundManagement.Infrastructure/Database/SecurityMigrations/DataProtectionDbContextModelSnapshot.cs @@ -0,0 +1,45 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PostFundManagement.Infrastructure.Database; + +#nullable disable + +namespace PostFundManagement.Infrastructure.Database.SecurityMigrations +{ + [DbContext(typeof(DataProtectionDbContext))] + partial class DataProtectionDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys", "security"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PostFundManagement.Infrastructure/Extensions/Constants.cs b/PostFundManagement.Infrastructure/Extensions/Constants.cs deleted file mode 100644 index 66ac514..0000000 --- a/PostFundManagement.Infrastructure/Extensions/Constants.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace PostFundManagement.Infrastructure.Extensions; - -public static class Constants -{ - public const string DatabaseConfigName = "PfmDatabase"; -} \ No newline at end of file diff --git a/PostFundManagement.Infrastructure/Extensions/Postgres.cs b/PostFundManagement.Infrastructure/Extensions/Postgres.cs index 2e943c1..00dd5cf 100644 --- a/PostFundManagement.Infrastructure/Extensions/Postgres.cs +++ b/PostFundManagement.Infrastructure/Extensions/Postgres.cs @@ -1,10 +1,21 @@ using PostFundManagement.Infrastructure.Database; -using static PostFundManagement.Infrastructure.Extensions.Constants; namespace PostFundManagement.Infrastructure.Extensions; public static class Postgres { + public const string DatabaseConfigName = "PfmDatabase"; + + public static IServiceCollection AddDataProtectionDatabase(this IServiceCollection services, IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString(DatabaseConfigName); + + services.AddPooledDbContextFactory(options => + options.UseNpgsql(connectionString)); + + return services; + } + public static IServiceCollection AddApplicationDbContext(this IServiceCollection services, IConfiguration configuration) { var connectionString = configuration.GetConnectionString(DatabaseConfigName)