diff --git a/PostFundManagement.Api/Endpoints/Users/GetUserEndpoint.cs b/PostFundManagement.Api/Endpoints/Users/GetUserEndpoint.cs index ccd860f..727f8ad 100644 --- a/PostFundManagement.Api/Endpoints/Users/GetUserEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Users/GetUserEndpoint.cs @@ -29,6 +29,7 @@ public sealed class GetUserEndpoint : IEndpoint .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status500InternalServerError) - .WithTags(EndpointTags.Users); + .WithTags(EndpointTags.Users) + .CacheOutput(); } } diff --git a/PostFundManagement.Api/Endpoints/Users/GetUsersEndpoint.cs b/PostFundManagement.Api/Endpoints/Users/GetUsersEndpoint.cs index aba25c5..9fde2c8 100644 --- a/PostFundManagement.Api/Endpoints/Users/GetUsersEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Users/GetUsersEndpoint.cs @@ -28,6 +28,7 @@ public class GetUsersEndpoint : IEndpoint .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status500InternalServerError) - .WithTags(EndpointTags.Users); + .WithTags(EndpointTags.Users) + .CacheOutput(); } } \ No newline at end of file diff --git a/PostFundManagement.Api/Endpoints/Users/SyncUserEndpoint.cs b/PostFundManagement.Api/Endpoints/Users/SyncUserEndpoint.cs index 1a0b93e..ab3e381 100644 --- a/PostFundManagement.Api/Endpoints/Users/SyncUserEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Users/SyncUserEndpoint.cs @@ -35,6 +35,7 @@ public class SyncUserEndpoint : IEndpoint .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status500InternalServerError) - .WithTags(EndpointTags.Users); + .WithTags(EndpointTags.Users) + .CacheOutput(); } } diff --git a/PostFundManagement.Api/Program.cs b/PostFundManagement.Api/Program.cs index 2b79b04..becd553 100644 --- a/PostFundManagement.Api/Program.cs +++ b/PostFundManagement.Api/Program.cs @@ -38,6 +38,54 @@ builder.Services.AddHealthChecks() .AddCheck("PostgresDatabase") .AddCheck("Self", () => HealthCheckResult.Healthy()); +IEnumerable identityScopes = ["openid", "profile", "roles", "email", "address"]; + +builder.Services.AddOpenApi(options => +{ + options.AddDocumentTransformer((document, context, cancellationToken) => + { + document.Components ??= new OpenApiComponents(); + document.Components?.SecuritySchemes!["OAuth2"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2, + Description = "Interactive OAuth2 flow for authentication and authorization", + Flows = new OpenApiOAuthFlows + { + AuthorizationCode = new OpenApiOAuthFlow + { + AuthorizationUrl = new Uri($"{builder.Configuration["SecuritySettings:Authority"]}/connect/authorize"), + TokenUrl = new Uri($"{builder.Configuration["SecuritySettings:Authority"]}/connect/token"), + Scopes = new Dictionary + { + { "openid", "OpenID Connect" }, + { "profile", "User Profile" }, + { "roles", "User Roles" }, + { "email", "User Email" }, + { "address", "User Address" }, + { builder.Configuration["SecuritySettings:Audience"]!, "PFM API Scope" } + } + } + } + }; + + return Task.CompletedTask; + }); + + options.AddOperationTransformer((operation, context, cancellationToken) => + { + operation.Security ??= []; + + var schemeReference = new OpenApiSecuritySchemeReference("OAuth2"); + + operation.Security.Add(new OpenApiSecurityRequirement + { + [schemeReference] = [.. identityScopes, builder.Configuration["SecuritySettings:Audience"]!] + }); + + return Task.CompletedTask; + }); +}); + var app = builder.Build(); var schedulerFactory = app.Services.GetRequiredService(); @@ -63,7 +111,7 @@ var versionGroups = new Dictionary { { 1, app.MapGroup("v{version:apiVersion}").WithApiVersionSet(versionSet) - .RequireAuthorization().AddEndpointFilter() + .RequireAuthorization("ApiScope").AddEndpointFilter() } }; @@ -86,14 +134,25 @@ var apiVersions = app.DescribeApiVersions() .ToList(); foreach (var description in app.DescribeApiVersions().OrderByDescending(o => o.ApiVersion.MajorVersion)) +{ app.MapScalarApiReference($"/openapi/{description.GroupName}", (options, context) => { options.AddServer(new ScalarServer($"https://{context.Request.Host}")); options.WithOpenApiRoutePattern($"/openapi/{description.GroupName}.json"); options.WithTheme(ScalarTheme.DeepSpace); options.Agent = new ScalarAgentOptions { Disabled = true }; - options.Authentication = new ScalarAuthenticationOptions { PreferredSecuritySchemes = ["Bearer"] }; + options.AddPreferredSecuritySchemes("OAuth2") + .AddOAuth2Flows("OAuth2", flow => + { + flow.AuthorizationCode = new AuthorizationCodeFlow + { + ClientId = builder.Configuration["SecuritySettings:ClientId"]!, + Pkce = Pkce.Sha256, + SelectedScopes = [.. identityScopes, builder.Configuration["SecuritySettings:Audience"]!] + }; + }); }); +} var latestVersionGroup = apiVersions.FirstOrDefault()?.GroupName ?? "v1"; diff --git a/PostFundManagement.Application/Extensions/General.cs b/PostFundManagement.Application/Extensions/General.cs index 2c598bc..8aa88c9 100644 --- a/PostFundManagement.Application/Extensions/General.cs +++ b/PostFundManagement.Application/Extensions/General.cs @@ -23,20 +23,20 @@ public static class General public static (bool Decoded, Guid userId) GetUserId(this ClaimsPrincipal principal) { - var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value; + var subClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; - var decoded = Guid.TryParse(sidClaim, out var userId); + var decoded = Guid.TryParse(subClaim, out var userId); return (decoded, userId); } - public static (bool Decoded, Guid email) GetUserEmail(this ClaimsPrincipal principal) + public static (bool Decoded, string email) GetUserEmail(this ClaimsPrincipal principal) { var emailClaim = principal.FindFirst("email")?.Value ?? principal.FindFirst(ClaimTypes.Email)?.Value; - var decoded = Guid.TryParse(emailClaim, out var email); + var decoded = !string.IsNullOrWhiteSpace(emailClaim); - return (decoded, email); + return (decoded, emailClaim!); } public static Microsoft.AspNetCore.Mvc.ProblemDetails ToProblems(this IReadOnlyList errors) => new() diff --git a/PostFundManagement.Application/Extensions/Security.cs b/PostFundManagement.Application/Extensions/Security.cs index 8ce32ae..4290d5f 100644 --- a/PostFundManagement.Application/Extensions/Security.cs +++ b/PostFundManagement.Application/Extensions/Security.cs @@ -135,17 +135,25 @@ public static class Security services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { + options.MapInboundClaims = false; options.Authority = authOptions.Authority; options.Audience = authOptions.Audience; + options.TokenValidationParameters = new TokenValidationParameters { ValidIssuer = authOptions.Authority, ValidateAudience = true, ValidateIssuer = true, + ValidateLifetime = true, }; }); - services.AddAuthorization(); + services.AddAuthorizationBuilder() + .AddPolicy("ApiScope", policy => + { + policy.RequireAuthenticatedUser(); + policy.RequireClaim("scope", authOptions.Audience!); + }); return services; } diff --git a/PostFundManagement.Application/Filters/ActiveUserFilter.cs b/PostFundManagement.Application/Filters/ActiveUserFilter.cs index c524585..db49dce 100644 --- a/PostFundManagement.Application/Filters/ActiveUserFilter.cs +++ b/PostFundManagement.Application/Filters/ActiveUserFilter.cs @@ -22,8 +22,11 @@ public sealed class ActiveUserFilter(IdentityService identityService) : IEndpoin if (!emailDecoded) return Results.Json(new { message = "Invalid/missing user email in token." }, statusCode: StatusCodes.Status400BadRequest); - if ((await identityService.UserIsAuthorisedAsync(userId, context.HttpContext.RequestAborted)).IsFailed) - return (object?)Results.Json(new { message = "Account approval is pending or inactive." }, statusCode: StatusCodes.Status403Forbidden); + var path = context.HttpContext.Request.Path.Value; + + if (!string.IsNullOrWhiteSpace(path) && !path!.EndsWith("integration/sync", StringComparison.InvariantCultureIgnoreCase)) + if ((await identityService.UserIsAuthorisedAsync(userId, context.HttpContext.RequestAborted)).IsFailed) + return (object?)Results.Json(new { message = "Account approval is pending or inactive." }, statusCode: StatusCodes.Status403Forbidden); context.HttpContext.Items[UserIdItemKey] = userId; context.HttpContext.Items[EmailtemKey] = email; diff --git a/PostFundManagement.Application/PostFundManagement.Application.csproj b/PostFundManagement.Application/PostFundManagement.Application.csproj index 76e16e1..80dcab2 100644 --- a/PostFundManagement.Application/PostFundManagement.Application.csproj +++ b/PostFundManagement.Application/PostFundManagement.Application.csproj @@ -26,6 +26,7 @@ + diff --git a/PostFundManagement.slnx b/PostFundManagement.slnx index 1438ccd..cd56524 100644 --- a/PostFundManagement.slnx +++ b/PostFundManagement.slnx @@ -1,4 +1,13 @@ + + + + + + + + +