using LiteCharms.Features.Abstractions; using LiteCharms.Features.Api; using LiteCharms.Features.Api.Configuration; using LiteCharms.Features.Api.Sdk; using LiteCharms.Features.Postgres; namespace LiteCharms.Features.Extensions; public static class Api { public const string Books = nameof(Books); public const string Payments = nameof(Payments); public static IServiceCollection AddPayfastServices(this IServiceCollection services, IConfiguration configuration) { var configSection = configuration.GetSection(nameof(PayfastSettings)); services.Configure(configSection); return services; } public static IServiceCollection AddSecurityApiSdk(this IServiceCollection services, IConfiguration configuration) { var configSection = configuration.GetSection(nameof(LiteCharmsClientSettings)); var authOptions = new LiteCharmsClientSettings(); 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 AddLiteCharmsWebSecurity(this IServiceCollection services, IConfiguration configuration) { var certificate = X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(configuration["DataProtection:Certificate"]!), configuration["DataProtection:Password"]); services.AddDataProtection().PersistKeysToDbContext() .ProtectKeysWithCertificate(certificate) .SetApplicationName("LiteCharmsApp"); services.ConfigureCookieOidcSameSiteSupport(); var configSection = configuration.GetSection(nameof(LiteCharmsSettings)); var authOptions = new LiteCharmsSettings(); 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; } private 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); }); private static void CheckSameSite(HttpContext httpContext, CookieOptions options) { if (options.SameSite == SameSiteMode.None) if (!httpContext.Request.IsHttps && httpContext.Request.Headers["X-Forwarded-Proto"] != "https") options.SameSite = SameSiteMode.Unspecified; } public static IServiceCollection AddLiteCharmsApiSecurity(this IServiceCollection services, IConfiguration configuration) { var configSection = configuration.GetSection(nameof(LiteCharmsSettings)); var authOptions = new LiteCharmsSettings(); 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.DefaultApiVersion = new ApiVersion(1); 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://"))! .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); }