From 76c09b3ebaf5d1f6df96c44ab574dc998ec7695a Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Tue, 3 Feb 2026 15:14:51 -0800 Subject: [PATCH 1/5] AB#31740 add basic scaffold for B2B auths --- .../Applicants/ApplicantProfileRequest.cs | 11 ++ .../Controllers/ApplicantProfileController.cs | 23 +++ .../BasicAuthenticationAuthorizationFilter.cs | 76 ++++++++++ ...th2ClientCredentialsAuthorizationFilter.cs | 137 ++++++++++++++++++ .../GrantManagerHttpApiModule.cs | 11 ++ .../Unity.GrantManager.HttpApi.csproj | 2 + .../appsettings.Development.json | 8 + 7 files changed, 268 insertions(+) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileRequest.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/BasicAuthenticationAuthorizationFilter.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/OAuth2ClientCredentialsAuthorizationFilter.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileRequest.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileRequest.cs new file mode 100644 index 000000000..b4fd4ed12 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileRequest.cs @@ -0,0 +1,11 @@ +using System; + +namespace Unity.GrantManager.Applicants +{ + public class ApplicantProfileRequest + { + public Guid ProfileId { get; set; } = Guid.NewGuid(); + public string Subject { get; set; } = string.Empty; + public string Issuer { get; set; } = string.Empty; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs new file mode 100644 index 000000000..5287e06e6 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; +using Unity.GrantManager.Applicants; +using Unity.GrantManager.Controllers.Authentication; +using Volo.Abp.AspNetCore.Mvc; + +namespace Unity.GrantManager.Controllers +{ + + [ApiController] + [Route("api/portal/applicant")] + [AllowAnonymous] + public class ApplicantProfileController : AbpControllerBase + { + [HttpGet] + [ServiceFilter(typeof(BasicAuthenticationAuthorizationFilter))] + public async Task GetApplicantProfileAsync([FromQuery] ApplicantProfileRequest applicantProfileRequest) + { + return Ok(); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/BasicAuthenticationAuthorizationFilter.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/BasicAuthenticationAuthorizationFilter.cs new file mode 100644 index 000000000..9d1afbe97 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/BasicAuthenticationAuthorizationFilter.cs @@ -0,0 +1,76 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Configuration; +using System; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Unity.GrantManager.Controllers.Authentication +{ + public class BasicAuthenticationAuthorizationFilter(IConfiguration configuration) : IAsyncAuthorizationFilter + { + public Task OnAuthorizationAsync(AuthorizationFilterContext context) + { + // Extract the Authorization header + if (!context.HttpContext.Request.Headers.TryGetValue("Authorization", out var authHeader)) + { + context.Result = new UnauthorizedObjectResult(new { error = "Missing Authorization header" }); + return Task.CompletedTask; + } + + var authHeaderValue = authHeader.FirstOrDefault(); + if (string.IsNullOrEmpty(authHeaderValue) || !authHeaderValue.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) + { + context.Result = new UnauthorizedObjectResult(new { error = "Invalid Authorization header format" }); + return Task.CompletedTask; + } + + // Decode the base64-encoded credentials + try + { + var encodedCredentials = authHeaderValue.Substring("Basic ".Length).Trim(); + var decodedCredentials = Encoding.UTF8.GetString(Convert.FromBase64String(encodedCredentials)); + var credentials = decodedCredentials.Split(':', 2); + + if (credentials.Length != 2) + { + context.Result = new UnauthorizedObjectResult(new { error = "Invalid credentials format" }); + return Task.CompletedTask; + } + + var username = credentials[0]; + var password = credentials[1]; + + // Validate credentials against configuration + var configuredUsername = configuration["B2BAuth:Username"]; + var configuredPassword = configuration["B2BAuth:Password"]; + + if (string.IsNullOrEmpty(configuredUsername) || string.IsNullOrEmpty(configuredPassword)) + { + context.Result = new StatusCodeResult(500); // Internal server error - configuration missing + return Task.CompletedTask; + } + + if (username != configuredUsername || password != configuredPassword) + { + context.Result = new UnauthorizedObjectResult(new { error = "Invalid credentials" }); + return Task.CompletedTask; + } + + // Authentication successful + return Task.CompletedTask; + } + catch (FormatException) + { + context.Result = new UnauthorizedObjectResult(new { error = "Invalid Authorization header encoding" }); + return Task.CompletedTask; + } + catch (Exception) + { + context.Result = new UnauthorizedObjectResult(new { error = "Authentication failed" }); + return Task.CompletedTask; + } + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/OAuth2ClientCredentialsAuthorizationFilter.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/OAuth2ClientCredentialsAuthorizationFilter.cs new file mode 100644 index 000000000..72c68b379 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/OAuth2ClientCredentialsAuthorizationFilter.cs @@ -0,0 +1,137 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Threading.Tasks; + +namespace Unity.GrantManager.Controllers.Authentication +{ + /// + /// Authorization filter for OAuth 2.0 Client Credentials flow. + /// Validates JWT tokens issued by Keycloak for B2B/M2M authentication. + /// + public class OAuth2ClientCredentialsAuthorizationFilter(IConfiguration configuration) : IAsyncAuthorizationFilter + { + private readonly JwtSecurityTokenHandler _tokenHandler = new(); + + public async Task OnAuthorizationAsync(AuthorizationFilterContext context) + { + // Extract the Authorization header + if (!context.HttpContext.Request.Headers.TryGetValue("Authorization", out var authHeader)) + { + context.Result = new UnauthorizedObjectResult(new { error = "missing_token", error_description = "Missing Authorization header" }); + return; + } + + var authHeaderValue = authHeader.FirstOrDefault(); + if (string.IsNullOrEmpty(authHeaderValue) || !authHeaderValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + context.Result = new UnauthorizedObjectResult(new { error = "invalid_token", error_description = "Invalid Authorization header format. Expected: Bearer " }); + return; + } + + // Extract the token + var token = authHeaderValue.Substring("Bearer ".Length).Trim(); + + try + { + // Validate the token + var claimsPrincipal = await ValidateTokenAsync(token); + + if (claimsPrincipal == null) + { + context.Result = new UnauthorizedObjectResult(new { error = "invalid_token", error_description = "Token validation failed" }); + return; + } + + // Optional: Add additional claims validation + var audience = claimsPrincipal.FindFirst("aud")?.Value; + var clientId = claimsPrincipal.FindFirst("azp")?.Value ?? claimsPrincipal.FindFirst("client_id")?.Value; + + var expectedAudience = configuration["B2BOAuth:Audience"]; + if (!string.IsNullOrEmpty(expectedAudience) && audience != expectedAudience) + { + context.Result = new UnauthorizedObjectResult(new { error = "invalid_token", error_description = "Invalid audience" }); + return; + } + + // Optional: Validate specific client IDs if configured + var allowedClientIds = configuration["B2BOAuth:AllowedClientIds"]; + if (!string.IsNullOrEmpty(allowedClientIds)) + { + var allowedClients = allowedClientIds.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(c => c.Trim()) + .ToList(); + + if (string.IsNullOrEmpty(clientId) || !allowedClients.Contains(clientId)) + { + context.Result = new ForbidResult(); + return; + } + } + + // Store the principal for use in the controller if needed + context.HttpContext.User = claimsPrincipal; + + // Authentication successful + } + catch (SecurityTokenExpiredException) + { + context.Result = new UnauthorizedObjectResult(new { error = "invalid_token", error_description = "Token has expired" }); + } + catch (SecurityTokenInvalidSignatureException) + { + context.Result = new UnauthorizedObjectResult(new { error = "invalid_token", error_description = "Invalid token signature" }); + } + catch (SecurityTokenValidationException ex) + { + context.Result = new UnauthorizedObjectResult(new { error = "invalid_token", error_description = ex.Message }); + } + catch (Exception) + { + context.Result = new UnauthorizedObjectResult(new { error = "invalid_token", error_description = "Token validation failed" }); + } + } + + private async Task ValidateTokenAsync(string token) + { + var serverAddress = configuration["AuthServer:ServerAddress"]; + var realm = configuration["AuthServer:Realm"]; + + if (string.IsNullOrEmpty(serverAddress) || string.IsNullOrEmpty(realm)) + { + throw new InvalidOperationException("AuthServer configuration is missing"); + } + + // Construct the issuer URL for Keycloak + var issuer = $"{serverAddress}/realms/{realm}"; + + // Get JWKS endpoint for token validation + var configurationManager = new ConfigurationManager( + $"{issuer}/.well-known/openid-configuration", + new OpenIdConnectConfigurationRetriever()); + + var openIdConfig = await configurationManager.GetConfigurationAsync(); + + var validationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = issuer, + ValidateAudience = false, // Client credentials tokens may not have audience + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKeys = openIdConfig.SigningKeys, + ClockSkew = TimeSpan.FromMinutes(2) // Allow 2 minutes clock skew + }; + + var claimsPrincipal = _tokenHandler.ValidateToken(token, validationParameters, out var validatedToken); + + return claimsPrincipal; + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/GrantManagerHttpApiModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/GrantManagerHttpApiModule.cs index f0467ae56..765b242f9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/GrantManagerHttpApiModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/GrantManagerHttpApiModule.cs @@ -8,6 +8,8 @@ using Volo.Abp.PermissionManagement.HttpApi; using Volo.Abp.SettingManagement; using Unity.Notifications; +using Unity.GrantManager.Controllers.Authentication; +using Microsoft.Extensions.DependencyInjection; namespace Unity.GrantManager; @@ -25,7 +27,10 @@ public class GrantManagerHttpApiModule : AbpModule { public override void ConfigureServices(ServiceConfigurationContext context) { + var services = context.Services; + ConfigureLocalization(); + ConfigureFilters(services); } private void ConfigureLocalization() @@ -39,4 +44,10 @@ private void ConfigureLocalization() ); }); } + + private static void ConfigureFilters(IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Unity.GrantManager.HttpApi.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Unity.GrantManager.HttpApi.csproj index de402457c..4965e2d76 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Unity.GrantManager.HttpApi.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Unity.GrantManager.HttpApi.csproj @@ -17,8 +17,10 @@ + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json index 4ebac5540..d247d5284 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json @@ -94,5 +94,13 @@ }, "IdentityProfileLogin": { "AutoCreateUser": true + }, + "B2BAuth": { + "Username": "", + "Password": "" + }, + "B2BOAuth": { + "Audience": "", + "AllowedClientIds": "" } } \ No newline at end of file From e4abf979062213466a5e813c58af4c408bceeb63 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Wed, 4 Feb 2026 13:50:55 -0800 Subject: [PATCH 2/5] AB#31740 add to api key filter for applicant portal integration --- .../Applicants/ApplicantProfileDto.cs | 13 ++ .../Applicants/ApplicantProfileRequest.cs | 7 +- .../Applicants/ApplicantTenantDto.cs | 10 ++ .../Applicants/IApplicantProfileAppService.cs | 11 ++ .../Applicants/ApplicantProfileAppService.cs | 73 ++++++++++ .../Controllers/ApplicantProfileController.cs | 21 ++- .../ApiKeyAuthorizationFilter.cs | 52 +++++++ .../BasicAuthenticationAuthorizationFilter.cs | 76 ---------- ...th2ClientCredentialsAuthorizationFilter.cs | 137 ------------------ .../GrantManagerHttpApiModule.cs | 3 +- .../Unity.GrantManager.HttpApi.csproj | 6 +- .../appsettings.Development.json | 9 +- 12 files changed, 185 insertions(+), 233 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileDto.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantTenantDto.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/IApplicantProfileAppService.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfileAppService.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/ApiKeyAuthorizationFilter.cs delete mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/BasicAuthenticationAuthorizationFilter.cs delete mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/OAuth2ClientCredentialsAuthorizationFilter.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileDto.cs new file mode 100644 index 000000000..bbf8fa938 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileDto.cs @@ -0,0 +1,13 @@ +using System; + +namespace Unity.GrantManager.Applicants +{ + public class ApplicantProfileDto + { + public Guid ProfileId { get; set; } + public string Subject { get; set; } = string.Empty; + public string Issuer { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileRequest.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileRequest.cs index b4fd4ed12..b6e453bec 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileRequest.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileRequest.cs @@ -6,6 +6,11 @@ public class ApplicantProfileRequest { public Guid ProfileId { get; set; } = Guid.NewGuid(); public string Subject { get; set; } = string.Empty; - public string Issuer { get; set; } = string.Empty; + public string Issuer { get; set; } = string.Empty; + } + + public class TenantedApplicantProfileRequest : ApplicantProfileRequest + { + public string TenantIdendifier { get; set; } = string.Empty; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantTenantDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantTenantDto.cs new file mode 100644 index 000000000..9794ad422 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantTenantDto.cs @@ -0,0 +1,10 @@ +using System; + +namespace Unity.GrantManager.Applicants +{ + public class ApplicantTenantDto + { + public Guid TenantId { get; set; } + public string TenantName { get; set; } = string.Empty; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/IApplicantProfileAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/IApplicantProfileAppService.cs new file mode 100644 index 000000000..559d3cdce --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/IApplicantProfileAppService.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Unity.GrantManager.Applicants +{ + public interface IApplicantProfileAppService + { + Task GetApplicantProfileAsync(ApplicantProfileRequest request); + Task> GetApplicantTenantsAsync(ApplicantProfileRequest request); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfileAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfileAppService.cs new file mode 100644 index 000000000..7d8575c44 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfileAppService.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.Applications; +using Volo.Abp; +using Volo.Abp.Application.Services; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; +using Volo.Abp.TenantManagement; + +namespace Unity.GrantManager.Applicants +{ + [RemoteService(false)] + public class ApplicantProfileAppService(ICurrentTenant currentTenant, + ITenantRepository tenantRepository, + IRepository applicationFormSubmissionRepository) + : ApplicationService, IApplicantProfileAppService + { + public async Task GetApplicantProfileAsync(ApplicantProfileRequest request) + { + // TODO: Implement profile retrieval logic + // This should query the applicant information based on the Subject and Issuer + // and return the applicant's profile details + + return await Task.FromResult(new ApplicantProfileDto + { + ProfileId = request.ProfileId, + Subject = request.Subject, + Issuer = request.Issuer, + Email = string.Empty, + DisplayName = string.Empty + }); + } + + public async Task> GetApplicantTenantsAsync(ApplicantProfileRequest request) + { + // Extract the username part from the OIDC sub (part before '@') + var subUsername = request.Subject.Contains('@') + ? request.Subject[..request.Subject.IndexOf('@')].ToUpper() + : request.Subject.ToUpper(); + + var result = new List(); + + // Get all tenants from the host context + using (currentTenant.Change(null)) + { + var tenants = await tenantRepository.GetListAsync(); + + // Query each tenant's database for matching submissions + foreach (var tenant in tenants) + { + using (currentTenant.Change(tenant.Id)) + { + var queryable = await applicationFormSubmissionRepository.GetQueryableAsync(); + var hasMatchingSubmission = queryable.Any(s => s.OidcSub == subUsername); + + if (hasMatchingSubmission) + { + result.Add(new ApplicantTenantDto + { + TenantId = tenant.Id, + TenantName = tenant.Name + }); + } + } + } + } + + return result; + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs index 5287e06e6..67103cd1b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs @@ -7,17 +7,26 @@ namespace Unity.GrantManager.Controllers { - [ApiController] - [Route("api/portal/applicant")] [AllowAnonymous] - public class ApplicantProfileController : AbpControllerBase + [ServiceFilter(typeof(ApiKeyAuthorizationFilter))] + public class ApplicantProfileController(IApplicantProfileAppService applicantProfileAppService) : AbpControllerBase { + + [HttpGet] + [Route("api/profile")] + public async Task GetApplicantProfileAsync([FromQuery] TenantedApplicantProfileRequest applicantProfileRequest) + { + var profile = await applicantProfileAppService.GetApplicantProfileAsync(applicantProfileRequest); + return Ok(profile); + } + [HttpGet] - [ServiceFilter(typeof(BasicAuthenticationAuthorizationFilter))] - public async Task GetApplicantProfileAsync([FromQuery] ApplicantProfileRequest applicantProfileRequest) + [Route("api/tenants")] + public async Task GetApplicantProfileTenantsAsync([FromQuery] ApplicantProfileRequest applicantProfileRequest) { - return Ok(); + var tenants = await applicantProfileAppService.GetApplicantTenantsAsync(applicantProfileRequest); + return Ok(tenants); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/ApiKeyAuthorizationFilter.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/ApiKeyAuthorizationFilter.cs new file mode 100644 index 000000000..6e180d2dc --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/ApiKeyAuthorizationFilter.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Configuration; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicationForms; + +namespace Unity.GrantManager.Controllers.Authentication +{ + public class ApiKeyAuthorizationFilter(IConfiguration configuration) : IAsyncAuthorizationFilter + { + public async Task OnAuthorizationAsync(AuthorizationFilterContext context) + { + if (!context.HttpContext.Request.Headers.TryGetValue(AuthConstants.ApiKeyHeader, out var extractedApiKey)) + { + context.Result = new UnauthorizedObjectResult(new ProblemDetails + { + Status = StatusCodes.Status401Unauthorized, + Title = "Unauthorized", + Detail = "API Key missing", + Type = "https://tools.ietf.org/html/rfc7235#section-3.1" + }); + return; + } + + var apiKey = configuration["B2BAuth:ApiKey"]; + + if (apiKey is null) + { + context.Result = new UnauthorizedObjectResult(new ProblemDetails + { + Status = StatusCodes.Status401Unauthorized, + Title = "Unauthorized", + Detail = "API Key not configured", + Type = "https://tools.ietf.org/html/rfc7235#section-3.1" + }); + return; + } + + if (apiKey != extractedApiKey) + { + context.Result = new UnauthorizedObjectResult(new ProblemDetails + { + Status = StatusCodes.Status401Unauthorized, + Title = "Unauthorized", + Detail = "Invalid API Key", + Type = "https://tools.ietf.org/html/rfc7235#section-3.1" + }); + } + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/BasicAuthenticationAuthorizationFilter.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/BasicAuthenticationAuthorizationFilter.cs deleted file mode 100644 index 9d1afbe97..000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/BasicAuthenticationAuthorizationFilter.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Extensions.Configuration; -using System; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Unity.GrantManager.Controllers.Authentication -{ - public class BasicAuthenticationAuthorizationFilter(IConfiguration configuration) : IAsyncAuthorizationFilter - { - public Task OnAuthorizationAsync(AuthorizationFilterContext context) - { - // Extract the Authorization header - if (!context.HttpContext.Request.Headers.TryGetValue("Authorization", out var authHeader)) - { - context.Result = new UnauthorizedObjectResult(new { error = "Missing Authorization header" }); - return Task.CompletedTask; - } - - var authHeaderValue = authHeader.FirstOrDefault(); - if (string.IsNullOrEmpty(authHeaderValue) || !authHeaderValue.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) - { - context.Result = new UnauthorizedObjectResult(new { error = "Invalid Authorization header format" }); - return Task.CompletedTask; - } - - // Decode the base64-encoded credentials - try - { - var encodedCredentials = authHeaderValue.Substring("Basic ".Length).Trim(); - var decodedCredentials = Encoding.UTF8.GetString(Convert.FromBase64String(encodedCredentials)); - var credentials = decodedCredentials.Split(':', 2); - - if (credentials.Length != 2) - { - context.Result = new UnauthorizedObjectResult(new { error = "Invalid credentials format" }); - return Task.CompletedTask; - } - - var username = credentials[0]; - var password = credentials[1]; - - // Validate credentials against configuration - var configuredUsername = configuration["B2BAuth:Username"]; - var configuredPassword = configuration["B2BAuth:Password"]; - - if (string.IsNullOrEmpty(configuredUsername) || string.IsNullOrEmpty(configuredPassword)) - { - context.Result = new StatusCodeResult(500); // Internal server error - configuration missing - return Task.CompletedTask; - } - - if (username != configuredUsername || password != configuredPassword) - { - context.Result = new UnauthorizedObjectResult(new { error = "Invalid credentials" }); - return Task.CompletedTask; - } - - // Authentication successful - return Task.CompletedTask; - } - catch (FormatException) - { - context.Result = new UnauthorizedObjectResult(new { error = "Invalid Authorization header encoding" }); - return Task.CompletedTask; - } - catch (Exception) - { - context.Result = new UnauthorizedObjectResult(new { error = "Authentication failed" }); - return Task.CompletedTask; - } - } - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/OAuth2ClientCredentialsAuthorizationFilter.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/OAuth2ClientCredentialsAuthorizationFilter.cs deleted file mode 100644 index 72c68b379..000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/OAuth2ClientCredentialsAuthorizationFilter.cs +++ /dev/null @@ -1,137 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Extensions.Configuration; -using Microsoft.IdentityModel.Protocols; -using Microsoft.IdentityModel.Protocols.OpenIdConnect; -using Microsoft.IdentityModel.Tokens; -using System; -using System.IdentityModel.Tokens.Jwt; -using System.Linq; -using System.Threading.Tasks; - -namespace Unity.GrantManager.Controllers.Authentication -{ - /// - /// Authorization filter for OAuth 2.0 Client Credentials flow. - /// Validates JWT tokens issued by Keycloak for B2B/M2M authentication. - /// - public class OAuth2ClientCredentialsAuthorizationFilter(IConfiguration configuration) : IAsyncAuthorizationFilter - { - private readonly JwtSecurityTokenHandler _tokenHandler = new(); - - public async Task OnAuthorizationAsync(AuthorizationFilterContext context) - { - // Extract the Authorization header - if (!context.HttpContext.Request.Headers.TryGetValue("Authorization", out var authHeader)) - { - context.Result = new UnauthorizedObjectResult(new { error = "missing_token", error_description = "Missing Authorization header" }); - return; - } - - var authHeaderValue = authHeader.FirstOrDefault(); - if (string.IsNullOrEmpty(authHeaderValue) || !authHeaderValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - { - context.Result = new UnauthorizedObjectResult(new { error = "invalid_token", error_description = "Invalid Authorization header format. Expected: Bearer " }); - return; - } - - // Extract the token - var token = authHeaderValue.Substring("Bearer ".Length).Trim(); - - try - { - // Validate the token - var claimsPrincipal = await ValidateTokenAsync(token); - - if (claimsPrincipal == null) - { - context.Result = new UnauthorizedObjectResult(new { error = "invalid_token", error_description = "Token validation failed" }); - return; - } - - // Optional: Add additional claims validation - var audience = claimsPrincipal.FindFirst("aud")?.Value; - var clientId = claimsPrincipal.FindFirst("azp")?.Value ?? claimsPrincipal.FindFirst("client_id")?.Value; - - var expectedAudience = configuration["B2BOAuth:Audience"]; - if (!string.IsNullOrEmpty(expectedAudience) && audience != expectedAudience) - { - context.Result = new UnauthorizedObjectResult(new { error = "invalid_token", error_description = "Invalid audience" }); - return; - } - - // Optional: Validate specific client IDs if configured - var allowedClientIds = configuration["B2BOAuth:AllowedClientIds"]; - if (!string.IsNullOrEmpty(allowedClientIds)) - { - var allowedClients = allowedClientIds.Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(c => c.Trim()) - .ToList(); - - if (string.IsNullOrEmpty(clientId) || !allowedClients.Contains(clientId)) - { - context.Result = new ForbidResult(); - return; - } - } - - // Store the principal for use in the controller if needed - context.HttpContext.User = claimsPrincipal; - - // Authentication successful - } - catch (SecurityTokenExpiredException) - { - context.Result = new UnauthorizedObjectResult(new { error = "invalid_token", error_description = "Token has expired" }); - } - catch (SecurityTokenInvalidSignatureException) - { - context.Result = new UnauthorizedObjectResult(new { error = "invalid_token", error_description = "Invalid token signature" }); - } - catch (SecurityTokenValidationException ex) - { - context.Result = new UnauthorizedObjectResult(new { error = "invalid_token", error_description = ex.Message }); - } - catch (Exception) - { - context.Result = new UnauthorizedObjectResult(new { error = "invalid_token", error_description = "Token validation failed" }); - } - } - - private async Task ValidateTokenAsync(string token) - { - var serverAddress = configuration["AuthServer:ServerAddress"]; - var realm = configuration["AuthServer:Realm"]; - - if (string.IsNullOrEmpty(serverAddress) || string.IsNullOrEmpty(realm)) - { - throw new InvalidOperationException("AuthServer configuration is missing"); - } - - // Construct the issuer URL for Keycloak - var issuer = $"{serverAddress}/realms/{realm}"; - - // Get JWKS endpoint for token validation - var configurationManager = new ConfigurationManager( - $"{issuer}/.well-known/openid-configuration", - new OpenIdConnectConfigurationRetriever()); - - var openIdConfig = await configurationManager.GetConfigurationAsync(); - - var validationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuer = issuer, - ValidateAudience = false, // Client credentials tokens may not have audience - ValidateLifetime = true, - ValidateIssuerSigningKey = true, - IssuerSigningKeys = openIdConfig.SigningKeys, - ClockSkew = TimeSpan.FromMinutes(2) // Allow 2 minutes clock skew - }; - - var claimsPrincipal = _tokenHandler.ValidateToken(token, validationParameters, out var validatedToken); - - return claimsPrincipal; - } - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/GrantManagerHttpApiModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/GrantManagerHttpApiModule.cs index 765b242f9..cb42ce896 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/GrantManagerHttpApiModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/GrantManagerHttpApiModule.cs @@ -47,7 +47,6 @@ private void ConfigureLocalization() private static void ConfigureFilters(IServiceCollection services) { - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Unity.GrantManager.HttpApi.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Unity.GrantManager.HttpApi.csproj index 4965e2d76..77574bfd5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Unity.GrantManager.HttpApi.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Unity.GrantManager.HttpApi.csproj @@ -16,11 +16,9 @@ - - + - - + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json index d247d5284..473ba538b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json @@ -96,11 +96,6 @@ "AutoCreateUser": true }, "B2BAuth": { - "Username": "", - "Password": "" - }, - "B2BOAuth": { - "Audience": "", - "AllowedClientIds": "" - } + "ApiKey": "xx55" + } } \ No newline at end of file From 6d23694e403a30eff71a542bcc57bad895a9ceb8 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Wed, 4 Feb 2026 16:12:41 -0800 Subject: [PATCH 3/5] AB#31740 update profile endpoint routes --- .../Controllers/ApplicantProfileController.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs index 67103cd1b..838256aff 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs @@ -9,12 +9,13 @@ namespace Unity.GrantManager.Controllers { [ApiController] [AllowAnonymous] + [Route("api/app/applicant-profiles")] [ServiceFilter(typeof(ApiKeyAuthorizationFilter))] public class ApplicantProfileController(IApplicantProfileAppService applicantProfileAppService) : AbpControllerBase { [HttpGet] - [Route("api/profile")] + [Route("profile")] public async Task GetApplicantProfileAsync([FromQuery] TenantedApplicantProfileRequest applicantProfileRequest) { var profile = await applicantProfileAppService.GetApplicantProfileAsync(applicantProfileRequest); @@ -22,7 +23,7 @@ public async Task GetApplicantProfileAsync([FromQuery] TenantedAp } [HttpGet] - [Route("api/tenants")] + [Route("tenants")] public async Task GetApplicantProfileTenantsAsync([FromQuery] ApplicantProfileRequest applicantProfileRequest) { var tenants = await applicantProfileAppService.GetApplicantTenantsAsync(applicantProfileRequest); From 6bf056379bc189f020100972239235d6759b48a4 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Wed, 4 Feb 2026 16:38:16 -0800 Subject: [PATCH 4/5] AB#31740 recommended updates --- .../Applicants/ApplicantProfileRequest.cs | 2 +- .../Applicants/ApplicantProfileAppService.cs | 4 ---- .../Controllers/Authentication/ApiKeyAuthorizationFilter.cs | 4 ++-- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileRequest.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileRequest.cs index b6e453bec..4b2d24d87 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileRequest.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileRequest.cs @@ -11,6 +11,6 @@ public class ApplicantProfileRequest public class TenantedApplicantProfileRequest : ApplicantProfileRequest { - public string TenantIdendifier { get; set; } = string.Empty; + public Guid TenantId { get; set; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfileAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfileAppService.cs index 7d8575c44..960dc5c6e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfileAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfileAppService.cs @@ -19,10 +19,6 @@ public class ApplicantProfileAppService(ICurrentTenant currentTenant, { public async Task GetApplicantProfileAsync(ApplicantProfileRequest request) { - // TODO: Implement profile retrieval logic - // This should query the applicant information based on the Subject and Issuer - // and return the applicant's profile details - return await Task.FromResult(new ApplicantProfileDto { ProfileId = request.ProfileId, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/ApiKeyAuthorizationFilter.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/ApiKeyAuthorizationFilter.cs index 6e180d2dc..c6310c166 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/ApiKeyAuthorizationFilter.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/ApiKeyAuthorizationFilter.cs @@ -7,9 +7,9 @@ namespace Unity.GrantManager.Controllers.Authentication { - public class ApiKeyAuthorizationFilter(IConfiguration configuration) : IAsyncAuthorizationFilter + public class ApiKeyAuthorizationFilter(IConfiguration configuration) : IAuthorizationFilter { - public async Task OnAuthorizationAsync(AuthorizationFilterContext context) + public void OnAuthorization(AuthorizationFilterContext context) { if (!context.HttpContext.Request.Headers.TryGetValue(AuthConstants.ApiKeyHeader, out var extractedApiKey)) { From 0413b4eceaf8984c0ee1e59478028572374f522e Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 5 Feb 2026 09:42:18 -0800 Subject: [PATCH 5/5] AB#31740 update from codeQL suggestions --- .../Controllers/ApplicantProfileController.cs | 6 ++---- .../Controllers/Authentication/ApiKeyAuthorizationFilter.cs | 1 - .../src/Unity.GrantManager.Web/appsettings.Development.json | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs index 838256aff..61dee526b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/ApplicantProfileController.cs @@ -1,5 +1,4 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using System.Threading.Tasks; using Unity.GrantManager.Applicants; using Unity.GrantManager.Controllers.Authentication; @@ -7,8 +6,7 @@ namespace Unity.GrantManager.Controllers { - [ApiController] - [AllowAnonymous] + [ApiController] [Route("api/app/applicant-profiles")] [ServiceFilter(typeof(ApiKeyAuthorizationFilter))] public class ApplicantProfileController(IApplicantProfileAppService applicantProfileAppService) : AbpControllerBase diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/ApiKeyAuthorizationFilter.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/ApiKeyAuthorizationFilter.cs index c6310c166..07602f9c3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/ApiKeyAuthorizationFilter.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/Authentication/ApiKeyAuthorizationFilter.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Configuration; -using System.Threading.Tasks; using Unity.GrantManager.ApplicationForms; namespace Unity.GrantManager.Controllers.Authentication diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json index 473ba538b..bec7eeca4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json @@ -96,6 +96,6 @@ "AutoCreateUser": true }, "B2BAuth": { - "ApiKey": "xx55" + "ApiKey": "" } } \ No newline at end of file