From 11b054202dbd1ce70057509d809aad261927e4f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:40:49 +0000 Subject: [PATCH 01/11] Initial plan From d08556e7a6230034409742f5f41ab146f5a2f1cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:05:54 +0000 Subject: [PATCH 02/11] Refactor OidcEndpoints to use MediatR commands/handlers pattern - Add OidcCommands.cs with four command records (Authorize, Token, Logout, UserInfo) - Add OidcRequestHandler.cs implementing IRequestHandler for each command, with all dependencies injected via constructor instead of per-endpoint parameters - Move ResolveClientCredentialsScopesAsync helper from OidcEndpoints to OidcHelpers - Replace OidcEndpoints body with thin wrappers that delegate to IMediator.Send() - Register OidcRequestHandler assembly in Program.cs MediatR configuration - Update OidcEndpointTests to test OidcRequestHandler directly and use the moved helper Agent-Logs-Url: https://github.com/TransactionProcessing/SecurityService/sessions/7a8f99ca-20b5-4a64-8879-c7d4d0abc398 Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../Oidc/OidcEndpointTests.cs | 26 +- SecurityService/Oidc/OidcCommands.cs | 12 + SecurityService/Oidc/OidcEndpoints.cs | 316 +----------------- SecurityService/Oidc/OidcHelpers.cs | 20 ++ SecurityService/Oidc/OidcRequestHandler.cs | 312 +++++++++++++++++ SecurityService/Program.cs | 6 +- 6 files changed, 381 insertions(+), 311 deletions(-) create mode 100644 SecurityService/Oidc/OidcCommands.cs create mode 100644 SecurityService/Oidc/OidcRequestHandler.cs diff --git a/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs b/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs index 82a3fea2..f1a86e45 100644 --- a/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs +++ b/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using System.Text; +using MediatR; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -51,7 +52,23 @@ public async Task UserInfoAsync_WithValidPrincipal_ReturnsJsonPayload() } }; - var result = await OidcEndpoints.UserInfoAsync(context, userManager.Object); + var signInManager = IdentityMocks.CreateSignInManager(userManager); + var appManager = new Mock(); + var authManager = new Mock(); + var scopeManager = new Mock(); + using var provider = TestServiceProviderFactory.Create(nameof(this.UserInfoAsync_WithValidPrincipal_ReturnsJsonPayload)); + using var scope = provider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var handler = new OidcRequestHandler( + userManager.Object, + signInManager.Object, + appManager.Object, + authManager.Object, + scopeManager.Object, + dbContext); + + var result = await handler.Handle(new OidcCommands.UserInfoCommand(context), CancellationToken.None); await result.ExecuteAsync(context); context.Response.StatusCode.ShouldBe(StatusCodes.Status200OK); @@ -62,9 +79,9 @@ public async Task UserInfoAsync_WithValidPrincipal_ReturnsJsonPayload() } [Fact] - public async Task TokenAsync_ClientCredentialsWithoutRequestedScopes_FallsBackToConfiguredClientScopes() + public async Task ResolveClientCredentialsScopesAsync_WithoutRequestedScopes_FallsBackToConfiguredClientScopes() { - using var provider = TestServiceProviderFactory.Create(nameof(this.TokenAsync_ClientCredentialsWithoutRequestedScopes_FallsBackToConfiguredClientScopes)); + using var provider = TestServiceProviderFactory.Create(nameof(this.ResolveClientCredentialsScopesAsync_WithoutRequestedScopes_FallsBackToConfiguredClientScopes)); using var scope = provider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); @@ -88,7 +105,7 @@ public async Task TokenAsync_ClientCredentialsWithoutRequestedScopes_FallsBackTo ClientId = "serviceClient" }; - var scopes = await OidcEndpoints.ResolveClientCredentialsScopesAsync( + var scopes = await OidcHelpers.ResolveClientCredentialsScopesAsync( request, dbContext, CancellationToken.None); @@ -151,3 +168,4 @@ public FakeAuthenticationService(AuthenticateResult authenticateResult) public Task SignOutAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) => Task.CompletedTask; } } + diff --git a/SecurityService/Oidc/OidcCommands.cs b/SecurityService/Oidc/OidcCommands.cs new file mode 100644 index 00000000..3327b526 --- /dev/null +++ b/SecurityService/Oidc/OidcCommands.cs @@ -0,0 +1,12 @@ +using MediatR; +using Microsoft.AspNetCore.Http; + +namespace SecurityService.Oidc; + +public static class OidcCommands +{ + public sealed record AuthorizeCommand(HttpContext HttpContext) : IRequest; + public sealed record TokenCommand(HttpContext HttpContext) : IRequest; + public sealed record LogoutCommand(HttpContext HttpContext) : IRequest; + public sealed record UserInfoCommand(HttpContext HttpContext) : IRequest; +} diff --git a/SecurityService/Oidc/OidcEndpoints.cs b/SecurityService/Oidc/OidcEndpoints.cs index 818cf461..6738d370 100644 --- a/SecurityService/Oidc/OidcEndpoints.cs +++ b/SecurityService/Oidc/OidcEndpoints.cs @@ -1,16 +1,4 @@ -using System.Collections.Immutable; -using System.Security.Claims; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; -using Microsoft.IdentityModel.Tokens; -using OpenIddict.Abstractions; -using OpenIddict.Server.AspNetCore; -using OpenIddict.Validation.AspNetCore; -using SecurityService.Database; -using SecurityService.Database.DbContexts; -using static OpenIddict.Abstractions.OpenIddictConstants; +using MediatR; namespace SecurityService.Oidc; @@ -24,300 +12,16 @@ public static void MapOidcEndpoints(this IEndpointRouteBuilder endpoints) endpoints.MapMethods("/connect/userinfo", [HttpMethods.Get, HttpMethods.Post], (Delegate)UserInfoAsync); } - public static async Task AuthorizeAsync( - HttpContext context, - UserManager userManager, - IOpenIddictApplicationManager applicationManager, - IOpenIddictAuthorizationManager authorizationManager, - IOpenIddictScopeManager scopeManager, - CancellationToken cancellationToken) - { - var request = context.GetOpenIddictServerRequest() ?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); - var currentRequestUrl = OidcHelpers.BuildCurrentRequestUrl(context.Request); - - var authenticationResult = await context.AuthenticateAsync(IdentityConstants.ApplicationScheme); - if (authenticationResult.Succeeded == false) - { - if (request.HasPromptValue(PromptValues.None)) - { - return Results.Forbid(new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.LoginRequired, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is not logged in." - }), [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); - } - - return Results.Challenge(new AuthenticationProperties { RedirectUri = currentRequestUrl }, [IdentityConstants.ApplicationScheme]); - } - - var user = await userManager.GetUserAsync(authenticationResult.Principal); - if (user is null) - { - return Results.Forbid(new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.LoginRequired, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user account could not be resolved." - }), [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); - } - - var application = await applicationManager.FindByClientIdAsync(request.ClientId!, cancellationToken); - if (application is null) - { - return Results.BadRequest(new { error = "invalid_client", error_description = "The client application could not be found." }); - } - - if (context.Request.Query.TryGetValue("consent", out var consentDecision)) - { - if (string.Equals(consentDecision, "denied", StringComparison.OrdinalIgnoreCase)) - { - return Results.Forbid(new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.AccessDenied, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The authorization request was denied." - }), [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); - } - - if (string.Equals(consentDecision, "accepted", StringComparison.OrdinalIgnoreCase)) - { - return await CompleteAuthorizationAsync(context, user, request, application, authorizationManager, userManager, scopeManager, cancellationToken, OidcHelpers.ReadMultiValue(context.Request.Query, "granted_scope")); - } - } - - var consentType = await applicationManager.GetConsentTypeAsync(application, cancellationToken) ?? ConsentTypes.Implicit; - var existingAuthorizations = await authorizationManager.FindAsync( - subject: user.Id, - client: await applicationManager.GetIdAsync(application, cancellationToken), - status: Statuses.Valid, - type: AuthorizationTypes.Permanent, - scopes: ImmutableArray.CreateRange(request.GetScopes()), - cancellationToken: cancellationToken).ToListAsync(cancellationToken); - - if (string.Equals(consentType, ConsentTypes.Explicit, StringComparison.OrdinalIgnoreCase) && existingAuthorizations.Count == 0) - { - if (request.HasPromptValue(PromptValues.None)) - { - return Results.Forbid(new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.ConsentRequired, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "Interactive user consent is required." - }), [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); - } - - return Results.Redirect($"/Consent?returnUrl={Uri.EscapeDataString(currentRequestUrl)}"); - } - - return await CompleteAuthorizationAsync(context, user, request, application, authorizationManager, userManager, scopeManager, cancellationToken, request.GetScopes()); - } - - public static async Task TokenAsync( - HttpContext context, - UserManager userManager, - SignInManager signInManager, - SecurityServiceDbContext dbContext, - IOpenIddictScopeManager scopeManager, - CancellationToken cancellationToken) - { - var request = context.GetOpenIddictServerRequest() ?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); - - if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType() || request.IsDeviceCodeGrantType()) - { - var authenticationResult = await context.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); - if (authenticationResult.Succeeded == false || authenticationResult.Principal is null) - { - return InvalidGrant("The specified token is invalid."); - } - - var subject = authenticationResult.Principal.GetClaim(Claims.Subject); - if (string.IsNullOrWhiteSpace(subject)) - { - return Results.SignIn(authenticationResult.Principal, authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); - } - - var user = await userManager.FindByIdAsync(subject); - if (user is null || await signInManager.CanSignInAsync(user) == false) - { - return InvalidGrant("The token is no longer valid."); - } - - var principal = await OidcHelpers.CreatePrincipalAsync( - user, - userManager, - authenticationResult.Principal.GetScopes(), - await scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(authenticationResult.Principal.GetScopes()), cancellationToken).ToListAsync(cancellationToken), - authenticationResult.Principal.GetAuthorizationId()); - return Results.SignIn(principal, authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); - } - - if (request.IsClientCredentialsGrantType()) - { - var grantedScopes = await ResolveClientCredentialsScopesAsync(request, dbContext, cancellationToken); - - var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role); - identity.SetClaim(Claims.Subject, request.ClientId!) - .SetClaim(Claims.Name, request.ClientId!); - identity.SetScopes(grantedScopes); - identity.SetResources(await scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(grantedScopes), cancellationToken).ToListAsync(cancellationToken)); - identity.SetDestinations(OidcHelpers.GetDestinations); - return Results.SignIn(new ClaimsPrincipal(identity), authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); - } - - if (request.IsPasswordGrantType()) - { - var user = await userManager.FindByNameAsync(request.Username!); - if (user is null) - { - return InvalidGrant(); - } + public static Task AuthorizeAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) + => mediator.Send(new OidcCommands.AuthorizeCommand(context), cancellationToken); - var signInResult = await signInManager.CheckPasswordSignInAsync(user, request.Password!, lockoutOnFailure: true); - if (signInResult.Succeeded == false) - { - return InvalidGrant(); - } + public static Task TokenAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) + => mediator.Send(new OidcCommands.TokenCommand(context), cancellationToken); - var grantedScopes = await ResolveClientCredentialsScopesAsync(request, dbContext, cancellationToken); - - var resources = await scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(grantedScopes), cancellationToken).ToListAsync(cancellationToken); - var principal = await OidcHelpers.CreatePrincipalAsync(user, userManager, grantedScopes, resources, authorizationId: null); - return Results.SignIn(principal, authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); - } - - return Results.BadRequest(new { error = "unsupported_grant_type", error_description = "The specified grant type is not supported by this service." }); - - static IResult InvalidGrant(string description = "The username/password couple is invalid.") => Results.Forbid(new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description - }), [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); - } - - public static async Task> ResolveClientCredentialsScopesAsync( - OpenIddictRequest request, - SecurityServiceDbContext dbContext, - CancellationToken cancellationToken) - { - IReadOnlyCollection grantedScopes = request.GetScopes().ToArray(); - if (grantedScopes.Count > 0) - { - return grantedScopes; - } + public static Task LogoutAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) + => mediator.Send(new OidcCommands.LogoutCommand(context), cancellationToken); - var clientDefinition = await dbContext.ClientDefinitions - .SingleOrDefaultAsync(client => client.ClientId == request.ClientId, cancellationToken); - - return clientDefinition is null - ? Array.Empty() - : JsonListSerializer.Deserialize(clientDefinition.AllowedScopesJson); - } - - public static Task LogoutAsync(HttpContext context) - { - var currentRequestUrl = OidcHelpers.BuildCurrentRequestUrl(context.Request); - var confirmed = string.Equals(context.Request.Query["logout"], "confirmed", StringComparison.OrdinalIgnoreCase); - if (confirmed) - { - return Task.FromResult(Results.SignOut(new AuthenticationProperties { RedirectUri = "/Account/Logout/LoggedOut" }, [IdentityConstants.ApplicationScheme, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme])); - } - - return Task.FromResult(Results.Redirect($"/Account/Logout?returnUrl={Uri.EscapeDataString(currentRequestUrl)}")); - } - - public static async Task UserInfoAsync(HttpContext context, UserManager userManager) - { - var authenticationResult = await context.AuthenticateAsync(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme); - if (authenticationResult.Succeeded == false || authenticationResult.Principal is null) - { - return InvalidToken("The access token is missing or invalid."); - } - - var subject = authenticationResult.Principal.GetClaim(Claims.Subject); - if (string.IsNullOrWhiteSpace(subject)) - { - return InvalidToken("The access token does not contain a subject identifier."); - } - - var user = await userManager.FindByIdAsync(subject); - if (user is null) - { - return InvalidToken("The user associated with the token could not be found."); - } - - var scopes = authenticationResult.Principal.GetScopes().ToHashSet(StringComparer.OrdinalIgnoreCase); - var response = new Dictionary - { - [Claims.Subject] = user.Id - }; - - if (scopes.Contains(Scopes.Profile)) - { - response[Claims.Name] = user.UserName; - response[Claims.PreferredUsername] = user.UserName; - response[Claims.GivenName] = user.GivenName; - response[Claims.FamilyName] = user.FamilyName; - response["middle_name"] = user.MiddleName; - } - - if (scopes.Contains(Scopes.Email)) - { - response[Claims.Email] = user.Email; - response[Claims.EmailVerified] = user.EmailConfirmed; - } - - if (scopes.Contains(Scopes.Roles)) - { - response[Claims.Role] = (await userManager.GetRolesAsync(user)).ToArray(); - } - - foreach (var claim in await userManager.GetClaimsAsync(user)) - { - if (response.ContainsKey(claim.Type) == false) - { - response[claim.Type] = claim.Value; - } - } - - return Results.Json(response.Where(pair => pair.Value is not null).ToDictionary(pair => pair.Key, pair => pair.Value)); - - static IResult InvalidToken(string description) => Results.Challenge(new AuthenticationProperties(new Dictionary - { - [OpenIddictValidationAspNetCoreConstants.Properties.Error] = Errors.InvalidToken, - [OpenIddictValidationAspNetCoreConstants.Properties.ErrorDescription] = description - }), [OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme]); - } - - private static async Task CompleteAuthorizationAsync( - HttpContext context, - ApplicationUser user, - OpenIddictRequest request, - object application, - IOpenIddictAuthorizationManager authorizationManager, - UserManager userManager, - IOpenIddictScopeManager scopeManager, - CancellationToken cancellationToken, - IReadOnlyCollection grantedScopes) - { - var resources = await scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(grantedScopes), cancellationToken).ToListAsync(cancellationToken); - var applicationManager = context.RequestServices.GetRequiredService(); - var authorizations = await authorizationManager.FindAsync( - subject: user.Id, - client: await applicationManager.GetIdAsync(application, cancellationToken), - status: Statuses.Valid, - type: AuthorizationTypes.Permanent, - scopes: ImmutableArray.CreateRange(grantedScopes), - cancellationToken: cancellationToken).ToListAsync(cancellationToken); - - var authorization = authorizations.LastOrDefault(); - var principal = await OidcHelpers.CreatePrincipalAsync(user, userManager, grantedScopes, resources, authorizationId: null); - - authorization ??= await authorizationManager.CreateAsync( - principal: principal, - subject: user.Id, - client: (await applicationManager.GetIdAsync(application, cancellationToken))!, - type: AuthorizationTypes.Permanent, - scopes: ImmutableArray.CreateRange(grantedScopes), - cancellationToken: cancellationToken); - - principal.SetAuthorizationId(await authorizationManager.GetIdAsync(authorization, cancellationToken)); - return Results.SignIn(principal, authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); - } + public static Task UserInfoAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) + => mediator.Send(new OidcCommands.UserInfoCommand(context), cancellationToken); } + diff --git a/SecurityService/Oidc/OidcHelpers.cs b/SecurityService/Oidc/OidcHelpers.cs index 0cf8894a..2fc95740 100644 --- a/SecurityService/Oidc/OidcHelpers.cs +++ b/SecurityService/Oidc/OidcHelpers.cs @@ -11,6 +11,7 @@ namespace SecurityService.Oidc; + public static class OidcHelpers { public static string BuildCurrentRequestUrl(HttpRequest request) @@ -156,6 +157,25 @@ public static IEnumerable GetDestinations(Claim claim) return (identityScopes, apiScopes); } + + public static async Task> ResolveClientCredentialsScopesAsync( + OpenIddictRequest request, + SecurityServiceDbContext dbContext, + CancellationToken cancellationToken) + { + IReadOnlyCollection grantedScopes = request.GetScopes().ToArray(); + if (grantedScopes.Count > 0) + { + return grantedScopes; + } + + var clientDefinition = await dbContext.ClientDefinitions + .SingleOrDefaultAsync(client => client.ClientId == request.ClientId, cancellationToken); + + return clientDefinition is null + ? Array.Empty() + : JsonListSerializer.Deserialize(clientDefinition.AllowedScopesJson); + } } public sealed record ScopeDisplayItem(string Name, string DisplayName, string? Description, bool Required, bool Emphasize); diff --git a/SecurityService/Oidc/OidcRequestHandler.cs b/SecurityService/Oidc/OidcRequestHandler.cs new file mode 100644 index 00000000..404a62af --- /dev/null +++ b/SecurityService/Oidc/OidcRequestHandler.cs @@ -0,0 +1,312 @@ +using System.Collections.Immutable; +using System.Security.Claims; +using MediatR; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using OpenIddict.Validation.AspNetCore; +using SecurityService.Database; +using SecurityService.Database.DbContexts; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace SecurityService.Oidc; + +public sealed class OidcRequestHandler : + IRequestHandler, + IRequestHandler, + IRequestHandler, + IRequestHandler +{ + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly IOpenIddictApplicationManager _applicationManager; + private readonly IOpenIddictAuthorizationManager _authorizationManager; + private readonly IOpenIddictScopeManager _scopeManager; + private readonly SecurityServiceDbContext _dbContext; + + public OidcRequestHandler( + UserManager userManager, + SignInManager signInManager, + IOpenIddictApplicationManager applicationManager, + IOpenIddictAuthorizationManager authorizationManager, + IOpenIddictScopeManager scopeManager, + SecurityServiceDbContext dbContext) + { + this._userManager = userManager; + this._signInManager = signInManager; + this._applicationManager = applicationManager; + this._authorizationManager = authorizationManager; + this._scopeManager = scopeManager; + this._dbContext = dbContext; + } + + public async Task Handle(OidcCommands.AuthorizeCommand command, CancellationToken cancellationToken) + { + var context = command.HttpContext; + var request = context.GetOpenIddictServerRequest() ?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); + var currentRequestUrl = OidcHelpers.BuildCurrentRequestUrl(context.Request); + + var authenticationResult = await context.AuthenticateAsync(IdentityConstants.ApplicationScheme); + if (authenticationResult.Succeeded == false) + { + if (request.HasPromptValue(PromptValues.None)) + { + return Results.Forbid(new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.LoginRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is not logged in." + }), [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); + } + + return Results.Challenge(new AuthenticationProperties { RedirectUri = currentRequestUrl }, [IdentityConstants.ApplicationScheme]); + } + + var user = await this._userManager.GetUserAsync(authenticationResult.Principal); + if (user is null) + { + return Results.Forbid(new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.LoginRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user account could not be resolved." + }), [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); + } + + var application = await this._applicationManager.FindByClientIdAsync(request.ClientId!, cancellationToken); + if (application is null) + { + return Results.BadRequest(new { error = "invalid_client", error_description = "The client application could not be found." }); + } + + if (context.Request.Query.TryGetValue("consent", out var consentDecision)) + { + if (string.Equals(consentDecision, "denied", StringComparison.OrdinalIgnoreCase)) + { + return Results.Forbid(new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.AccessDenied, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The authorization request was denied." + }), [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); + } + + if (string.Equals(consentDecision, "accepted", StringComparison.OrdinalIgnoreCase)) + { + return await this.CompleteAuthorizationAsync(user, request, application, cancellationToken, OidcHelpers.ReadMultiValue(context.Request.Query, "granted_scope")); + } + } + + var consentType = await this._applicationManager.GetConsentTypeAsync(application, cancellationToken) ?? ConsentTypes.Implicit; + var existingAuthorizations = await this._authorizationManager.FindAsync( + subject: user.Id, + client: await this._applicationManager.GetIdAsync(application, cancellationToken), + status: Statuses.Valid, + type: AuthorizationTypes.Permanent, + scopes: ImmutableArray.CreateRange(request.GetScopes()), + cancellationToken: cancellationToken).ToListAsync(cancellationToken); + + if (string.Equals(consentType, ConsentTypes.Explicit, StringComparison.OrdinalIgnoreCase) && existingAuthorizations.Count == 0) + { + if (request.HasPromptValue(PromptValues.None)) + { + return Results.Forbid(new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "Interactive user consent is required." + }), [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); + } + + return Results.Redirect($"/Consent?returnUrl={Uri.EscapeDataString(currentRequestUrl)}"); + } + + return await this.CompleteAuthorizationAsync(user, request, application, cancellationToken, request.GetScopes()); + } + + public async Task Handle(OidcCommands.TokenCommand command, CancellationToken cancellationToken) + { + var context = command.HttpContext; + var request = context.GetOpenIddictServerRequest() ?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); + + if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType() || request.IsDeviceCodeGrantType()) + { + var authenticationResult = await context.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + if (authenticationResult.Succeeded == false || authenticationResult.Principal is null) + { + return InvalidGrant("The specified token is invalid."); + } + + var subject = authenticationResult.Principal.GetClaim(Claims.Subject); + if (string.IsNullOrWhiteSpace(subject)) + { + return Results.SignIn(authenticationResult.Principal, authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + var user = await this._userManager.FindByIdAsync(subject); + if (user is null || await this._signInManager.CanSignInAsync(user) == false) + { + return InvalidGrant("The token is no longer valid."); + } + + var principal = await OidcHelpers.CreatePrincipalAsync( + user, + this._userManager, + authenticationResult.Principal.GetScopes(), + await this._scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(authenticationResult.Principal.GetScopes()), cancellationToken).ToListAsync(cancellationToken), + authenticationResult.Principal.GetAuthorizationId()); + return Results.SignIn(principal, authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + if (request.IsClientCredentialsGrantType()) + { + var grantedScopes = await OidcHelpers.ResolveClientCredentialsScopesAsync(request, this._dbContext, cancellationToken); + + var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role); + identity.SetClaim(Claims.Subject, request.ClientId!) + .SetClaim(Claims.Name, request.ClientId!); + identity.SetScopes(grantedScopes); + identity.SetResources(await this._scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(grantedScopes), cancellationToken).ToListAsync(cancellationToken)); + identity.SetDestinations(OidcHelpers.GetDestinations); + return Results.SignIn(new ClaimsPrincipal(identity), authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + if (request.IsPasswordGrantType()) + { + var user = await this._userManager.FindByNameAsync(request.Username!); + if (user is null) + { + return InvalidGrant(); + } + + var signInResult = await this._signInManager.CheckPasswordSignInAsync(user, request.Password!, lockoutOnFailure: true); + if (signInResult.Succeeded == false) + { + return InvalidGrant(); + } + + var grantedScopes = await OidcHelpers.ResolveClientCredentialsScopesAsync(request, this._dbContext, cancellationToken); + + var resources = await this._scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(grantedScopes), cancellationToken).ToListAsync(cancellationToken); + var principal = await OidcHelpers.CreatePrincipalAsync(user, this._userManager, grantedScopes, resources, authorizationId: null); + return Results.SignIn(principal, authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + return Results.BadRequest(new { error = "unsupported_grant_type", error_description = "The specified grant type is not supported by this service." }); + } + + public Task Handle(OidcCommands.LogoutCommand command, CancellationToken cancellationToken) + { + var context = command.HttpContext; + var currentRequestUrl = OidcHelpers.BuildCurrentRequestUrl(context.Request); + var confirmed = string.Equals(context.Request.Query["logout"], "confirmed", StringComparison.OrdinalIgnoreCase); + if (confirmed) + { + return Task.FromResult(Results.SignOut(new AuthenticationProperties { RedirectUri = "/Account/Logout/LoggedOut" }, [IdentityConstants.ApplicationScheme, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme])); + } + + return Task.FromResult(Results.Redirect($"/Account/Logout?returnUrl={Uri.EscapeDataString(currentRequestUrl)}")); + } + + public async Task Handle(OidcCommands.UserInfoCommand command, CancellationToken cancellationToken) + { + var context = command.HttpContext; + var authenticationResult = await context.AuthenticateAsync(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme); + if (authenticationResult.Succeeded == false || authenticationResult.Principal is null) + { + return InvalidToken("The access token is missing or invalid."); + } + + var subject = authenticationResult.Principal.GetClaim(Claims.Subject); + if (string.IsNullOrWhiteSpace(subject)) + { + return InvalidToken("The access token does not contain a subject identifier."); + } + + var user = await this._userManager.FindByIdAsync(subject); + if (user is null) + { + return InvalidToken("The user associated with the token could not be found."); + } + + var scopes = authenticationResult.Principal.GetScopes().ToHashSet(StringComparer.OrdinalIgnoreCase); + var response = new Dictionary + { + [Claims.Subject] = user.Id + }; + + if (scopes.Contains(Scopes.Profile)) + { + response[Claims.Name] = user.UserName; + response[Claims.PreferredUsername] = user.UserName; + response[Claims.GivenName] = user.GivenName; + response[Claims.FamilyName] = user.FamilyName; + response["middle_name"] = user.MiddleName; + } + + if (scopes.Contains(Scopes.Email)) + { + response[Claims.Email] = user.Email; + response[Claims.EmailVerified] = user.EmailConfirmed; + } + + if (scopes.Contains(Scopes.Roles)) + { + response[Claims.Role] = (await this._userManager.GetRolesAsync(user)).ToArray(); + } + + foreach (var claim in await this._userManager.GetClaimsAsync(user)) + { + if (response.ContainsKey(claim.Type) == false) + { + response[claim.Type] = claim.Value; + } + } + + return Results.Json(response.Where(pair => pair.Value is not null).ToDictionary(pair => pair.Key, pair => pair.Value)); + } + + private async Task CompleteAuthorizationAsync( + ApplicationUser user, + OpenIddictRequest request, + object application, + CancellationToken cancellationToken, + IReadOnlyCollection grantedScopes) + { + var resources = await this._scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(grantedScopes), cancellationToken).ToListAsync(cancellationToken); + var authorizations = await this._authorizationManager.FindAsync( + subject: user.Id, + client: await this._applicationManager.GetIdAsync(application, cancellationToken), + status: Statuses.Valid, + type: AuthorizationTypes.Permanent, + scopes: ImmutableArray.CreateRange(grantedScopes), + cancellationToken: cancellationToken).ToListAsync(cancellationToken); + + var authorization = authorizations.LastOrDefault(); + var principal = await OidcHelpers.CreatePrincipalAsync(user, this._userManager, grantedScopes, resources, authorizationId: null); + + authorization ??= await this._authorizationManager.CreateAsync( + principal: principal, + subject: user.Id, + client: (await this._applicationManager.GetIdAsync(application, cancellationToken))!, + type: AuthorizationTypes.Permanent, + scopes: ImmutableArray.CreateRange(grantedScopes), + cancellationToken: cancellationToken); + + principal.SetAuthorizationId(await this._authorizationManager.GetIdAsync(authorization, cancellationToken)); + return Results.SignIn(principal, authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + private static IResult InvalidGrant(string description = "The username/password couple is invalid.") => + Results.Forbid(new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description + }), [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); + + private static IResult InvalidToken(string description) => + Results.Challenge(new AuthenticationProperties(new Dictionary + { + [OpenIddictValidationAspNetCoreConstants.Properties.Error] = Errors.InvalidToken, + [OpenIddictValidationAspNetCoreConstants.Properties.ErrorDescription] = description + }), [OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme]); +} diff --git a/SecurityService/Program.cs b/SecurityService/Program.cs index 72b305d1..32be9285 100644 --- a/SecurityService/Program.cs +++ b/SecurityService/Program.cs @@ -207,7 +207,11 @@ builder.Services.AddAuthorization(); -builder.Services.AddMediatR(configuration => configuration.RegisterServicesFromAssembly(typeof(SecurityServiceCommands).Assembly)); +builder.Services.AddMediatR(configuration => +{ + configuration.RegisterServicesFromAssembly(typeof(SecurityServiceCommands).Assembly); + configuration.RegisterServicesFromAssembly(typeof(OidcRequestHandler).Assembly); +}); builder.Services.ConfigureHttpJsonOptions(jsonOptions => JsonSerializerConfiguration.ConfigureMinimalApi(jsonOptions.SerializerOptions)); builder.Services.AddRazorPages(); builder.Services.AddEndpointsApiExplorer(); From 4d69eb964a3f4cf95f84e80ee52b9cf6a39146ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:09:59 +0000 Subject: [PATCH 03/11] Fix extra blank line in OidcHelpers.cs after namespace declaration Agent-Logs-Url: https://github.com/TransactionProcessing/SecurityService/sessions/7a8f99ca-20b5-4a64-8879-c7d4d0abc398 Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- SecurityService/Oidc/OidcHelpers.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/SecurityService/Oidc/OidcHelpers.cs b/SecurityService/Oidc/OidcHelpers.cs index 2fc95740..014da3f8 100644 --- a/SecurityService/Oidc/OidcHelpers.cs +++ b/SecurityService/Oidc/OidcHelpers.cs @@ -11,7 +11,6 @@ namespace SecurityService.Oidc; - public static class OidcHelpers { public static string BuildCurrentRequestUrl(HttpRequest request) From 4b00ba975b0503aa7cafef3dbb02e74474a26df3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:47:31 +0000 Subject: [PATCH 04/11] Reduce OidcRequestHandler method complexity and size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract private helpers to reduce cyclomatic complexity: - Handle(AuthorizeCommand): delegates auth-fail, consent-decision, and consent-type branches to HandleAuthenticationFailed, HandleConsentDecisionAsync, HandleConsentTypeAsync - Handle(TokenCommand): dispatches to HandleCodeOrRefreshTokenAsync, HandleClientCredentialsAsync, HandlePasswordGrantAsync - Handle(UserInfoCommand): delegates response building to BuildUserInfoResponseAsync - Consolidate repeated ForbidServer pattern into shared private method Each public Handle method is now under 30 lines with complexity ≤ 4 Agent-Logs-Url: https://github.com/TransactionProcessing/SecurityService/sessions/0ba17972-8cbb-4946-9aef-99775a7a6b0c Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- SecurityService/Oidc/OidcRequestHandler.cs | 246 ++++++++++++--------- 1 file changed, 139 insertions(+), 107 deletions(-) diff --git a/SecurityService/Oidc/OidcRequestHandler.cs b/SecurityService/Oidc/OidcRequestHandler.cs index 404a62af..e25bf8c4 100644 --- a/SecurityService/Oidc/OidcRequestHandler.cs +++ b/SecurityService/Oidc/OidcRequestHandler.cs @@ -52,26 +52,13 @@ public async Task Handle(OidcCommands.AuthorizeCommand command, Cancell var authenticationResult = await context.AuthenticateAsync(IdentityConstants.ApplicationScheme); if (authenticationResult.Succeeded == false) { - if (request.HasPromptValue(PromptValues.None)) - { - return Results.Forbid(new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.LoginRequired, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is not logged in." - }), [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); - } - - return Results.Challenge(new AuthenticationProperties { RedirectUri = currentRequestUrl }, [IdentityConstants.ApplicationScheme]); + return HandleAuthenticationFailed(request, currentRequestUrl); } var user = await this._userManager.GetUserAsync(authenticationResult.Principal); if (user is null) { - return Results.Forbid(new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.LoginRequired, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user account could not be resolved." - }), [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); + return ForbidServer(Errors.LoginRequired, "The user account could not be resolved."); } var application = await this._applicationManager.FindByClientIdAsync(request.ClientId!, cancellationToken); @@ -82,45 +69,10 @@ public async Task Handle(OidcCommands.AuthorizeCommand command, Cancell if (context.Request.Query.TryGetValue("consent", out var consentDecision)) { - if (string.Equals(consentDecision, "denied", StringComparison.OrdinalIgnoreCase)) - { - return Results.Forbid(new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.AccessDenied, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The authorization request was denied." - }), [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); - } - - if (string.Equals(consentDecision, "accepted", StringComparison.OrdinalIgnoreCase)) - { - return await this.CompleteAuthorizationAsync(user, request, application, cancellationToken, OidcHelpers.ReadMultiValue(context.Request.Query, "granted_scope")); - } + return await this.HandleConsentDecisionAsync(user, request, application, context, consentDecision!, cancellationToken); } - var consentType = await this._applicationManager.GetConsentTypeAsync(application, cancellationToken) ?? ConsentTypes.Implicit; - var existingAuthorizations = await this._authorizationManager.FindAsync( - subject: user.Id, - client: await this._applicationManager.GetIdAsync(application, cancellationToken), - status: Statuses.Valid, - type: AuthorizationTypes.Permanent, - scopes: ImmutableArray.CreateRange(request.GetScopes()), - cancellationToken: cancellationToken).ToListAsync(cancellationToken); - - if (string.Equals(consentType, ConsentTypes.Explicit, StringComparison.OrdinalIgnoreCase) && existingAuthorizations.Count == 0) - { - if (request.HasPromptValue(PromptValues.None)) - { - return Results.Forbid(new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.ConsentRequired, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "Interactive user consent is required." - }), [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); - } - - return Results.Redirect($"/Consent?returnUrl={Uri.EscapeDataString(currentRequestUrl)}"); - } - - return await this.CompleteAuthorizationAsync(user, request, application, cancellationToken, request.GetScopes()); + return await this.HandleConsentTypeAsync(user, request, application, currentRequestUrl, cancellationToken); } public async Task Handle(OidcCommands.TokenCommand command, CancellationToken cancellationToken) @@ -130,65 +82,17 @@ public async Task Handle(OidcCommands.TokenCommand command, Cancellatio if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType() || request.IsDeviceCodeGrantType()) { - var authenticationResult = await context.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); - if (authenticationResult.Succeeded == false || authenticationResult.Principal is null) - { - return InvalidGrant("The specified token is invalid."); - } - - var subject = authenticationResult.Principal.GetClaim(Claims.Subject); - if (string.IsNullOrWhiteSpace(subject)) - { - return Results.SignIn(authenticationResult.Principal, authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); - } - - var user = await this._userManager.FindByIdAsync(subject); - if (user is null || await this._signInManager.CanSignInAsync(user) == false) - { - return InvalidGrant("The token is no longer valid."); - } - - var principal = await OidcHelpers.CreatePrincipalAsync( - user, - this._userManager, - authenticationResult.Principal.GetScopes(), - await this._scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(authenticationResult.Principal.GetScopes()), cancellationToken).ToListAsync(cancellationToken), - authenticationResult.Principal.GetAuthorizationId()); - return Results.SignIn(principal, authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return await this.HandleCodeOrRefreshTokenAsync(context, cancellationToken); } if (request.IsClientCredentialsGrantType()) { - var grantedScopes = await OidcHelpers.ResolveClientCredentialsScopesAsync(request, this._dbContext, cancellationToken); - - var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role); - identity.SetClaim(Claims.Subject, request.ClientId!) - .SetClaim(Claims.Name, request.ClientId!); - identity.SetScopes(grantedScopes); - identity.SetResources(await this._scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(grantedScopes), cancellationToken).ToListAsync(cancellationToken)); - identity.SetDestinations(OidcHelpers.GetDestinations); - return Results.SignIn(new ClaimsPrincipal(identity), authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return await this.HandleClientCredentialsAsync(request, cancellationToken); } if (request.IsPasswordGrantType()) { - var user = await this._userManager.FindByNameAsync(request.Username!); - if (user is null) - { - return InvalidGrant(); - } - - var signInResult = await this._signInManager.CheckPasswordSignInAsync(user, request.Password!, lockoutOnFailure: true); - if (signInResult.Succeeded == false) - { - return InvalidGrant(); - } - - var grantedScopes = await OidcHelpers.ResolveClientCredentialsScopesAsync(request, this._dbContext, cancellationToken); - - var resources = await this._scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(grantedScopes), cancellationToken).ToListAsync(cancellationToken); - var principal = await OidcHelpers.CreatePrincipalAsync(user, this._userManager, grantedScopes, resources, authorizationId: null); - return Results.SignIn(principal, authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return await this.HandlePasswordGrantAsync(request, cancellationToken); } return Results.BadRequest(new { error = "unsupported_grant_type", error_description = "The specified grant type is not supported by this service." }); @@ -229,10 +133,131 @@ public async Task Handle(OidcCommands.UserInfoCommand command, Cancella } var scopes = authenticationResult.Principal.GetScopes().ToHashSet(StringComparer.OrdinalIgnoreCase); - var response = new Dictionary + var response = await this.BuildUserInfoResponseAsync(user, scopes); + return Results.Json(response.Where(pair => pair.Value is not null).ToDictionary(pair => pair.Key, pair => pair.Value)); + } + + private static IResult HandleAuthenticationFailed(OpenIddictRequest request, string currentRequestUrl) + { + if (request.HasPromptValue(PromptValues.None)) + { + return ForbidServer(Errors.LoginRequired, "The user is not logged in."); + } + + return Results.Challenge(new AuthenticationProperties { RedirectUri = currentRequestUrl }, [IdentityConstants.ApplicationScheme]); + } + + private async Task HandleConsentDecisionAsync( + ApplicationUser user, + OpenIddictRequest request, + object application, + HttpContext context, + string consentDecision, + CancellationToken cancellationToken) + { + if (string.Equals(consentDecision, "denied", StringComparison.OrdinalIgnoreCase)) + { + return ForbidServer(Errors.AccessDenied, "The authorization request was denied."); + } + + if (string.Equals(consentDecision, "accepted", StringComparison.OrdinalIgnoreCase)) + { + return await this.CompleteAuthorizationAsync(user, request, application, cancellationToken, OidcHelpers.ReadMultiValue(context.Request.Query, "granted_scope")); + } + + return await this.HandleConsentTypeAsync(user, request, application, OidcHelpers.BuildCurrentRequestUrl(context.Request), cancellationToken); + } + + private async Task HandleConsentTypeAsync( + ApplicationUser user, + OpenIddictRequest request, + object application, + string currentRequestUrl, + CancellationToken cancellationToken) + { + var consentType = await this._applicationManager.GetConsentTypeAsync(application, cancellationToken) ?? ConsentTypes.Implicit; + var existingAuthorizations = await this._authorizationManager.FindAsync( + subject: user.Id, + client: await this._applicationManager.GetIdAsync(application, cancellationToken), + status: Statuses.Valid, + type: AuthorizationTypes.Permanent, + scopes: ImmutableArray.CreateRange(request.GetScopes()), + cancellationToken: cancellationToken).ToListAsync(cancellationToken); + + if (string.Equals(consentType, ConsentTypes.Explicit, StringComparison.OrdinalIgnoreCase) && existingAuthorizations.Count == 0) { - [Claims.Subject] = user.Id - }; + if (request.HasPromptValue(PromptValues.None)) + { + return ForbidServer(Errors.ConsentRequired, "Interactive user consent is required."); + } + + return Results.Redirect($"/Consent?returnUrl={Uri.EscapeDataString(currentRequestUrl)}"); + } + + return await this.CompleteAuthorizationAsync(user, request, application, cancellationToken, request.GetScopes()); + } + + private async Task HandleCodeOrRefreshTokenAsync(HttpContext context, CancellationToken cancellationToken) + { + var authenticationResult = await context.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + if (authenticationResult.Succeeded == false || authenticationResult.Principal is null) + { + return InvalidGrant("The specified token is invalid."); + } + + var subject = authenticationResult.Principal.GetClaim(Claims.Subject); + if (string.IsNullOrWhiteSpace(subject)) + { + return Results.SignIn(authenticationResult.Principal, authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + var user = await this._userManager.FindByIdAsync(subject); + if (user is null || await this._signInManager.CanSignInAsync(user) == false) + { + return InvalidGrant("The token is no longer valid."); + } + + var scopes = authenticationResult.Principal.GetScopes(); + var resources = await this._scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(scopes), cancellationToken).ToListAsync(cancellationToken); + var principal = await OidcHelpers.CreatePrincipalAsync(user, this._userManager, scopes, resources, authenticationResult.Principal.GetAuthorizationId()); + return Results.SignIn(principal, authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + private async Task HandleClientCredentialsAsync(OpenIddictRequest request, CancellationToken cancellationToken) + { + var grantedScopes = await OidcHelpers.ResolveClientCredentialsScopesAsync(request, this._dbContext, cancellationToken); + var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role); + identity.SetClaim(Claims.Subject, request.ClientId!) + .SetClaim(Claims.Name, request.ClientId!); + identity.SetScopes(grantedScopes); + identity.SetResources(await this._scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(grantedScopes), cancellationToken).ToListAsync(cancellationToken)); + identity.SetDestinations(OidcHelpers.GetDestinations); + return Results.SignIn(new ClaimsPrincipal(identity), authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + private async Task HandlePasswordGrantAsync(OpenIddictRequest request, CancellationToken cancellationToken) + { + var user = await this._userManager.FindByNameAsync(request.Username!); + if (user is null) + { + return InvalidGrant(); + } + + var signInResult = await this._signInManager.CheckPasswordSignInAsync(user, request.Password!, lockoutOnFailure: true); + if (signInResult.Succeeded == false) + { + return InvalidGrant(); + } + + var grantedScopes = await OidcHelpers.ResolveClientCredentialsScopesAsync(request, this._dbContext, cancellationToken); + var resources = await this._scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(grantedScopes), cancellationToken).ToListAsync(cancellationToken); + var principal = await OidcHelpers.CreatePrincipalAsync(user, this._userManager, grantedScopes, resources, authorizationId: null); + return Results.SignIn(principal, authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + private async Task> BuildUserInfoResponseAsync(ApplicationUser user, HashSet scopes) + { + var response = new Dictionary { [Claims.Subject] = user.Id }; if (scopes.Contains(Scopes.Profile)) { @@ -262,9 +287,16 @@ public async Task Handle(OidcCommands.UserInfoCommand command, Cancella } } - return Results.Json(response.Where(pair => pair.Value is not null).ToDictionary(pair => pair.Key, pair => pair.Value)); + return response; } + private static IResult ForbidServer(string error, string description) => + Results.Forbid(new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = error, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description + }), [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); + private async Task CompleteAuthorizationAsync( ApplicationUser user, OpenIddictRequest request, From 02aa1c8fb0a07072d938230ccd5b559eed9e32d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 09:06:05 +0000 Subject: [PATCH 05/11] Remove Async suffix from private and helper methods Rename all Async-suffixed methods added in the previous refactor: - OidcRequestHandler.cs: HandleConsentDecision, HandleConsentType, HandleCodeOrRefreshToken, HandleClientCredentials, HandlePasswordGrant, BuildUserInfoResponse, CompleteAuthorization - OidcHelpers.cs: CreatePrincipal, BuildScopeDisplay (both overloads), ResolveClientCredentialsScopes - Update all call sites: OidcRequestHandler.cs, Verify.cshtml.cs, Consent/Index.cshtml.cs, OidcEndpointTests.cs Agent-Logs-Url: https://github.com/TransactionProcessing/SecurityService/sessions/7eef1f9f-2923-4f7f-8e9a-beae9b063339 Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../Oidc/OidcEndpointTests.cs | 10 ++--- SecurityService/Oidc/OidcHelpers.cs | 10 ++--- SecurityService/Oidc/OidcRequestHandler.cs | 42 +++++++++---------- .../Pages/Connect/Verify.cshtml.cs | 4 +- SecurityService/Pages/Consent/Index.cshtml.cs | 2 +- 5 files changed, 34 insertions(+), 34 deletions(-) diff --git a/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs b/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs index f1a86e45..93a7d1ac 100644 --- a/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs +++ b/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs @@ -79,9 +79,9 @@ public async Task UserInfoAsync_WithValidPrincipal_ReturnsJsonPayload() } [Fact] - public async Task ResolveClientCredentialsScopesAsync_WithoutRequestedScopes_FallsBackToConfiguredClientScopes() + public async Task ResolveClientCredentialsScopes_WithoutRequestedScopes_FallsBackToConfiguredClientScopes() { - using var provider = TestServiceProviderFactory.Create(nameof(this.ResolveClientCredentialsScopesAsync_WithoutRequestedScopes_FallsBackToConfiguredClientScopes)); + using var provider = TestServiceProviderFactory.Create(nameof(this.ResolveClientCredentialsScopes_WithoutRequestedScopes_FallsBackToConfiguredClientScopes)); using var scope = provider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); @@ -105,7 +105,7 @@ public async Task ResolveClientCredentialsScopesAsync_WithoutRequestedScopes_Fal ClientId = "serviceClient" }; - var scopes = await OidcHelpers.ResolveClientCredentialsScopesAsync( + var scopes = await OidcHelpers.ResolveClientCredentialsScopes( request, dbContext, CancellationToken.None); @@ -114,7 +114,7 @@ public async Task ResolveClientCredentialsScopesAsync_WithoutRequestedScopes_Fal } [Fact] - public async Task CreatePrincipalAsync_WhenUserClaimsDuplicateBuiltInClaims_DoesNotDuplicateValues() + public async Task CreatePrincipal_WhenUserClaimsDuplicateBuiltInClaims_DoesNotDuplicateValues() { var userManager = IdentityMocks.CreateUserManager(); var user = new ApplicationUser @@ -135,7 +135,7 @@ public async Task CreatePrincipalAsync_WhenUserClaimsDuplicateBuiltInClaims_Does new Claim(OpenIddictConstants.Claims.Role, "Merchant") ]); - var principal = await OidcHelpers.CreatePrincipalAsync( + var principal = await OidcHelpers.CreatePrincipal( user, userManager.Object, [OpenIddictConstants.Scopes.Email, OpenIddictConstants.Scopes.Profile, OpenIddictConstants.Scopes.Roles], diff --git a/SecurityService/Oidc/OidcHelpers.cs b/SecurityService/Oidc/OidcHelpers.cs index 014da3f8..eae62574 100644 --- a/SecurityService/Oidc/OidcHelpers.cs +++ b/SecurityService/Oidc/OidcHelpers.cs @@ -38,7 +38,7 @@ public static string AppendQueryValues(string url, string name, IEnumerable ReadMultiValue(IQueryCollection query, string key) => query.TryGetValue(key, out StringValues values) ? values.Where(value => value is not null).Cast().ToArray() : Array.Empty(); - public static async Task CreatePrincipalAsync( + public static async Task CreatePrincipal( ApplicationUser user, UserManager userManager, IEnumerable scopes, @@ -116,15 +116,15 @@ public static IEnumerable GetDestinations(Claim claim) }; } - public static async Task<(IReadOnlyCollection IdentityScopes, IReadOnlyCollection ApiScopes)> BuildScopeDisplayAsync( + public static async Task<(IReadOnlyCollection IdentityScopes, IReadOnlyCollection ApiScopes)> BuildScopeDisplay( OpenIddictRequest request, SecurityServiceDbContext dbContext, CancellationToken cancellationToken) { - return await BuildScopeDisplayAsync(request.GetScopes(), dbContext, cancellationToken); + return await BuildScopeDisplay(request.GetScopes(), dbContext, cancellationToken); } - public static async Task<(IReadOnlyCollection IdentityScopes, IReadOnlyCollection ApiScopes)> BuildScopeDisplayAsync( + public static async Task<(IReadOnlyCollection IdentityScopes, IReadOnlyCollection ApiScopes)> BuildScopeDisplay( IEnumerable scopeNames, SecurityServiceDbContext dbContext, CancellationToken cancellationToken) @@ -157,7 +157,7 @@ public static IEnumerable GetDestinations(Claim claim) return (identityScopes, apiScopes); } - public static async Task> ResolveClientCredentialsScopesAsync( + public static async Task> ResolveClientCredentialsScopes( OpenIddictRequest request, SecurityServiceDbContext dbContext, CancellationToken cancellationToken) diff --git a/SecurityService/Oidc/OidcRequestHandler.cs b/SecurityService/Oidc/OidcRequestHandler.cs index e25bf8c4..ff6a8ae5 100644 --- a/SecurityService/Oidc/OidcRequestHandler.cs +++ b/SecurityService/Oidc/OidcRequestHandler.cs @@ -69,10 +69,10 @@ public async Task Handle(OidcCommands.AuthorizeCommand command, Cancell if (context.Request.Query.TryGetValue("consent", out var consentDecision)) { - return await this.HandleConsentDecisionAsync(user, request, application, context, consentDecision!, cancellationToken); + return await this.HandleConsentDecision(user, request, application, context, consentDecision!, cancellationToken); } - return await this.HandleConsentTypeAsync(user, request, application, currentRequestUrl, cancellationToken); + return await this.HandleConsentType(user, request, application, currentRequestUrl, cancellationToken); } public async Task Handle(OidcCommands.TokenCommand command, CancellationToken cancellationToken) @@ -82,17 +82,17 @@ public async Task Handle(OidcCommands.TokenCommand command, Cancellatio if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType() || request.IsDeviceCodeGrantType()) { - return await this.HandleCodeOrRefreshTokenAsync(context, cancellationToken); + return await this.HandleCodeOrRefreshToken(context, cancellationToken); } if (request.IsClientCredentialsGrantType()) { - return await this.HandleClientCredentialsAsync(request, cancellationToken); + return await this.HandleClientCredentials(request, cancellationToken); } if (request.IsPasswordGrantType()) { - return await this.HandlePasswordGrantAsync(request, cancellationToken); + return await this.HandlePasswordGrant(request, cancellationToken); } return Results.BadRequest(new { error = "unsupported_grant_type", error_description = "The specified grant type is not supported by this service." }); @@ -133,7 +133,7 @@ public async Task Handle(OidcCommands.UserInfoCommand command, Cancella } var scopes = authenticationResult.Principal.GetScopes().ToHashSet(StringComparer.OrdinalIgnoreCase); - var response = await this.BuildUserInfoResponseAsync(user, scopes); + var response = await this.BuildUserInfoResponse(user, scopes); return Results.Json(response.Where(pair => pair.Value is not null).ToDictionary(pair => pair.Key, pair => pair.Value)); } @@ -147,7 +147,7 @@ private static IResult HandleAuthenticationFailed(OpenIddictRequest request, str return Results.Challenge(new AuthenticationProperties { RedirectUri = currentRequestUrl }, [IdentityConstants.ApplicationScheme]); } - private async Task HandleConsentDecisionAsync( + private async Task HandleConsentDecision( ApplicationUser user, OpenIddictRequest request, object application, @@ -162,13 +162,13 @@ private async Task HandleConsentDecisionAsync( if (string.Equals(consentDecision, "accepted", StringComparison.OrdinalIgnoreCase)) { - return await this.CompleteAuthorizationAsync(user, request, application, cancellationToken, OidcHelpers.ReadMultiValue(context.Request.Query, "granted_scope")); + return await this.CompleteAuthorization(user, request, application, cancellationToken, OidcHelpers.ReadMultiValue(context.Request.Query, "granted_scope")); } - return await this.HandleConsentTypeAsync(user, request, application, OidcHelpers.BuildCurrentRequestUrl(context.Request), cancellationToken); + return await this.HandleConsentType(user, request, application, OidcHelpers.BuildCurrentRequestUrl(context.Request), cancellationToken); } - private async Task HandleConsentTypeAsync( + private async Task HandleConsentType( ApplicationUser user, OpenIddictRequest request, object application, @@ -194,10 +194,10 @@ private async Task HandleConsentTypeAsync( return Results.Redirect($"/Consent?returnUrl={Uri.EscapeDataString(currentRequestUrl)}"); } - return await this.CompleteAuthorizationAsync(user, request, application, cancellationToken, request.GetScopes()); + return await this.CompleteAuthorization(user, request, application, cancellationToken, request.GetScopes()); } - private async Task HandleCodeOrRefreshTokenAsync(HttpContext context, CancellationToken cancellationToken) + private async Task HandleCodeOrRefreshToken(HttpContext context, CancellationToken cancellationToken) { var authenticationResult = await context.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); if (authenticationResult.Succeeded == false || authenticationResult.Principal is null) @@ -219,13 +219,13 @@ private async Task HandleCodeOrRefreshTokenAsync(HttpContext context, C var scopes = authenticationResult.Principal.GetScopes(); var resources = await this._scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(scopes), cancellationToken).ToListAsync(cancellationToken); - var principal = await OidcHelpers.CreatePrincipalAsync(user, this._userManager, scopes, resources, authenticationResult.Principal.GetAuthorizationId()); + var principal = await OidcHelpers.CreatePrincipal(user, this._userManager, scopes, resources, authenticationResult.Principal.GetAuthorizationId()); return Results.SignIn(principal, authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } - private async Task HandleClientCredentialsAsync(OpenIddictRequest request, CancellationToken cancellationToken) + private async Task HandleClientCredentials(OpenIddictRequest request, CancellationToken cancellationToken) { - var grantedScopes = await OidcHelpers.ResolveClientCredentialsScopesAsync(request, this._dbContext, cancellationToken); + var grantedScopes = await OidcHelpers.ResolveClientCredentialsScopes(request, this._dbContext, cancellationToken); var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role); identity.SetClaim(Claims.Subject, request.ClientId!) .SetClaim(Claims.Name, request.ClientId!); @@ -235,7 +235,7 @@ private async Task HandleClientCredentialsAsync(OpenIddictRequest reque return Results.SignIn(new ClaimsPrincipal(identity), authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } - private async Task HandlePasswordGrantAsync(OpenIddictRequest request, CancellationToken cancellationToken) + private async Task HandlePasswordGrant(OpenIddictRequest request, CancellationToken cancellationToken) { var user = await this._userManager.FindByNameAsync(request.Username!); if (user is null) @@ -249,13 +249,13 @@ private async Task HandlePasswordGrantAsync(OpenIddictRequest request, return InvalidGrant(); } - var grantedScopes = await OidcHelpers.ResolveClientCredentialsScopesAsync(request, this._dbContext, cancellationToken); + var grantedScopes = await OidcHelpers.ResolveClientCredentialsScopes(request, this._dbContext, cancellationToken); var resources = await this._scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(grantedScopes), cancellationToken).ToListAsync(cancellationToken); - var principal = await OidcHelpers.CreatePrincipalAsync(user, this._userManager, grantedScopes, resources, authorizationId: null); + var principal = await OidcHelpers.CreatePrincipal(user, this._userManager, grantedScopes, resources, authorizationId: null); return Results.SignIn(principal, authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } - private async Task> BuildUserInfoResponseAsync(ApplicationUser user, HashSet scopes) + private async Task> BuildUserInfoResponse(ApplicationUser user, HashSet scopes) { var response = new Dictionary { [Claims.Subject] = user.Id }; @@ -297,7 +297,7 @@ private static IResult ForbidServer(string error, string description) => [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description }), [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); - private async Task CompleteAuthorizationAsync( + private async Task CompleteAuthorization( ApplicationUser user, OpenIddictRequest request, object application, @@ -314,7 +314,7 @@ private async Task CompleteAuthorizationAsync( cancellationToken: cancellationToken).ToListAsync(cancellationToken); var authorization = authorizations.LastOrDefault(); - var principal = await OidcHelpers.CreatePrincipalAsync(user, this._userManager, grantedScopes, resources, authorizationId: null); + var principal = await OidcHelpers.CreatePrincipal(user, this._userManager, grantedScopes, resources, authorizationId: null); authorization ??= await this._authorizationManager.CreateAsync( principal: principal, diff --git a/SecurityService/Pages/Connect/Verify.cshtml.cs b/SecurityService/Pages/Connect/Verify.cshtml.cs index 0360b94e..1f75be5e 100644 --- a/SecurityService/Pages/Connect/Verify.cshtml.cs +++ b/SecurityService/Pages/Connect/Verify.cshtml.cs @@ -96,7 +96,7 @@ public async Task OnPostAsync(CancellationToken cancellationToken var scopes = result.AuthenticationResult.Principal!.GetScopes(); var resources = await this._scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(scopes), cancellationToken).ToListAsync(cancellationToken); - var principal = await OidcHelpers.CreatePrincipalAsync(user, this._userManager, scopes, resources, authorizationId: null); + var principal = await OidcHelpers.CreatePrincipal(user, this._userManager, scopes, resources, authorizationId: null); return this.SignIn(principal, new AuthenticationProperties { RedirectUri = "/" }, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } @@ -104,7 +104,7 @@ public async Task OnPostAsync(CancellationToken cancellationToken private async Task PopulateAsync(AuthenticateResult authenticationResult, CancellationToken cancellationToken) { this.RequestedScopes = authenticationResult.Principal!.GetScopes().ToArray(); - var scopeDisplay = await OidcHelpers.BuildScopeDisplayAsync(this.RequestedScopes, this._dbContext, cancellationToken); + var scopeDisplay = await OidcHelpers.BuildScopeDisplay(this.RequestedScopes, this._dbContext, cancellationToken); this.IdentityScopes = scopeDisplay.IdentityScopes; this.ApiScopes = scopeDisplay.ApiScopes; this.Input.UserCode = authenticationResult.Properties?.GetTokenValue(OpenIddictServerAspNetCoreConstants.Tokens.UserCode) ?? this.Request.Query["user_code"].FirstOrDefault() ?? string.Empty; diff --git a/SecurityService/Pages/Consent/Index.cshtml.cs b/SecurityService/Pages/Consent/Index.cshtml.cs index 47ff8ed3..9735c681 100644 --- a/SecurityService/Pages/Consent/Index.cshtml.cs +++ b/SecurityService/Pages/Consent/Index.cshtml.cs @@ -36,7 +36,7 @@ public async Task OnGetAsync(string returnUrl, CancellationToken var application = await this._applicationManager.FindByClientIdAsync(request.ClientId!, cancellationToken); this.ClientName = application is null ? request.ClientId! : await this._applicationManager.GetDisplayNameAsync(application, cancellationToken) ?? request.ClientId!; - var scopes = await OidcHelpers.BuildScopeDisplayAsync(request, this._dbContext, cancellationToken); + var scopes = await OidcHelpers.BuildScopeDisplay(request, this._dbContext, cancellationToken); this.IdentityScopes = scopes.IdentityScopes; this.ApiScopes = scopes.ApiScopes; return this.Page(); From b88c4398b55cd4782611d36878e3a5bcdd28c87a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:27:10 +0000 Subject: [PATCH 06/11] Add missing using Microsoft.AspNetCore to fix GetOpenIddictServerRequest build error Agent-Logs-Url: https://github.com/TransactionProcessing/SecurityService/sessions/ad87a1db-0141-445b-8444-0e27c7f269cb Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- SecurityService/Oidc/OidcRequestHandler.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/SecurityService/Oidc/OidcRequestHandler.cs b/SecurityService/Oidc/OidcRequestHandler.cs index ff6a8ae5..c681ae6e 100644 --- a/SecurityService/Oidc/OidcRequestHandler.cs +++ b/SecurityService/Oidc/OidcRequestHandler.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using System.Security.Claims; using MediatR; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; From 1f034a6efaf894191b386c47a0f5a8745e551a4d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:11:28 +0000 Subject: [PATCH 07/11] Move OidcCommands and OidcRequestHandler to BusinessLogic with Result return types Agent-Logs-Url: https://github.com/TransactionProcessing/SecurityService/sessions/03e146be-feb7-4170-b6e8-204aaef5ec6c Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../Oidc/OidcActionResult.cs | 20 +++ .../Oidc/OidcCommands.cs | 11 +- .../Oidc/OidcHelpers.cs | 2 +- .../Oidc/OidcRequestHandler.cs | 125 ++++++++++-------- .../SecurityService.BusinessLogic.csproj | 2 + .../Oidc/OidcEndpointTests.cs | 12 +- SecurityService/Oidc/OidcEndpoints.cs | 54 ++++++-- .../Pages/Account/Grants/Index.cshtml.cs | 2 +- .../Pages/Account/Logout/Index.cshtml.cs | 2 +- .../Pages/Connect/Verify.cshtml.cs | 2 +- SecurityService/Pages/Consent/Index.cshtml.cs | 2 +- SecurityService/Program.cs | 1 - 12 files changed, 156 insertions(+), 79 deletions(-) create mode 100644 SecurityService.BusinessLogic/Oidc/OidcActionResult.cs rename {SecurityService => SecurityService.BusinessLogic}/Oidc/OidcCommands.cs (60%) rename {SecurityService => SecurityService.BusinessLogic}/Oidc/OidcHelpers.cs (99%) rename {SecurityService => SecurityService.BusinessLogic}/Oidc/OidcRequestHandler.cs (65%) diff --git a/SecurityService.BusinessLogic/Oidc/OidcActionResult.cs b/SecurityService.BusinessLogic/Oidc/OidcActionResult.cs new file mode 100644 index 00000000..d7c05cac --- /dev/null +++ b/SecurityService.BusinessLogic/Oidc/OidcActionResult.cs @@ -0,0 +1,20 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; + +namespace SecurityService.BusinessLogic.Oidc; + +public abstract record OidcActionResult; + +public sealed record OidcSignInResult(ClaimsPrincipal Principal, string AuthenticationScheme) : OidcActionResult; + +public sealed record OidcSignOutResult(AuthenticationProperties Properties, IList AuthenticationSchemes) : OidcActionResult; + +public sealed record OidcRedirectResult(string Url) : OidcActionResult; + +public sealed record OidcForbidResult(AuthenticationProperties Properties, IList AuthenticationSchemes) : OidcActionResult; + +public sealed record OidcChallengeResult(AuthenticationProperties Properties, IList AuthenticationSchemes) : OidcActionResult; + +public sealed record OidcJsonResult(Dictionary Data) : OidcActionResult; + +public sealed record OidcBadRequestResult(object Error) : OidcActionResult; diff --git a/SecurityService/Oidc/OidcCommands.cs b/SecurityService.BusinessLogic/Oidc/OidcCommands.cs similarity index 60% rename from SecurityService/Oidc/OidcCommands.cs rename to SecurityService.BusinessLogic/Oidc/OidcCommands.cs index 3327b526..76c5a9b0 100644 --- a/SecurityService/Oidc/OidcCommands.cs +++ b/SecurityService.BusinessLogic/Oidc/OidcCommands.cs @@ -1,12 +1,13 @@ using MediatR; using Microsoft.AspNetCore.Http; +using SimpleResults; -namespace SecurityService.Oidc; +namespace SecurityService.BusinessLogic.Oidc; public static class OidcCommands { - public sealed record AuthorizeCommand(HttpContext HttpContext) : IRequest; - public sealed record TokenCommand(HttpContext HttpContext) : IRequest; - public sealed record LogoutCommand(HttpContext HttpContext) : IRequest; - public sealed record UserInfoCommand(HttpContext HttpContext) : IRequest; + public sealed record AuthorizeCommand(HttpContext HttpContext) : IRequest>; + public sealed record TokenCommand(HttpContext HttpContext) : IRequest>; + public sealed record LogoutCommand(HttpContext HttpContext) : IRequest>; + public sealed record UserInfoCommand(HttpContext HttpContext) : IRequest>; } diff --git a/SecurityService/Oidc/OidcHelpers.cs b/SecurityService.BusinessLogic/Oidc/OidcHelpers.cs similarity index 99% rename from SecurityService/Oidc/OidcHelpers.cs rename to SecurityService.BusinessLogic/Oidc/OidcHelpers.cs index eae62574..e90e08e7 100644 --- a/SecurityService/Oidc/OidcHelpers.cs +++ b/SecurityService.BusinessLogic/Oidc/OidcHelpers.cs @@ -9,7 +9,7 @@ using SecurityService.Database.Entities; using static OpenIddict.Abstractions.OpenIddictConstants; -namespace SecurityService.Oidc; +namespace SecurityService.BusinessLogic.Oidc; public static class OidcHelpers { diff --git a/SecurityService/Oidc/OidcRequestHandler.cs b/SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs similarity index 65% rename from SecurityService/Oidc/OidcRequestHandler.cs rename to SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs index c681ae6e..9b06b12b 100644 --- a/SecurityService/Oidc/OidcRequestHandler.cs +++ b/SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs @@ -11,15 +11,16 @@ using OpenIddict.Validation.AspNetCore; using SecurityService.Database; using SecurityService.Database.DbContexts; +using SimpleResults; using static OpenIddict.Abstractions.OpenIddictConstants; -namespace SecurityService.Oidc; +namespace SecurityService.BusinessLogic.Oidc; public sealed class OidcRequestHandler : - IRequestHandler, - IRequestHandler, - IRequestHandler, - IRequestHandler + IRequestHandler>, + IRequestHandler>, + IRequestHandler>, + IRequestHandler> { private readonly UserManager _userManager; private readonly SignInManager _signInManager; @@ -44,7 +45,7 @@ public OidcRequestHandler( this._dbContext = dbContext; } - public async Task Handle(OidcCommands.AuthorizeCommand command, CancellationToken cancellationToken) + public async Task> Handle(OidcCommands.AuthorizeCommand command, CancellationToken cancellationToken) { var context = command.HttpContext; var request = context.GetOpenIddictServerRequest() ?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); @@ -53,102 +54,106 @@ public async Task Handle(OidcCommands.AuthorizeCommand command, Cancell var authenticationResult = await context.AuthenticateAsync(IdentityConstants.ApplicationScheme); if (authenticationResult.Succeeded == false) { - return HandleAuthenticationFailed(request, currentRequestUrl); + return Result.Success(HandleAuthenticationFailed(request, currentRequestUrl)); } var user = await this._userManager.GetUserAsync(authenticationResult.Principal); if (user is null) { - return ForbidServer(Errors.LoginRequired, "The user account could not be resolved."); + return Result.Success(ForbidServer(Errors.LoginRequired, "The user account could not be resolved.")); } var application = await this._applicationManager.FindByClientIdAsync(request.ClientId!, cancellationToken); if (application is null) { - return Results.BadRequest(new { error = "invalid_client", error_description = "The client application could not be found." }); + return Result.Success(new OidcBadRequestResult(new { error = "invalid_client", error_description = "The client application could not be found." })); } if (context.Request.Query.TryGetValue("consent", out var consentDecision)) { - return await this.HandleConsentDecision(user, request, application, context, consentDecision!, cancellationToken); + return Result.Success(await this.HandleConsentDecision(user, request, application, context, consentDecision!, cancellationToken)); } - return await this.HandleConsentType(user, request, application, currentRequestUrl, cancellationToken); + return Result.Success(await this.HandleConsentType(user, request, application, currentRequestUrl, cancellationToken)); } - public async Task Handle(OidcCommands.TokenCommand command, CancellationToken cancellationToken) + public async Task> Handle(OidcCommands.TokenCommand command, CancellationToken cancellationToken) { var context = command.HttpContext; var request = context.GetOpenIddictServerRequest() ?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType() || request.IsDeviceCodeGrantType()) { - return await this.HandleCodeOrRefreshToken(context, cancellationToken); + return Result.Success(await this.HandleCodeOrRefreshToken(context, cancellationToken)); } if (request.IsClientCredentialsGrantType()) { - return await this.HandleClientCredentials(request, cancellationToken); + return Result.Success(await this.HandleClientCredentials(request, cancellationToken)); } if (request.IsPasswordGrantType()) { - return await this.HandlePasswordGrant(request, cancellationToken); + return Result.Success(await this.HandlePasswordGrant(request, cancellationToken)); } - return Results.BadRequest(new { error = "unsupported_grant_type", error_description = "The specified grant type is not supported by this service." }); + return Result.Success(new OidcBadRequestResult(new { error = "unsupported_grant_type", error_description = "The specified grant type is not supported by this service." })); } - public Task Handle(OidcCommands.LogoutCommand command, CancellationToken cancellationToken) + public Task> Handle(OidcCommands.LogoutCommand command, CancellationToken cancellationToken) { var context = command.HttpContext; var currentRequestUrl = OidcHelpers.BuildCurrentRequestUrl(context.Request); var confirmed = string.Equals(context.Request.Query["logout"], "confirmed", StringComparison.OrdinalIgnoreCase); if (confirmed) { - return Task.FromResult(Results.SignOut(new AuthenticationProperties { RedirectUri = "/Account/Logout/LoggedOut" }, [IdentityConstants.ApplicationScheme, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme])); + OidcActionResult signOut = new OidcSignOutResult( + new AuthenticationProperties { RedirectUri = "/Account/Logout/LoggedOut" }, + [IdentityConstants.ApplicationScheme, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); + return Task.FromResult(Result.Success(signOut)); } - return Task.FromResult(Results.Redirect($"/Account/Logout?returnUrl={Uri.EscapeDataString(currentRequestUrl)}")); + OidcActionResult redirect = new OidcRedirectResult($"/Account/Logout?returnUrl={Uri.EscapeDataString(currentRequestUrl)}"); + return Task.FromResult(Result.Success(redirect)); } - public async Task Handle(OidcCommands.UserInfoCommand command, CancellationToken cancellationToken) + public async Task> Handle(OidcCommands.UserInfoCommand command, CancellationToken cancellationToken) { var context = command.HttpContext; var authenticationResult = await context.AuthenticateAsync(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme); if (authenticationResult.Succeeded == false || authenticationResult.Principal is null) { - return InvalidToken("The access token is missing or invalid."); + return Result.Success(InvalidToken("The access token is missing or invalid.")); } var subject = authenticationResult.Principal.GetClaim(Claims.Subject); if (string.IsNullOrWhiteSpace(subject)) { - return InvalidToken("The access token does not contain a subject identifier."); + return Result.Success(InvalidToken("The access token does not contain a subject identifier.")); } var user = await this._userManager.FindByIdAsync(subject); if (user is null) { - return InvalidToken("The user associated with the token could not be found."); + return Result.Success(InvalidToken("The user associated with the token could not be found.")); } var scopes = authenticationResult.Principal.GetScopes().ToHashSet(StringComparer.OrdinalIgnoreCase); var response = await this.BuildUserInfoResponse(user, scopes); - return Results.Json(response.Where(pair => pair.Value is not null).ToDictionary(pair => pair.Key, pair => pair.Value)); + return Result.Success(new OidcJsonResult(response.Where(pair => pair.Value is not null).ToDictionary(pair => pair.Key, pair => pair.Value))); } - private static IResult HandleAuthenticationFailed(OpenIddictRequest request, string currentRequestUrl) + private static OidcActionResult HandleAuthenticationFailed(OpenIddictRequest request, string currentRequestUrl) { if (request.HasPromptValue(PromptValues.None)) { return ForbidServer(Errors.LoginRequired, "The user is not logged in."); } - return Results.Challenge(new AuthenticationProperties { RedirectUri = currentRequestUrl }, [IdentityConstants.ApplicationScheme]); + return new OidcChallengeResult(new AuthenticationProperties { RedirectUri = currentRequestUrl }, [IdentityConstants.ApplicationScheme]); } - private async Task HandleConsentDecision( + private async Task HandleConsentDecision( ApplicationUser user, OpenIddictRequest request, object application, @@ -169,7 +174,7 @@ private async Task HandleConsentDecision( return await this.HandleConsentType(user, request, application, OidcHelpers.BuildCurrentRequestUrl(context.Request), cancellationToken); } - private async Task HandleConsentType( + private async Task HandleConsentType( ApplicationUser user, OpenIddictRequest request, object application, @@ -192,13 +197,13 @@ private async Task HandleConsentType( return ForbidServer(Errors.ConsentRequired, "Interactive user consent is required."); } - return Results.Redirect($"/Consent?returnUrl={Uri.EscapeDataString(currentRequestUrl)}"); + return new OidcRedirectResult($"/Consent?returnUrl={Uri.EscapeDataString(currentRequestUrl)}"); } return await this.CompleteAuthorization(user, request, application, cancellationToken, request.GetScopes()); } - private async Task HandleCodeOrRefreshToken(HttpContext context, CancellationToken cancellationToken) + private async Task HandleCodeOrRefreshToken(HttpContext context, CancellationToken cancellationToken) { var authenticationResult = await context.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); if (authenticationResult.Succeeded == false || authenticationResult.Principal is null) @@ -209,7 +214,7 @@ private async Task HandleCodeOrRefreshToken(HttpContext context, Cancel var subject = authenticationResult.Principal.GetClaim(Claims.Subject); if (string.IsNullOrWhiteSpace(subject)) { - return Results.SignIn(authenticationResult.Principal, authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return new OidcSignInResult(authenticationResult.Principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } var user = await this._userManager.FindByIdAsync(subject); @@ -221,10 +226,10 @@ private async Task HandleCodeOrRefreshToken(HttpContext context, Cancel var scopes = authenticationResult.Principal.GetScopes(); var resources = await this._scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(scopes), cancellationToken).ToListAsync(cancellationToken); var principal = await OidcHelpers.CreatePrincipal(user, this._userManager, scopes, resources, authenticationResult.Principal.GetAuthorizationId()); - return Results.SignIn(principal, authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return new OidcSignInResult(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } - private async Task HandleClientCredentials(OpenIddictRequest request, CancellationToken cancellationToken) + private async Task HandleClientCredentials(OpenIddictRequest request, CancellationToken cancellationToken) { var grantedScopes = await OidcHelpers.ResolveClientCredentialsScopes(request, this._dbContext, cancellationToken); var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role); @@ -233,10 +238,10 @@ private async Task HandleClientCredentials(OpenIddictRequest request, C identity.SetScopes(grantedScopes); identity.SetResources(await this._scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(grantedScopes), cancellationToken).ToListAsync(cancellationToken)); identity.SetDestinations(OidcHelpers.GetDestinations); - return Results.SignIn(new ClaimsPrincipal(identity), authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return new OidcSignInResult(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } - private async Task HandlePasswordGrant(OpenIddictRequest request, CancellationToken cancellationToken) + private async Task HandlePasswordGrant(OpenIddictRequest request, CancellationToken cancellationToken) { var user = await this._userManager.FindByNameAsync(request.Username!); if (user is null) @@ -253,7 +258,7 @@ private async Task HandlePasswordGrant(OpenIddictRequest request, Cance var grantedScopes = await OidcHelpers.ResolveClientCredentialsScopes(request, this._dbContext, cancellationToken); var resources = await this._scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(grantedScopes), cancellationToken).ToListAsync(cancellationToken); var principal = await OidcHelpers.CreatePrincipal(user, this._userManager, grantedScopes, resources, authorizationId: null); - return Results.SignIn(principal, authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return new OidcSignInResult(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } private async Task> BuildUserInfoResponse(ApplicationUser user, HashSet scopes) @@ -291,14 +296,16 @@ private async Task HandlePasswordGrant(OpenIddictRequest request, Cance return response; } - private static IResult ForbidServer(string error, string description) => - Results.Forbid(new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = error, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description - }), [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); + private static OidcForbidResult ForbidServer(string error, string description) => + new OidcForbidResult( + new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = error, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description + }), + [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); - private async Task CompleteAuthorization( + private async Task CompleteAuthorization( ApplicationUser user, OpenIddictRequest request, object application, @@ -326,20 +333,24 @@ private async Task CompleteAuthorization( cancellationToken: cancellationToken); principal.SetAuthorizationId(await this._authorizationManager.GetIdAsync(authorization, cancellationToken)); - return Results.SignIn(principal, authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return new OidcSignInResult(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } - private static IResult InvalidGrant(string description = "The username/password couple is invalid.") => - Results.Forbid(new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description - }), [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); - - private static IResult InvalidToken(string description) => - Results.Challenge(new AuthenticationProperties(new Dictionary - { - [OpenIddictValidationAspNetCoreConstants.Properties.Error] = Errors.InvalidToken, - [OpenIddictValidationAspNetCoreConstants.Properties.ErrorDescription] = description - }), [OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme]); + private static OidcForbidResult InvalidGrant(string description = "The username/password couple is invalid.") => + new OidcForbidResult( + new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description + }), + [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); + + private static OidcChallengeResult InvalidToken(string description) => + new OidcChallengeResult( + new AuthenticationProperties(new Dictionary + { + [OpenIddictValidationAspNetCoreConstants.Properties.Error] = Errors.InvalidToken, + [OpenIddictValidationAspNetCoreConstants.Properties.ErrorDescription] = description + }), + [OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme]); } diff --git a/SecurityService.BusinessLogic/SecurityService.BusinessLogic.csproj b/SecurityService.BusinessLogic/SecurityService.BusinessLogic.csproj index cb1e0812..eb781937 100644 --- a/SecurityService.BusinessLogic/SecurityService.BusinessLogic.csproj +++ b/SecurityService.BusinessLogic/SecurityService.BusinessLogic.csproj @@ -11,6 +11,8 @@ + + diff --git a/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs b/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs index 93a7d1ac..4c15d976 100644 --- a/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs +++ b/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Moq; using OpenIddict.Validation.AspNetCore; +using SecurityService.BusinessLogic.Oidc; using SecurityService.Database; using SecurityService.Database.DbContexts; using SecurityService.Database.Entities; @@ -69,8 +70,16 @@ public async Task UserInfoAsync_WithValidPrincipal_ReturnsJsonPayload() dbContext); var result = await handler.Handle(new OidcCommands.UserInfoCommand(context), CancellationToken.None); - await result.ExecuteAsync(context); + result.IsSuccess.ShouldBeTrue(); + var jsonResult = result.Value.ShouldBeOfType(); + jsonResult.Data.ShouldContainKey("sub"); + jsonResult.Data["sub"].ShouldBe("user-1"); + jsonResult.Data.ShouldContainKey("email"); + jsonResult.Data["email"].ShouldBe("alice@example.com"); + + var iResult = OidcEndpoints.ToIResult(result.Value!); + await iResult.ExecuteAsync(context); context.Response.StatusCode.ShouldBe(StatusCodes.Status200OK); context.Response.Body.Position = 0; var payload = await new StreamReader(context.Response.Body, Encoding.UTF8).ReadToEndAsync(); @@ -168,4 +177,3 @@ public FakeAuthenticationService(AuthenticateResult authenticateResult) public Task SignOutAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) => Task.CompletedTask; } } - diff --git a/SecurityService/Oidc/OidcEndpoints.cs b/SecurityService/Oidc/OidcEndpoints.cs index 6738d370..3879bf1e 100644 --- a/SecurityService/Oidc/OidcEndpoints.cs +++ b/SecurityService/Oidc/OidcEndpoints.cs @@ -1,4 +1,7 @@ using MediatR; +using Microsoft.AspNetCore.Authentication; +using SecurityService.BusinessLogic.Oidc; +using SimpleResults; namespace SecurityService.Oidc; @@ -12,16 +15,49 @@ public static void MapOidcEndpoints(this IEndpointRouteBuilder endpoints) endpoints.MapMethods("/connect/userinfo", [HttpMethods.Get, HttpMethods.Post], (Delegate)UserInfoAsync); } - public static Task AuthorizeAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) - => mediator.Send(new OidcCommands.AuthorizeCommand(context), cancellationToken); + public static async Task AuthorizeAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) + { + var result = await mediator.Send(new OidcCommands.AuthorizeCommand(context), cancellationToken); + return ToIResult(result); + } - public static Task TokenAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) - => mediator.Send(new OidcCommands.TokenCommand(context), cancellationToken); + public static async Task TokenAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) + { + var result = await mediator.Send(new OidcCommands.TokenCommand(context), cancellationToken); + return ToIResult(result); + } - public static Task LogoutAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) - => mediator.Send(new OidcCommands.LogoutCommand(context), cancellationToken); + public static async Task LogoutAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) + { + var result = await mediator.Send(new OidcCommands.LogoutCommand(context), cancellationToken); + return ToIResult(result); + } - public static Task UserInfoAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) - => mediator.Send(new OidcCommands.UserInfoCommand(context), cancellationToken); -} + public static async Task UserInfoAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) + { + var result = await mediator.Send(new OidcCommands.UserInfoCommand(context), cancellationToken); + return ToIResult(result); + } + + public static IResult ToIResult(Result result) + { + if (result.IsSuccess == false) + { + return Results.Problem(result.Message); + } + return ToIResult(result.Value!); + } + + public static IResult ToIResult(OidcActionResult action) => action switch + { + OidcSignInResult r => Results.SignIn(r.Principal, properties: null, authenticationScheme: r.AuthenticationScheme), + OidcSignOutResult r => Results.SignOut(r.Properties, r.AuthenticationSchemes), + OidcRedirectResult r => Results.Redirect(r.Url), + OidcForbidResult r => Results.Forbid(r.Properties, r.AuthenticationSchemes), + OidcChallengeResult r => Results.Challenge(r.Properties, r.AuthenticationSchemes), + OidcJsonResult r => Results.Json(r.Data), + OidcBadRequestResult r => Results.BadRequest(r.Error), + _ => Results.StatusCode(500) + }; +} diff --git a/SecurityService/Pages/Account/Grants/Index.cshtml.cs b/SecurityService/Pages/Account/Grants/Index.cshtml.cs index 2c3619fd..132842b5 100644 --- a/SecurityService/Pages/Account/Grants/Index.cshtml.cs +++ b/SecurityService/Pages/Account/Grants/Index.cshtml.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc.RazorPages; using SecurityService.Database; using SecurityService.Models; -using SecurityService.Oidc; +using SecurityService.BusinessLogic.Oidc; using SecurityService.Services; namespace SecurityService.Pages.Account.Grants; diff --git a/SecurityService/Pages/Account/Logout/Index.cshtml.cs b/SecurityService/Pages/Account/Logout/Index.cshtml.cs index 5de8cc3a..ab09ba9e 100644 --- a/SecurityService/Pages/Account/Logout/Index.cshtml.cs +++ b/SecurityService/Pages/Account/Logout/Index.cshtml.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -using SecurityService.Oidc; +using SecurityService.BusinessLogic.Oidc; namespace SecurityService.Pages.Account.Logout; diff --git a/SecurityService/Pages/Connect/Verify.cshtml.cs b/SecurityService/Pages/Connect/Verify.cshtml.cs index 1f75be5e..0b4ccaa0 100644 --- a/SecurityService/Pages/Connect/Verify.cshtml.cs +++ b/SecurityService/Pages/Connect/Verify.cshtml.cs @@ -7,7 +7,7 @@ using OpenIddict.Server.AspNetCore; using SecurityService.Database; using SecurityService.Database.DbContexts; -using SecurityService.Oidc; +using SecurityService.BusinessLogic.Oidc; using static OpenIddict.Abstractions.OpenIddictConstants; namespace SecurityService.Pages.Connect; diff --git a/SecurityService/Pages/Consent/Index.cshtml.cs b/SecurityService/Pages/Consent/Index.cshtml.cs index 9735c681..48bee498 100644 --- a/SecurityService/Pages/Consent/Index.cshtml.cs +++ b/SecurityService/Pages/Consent/Index.cshtml.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc.RazorPages; using OpenIddict.Abstractions; using SecurityService.Database.DbContexts; -using SecurityService.Oidc; +using SecurityService.BusinessLogic.Oidc; namespace SecurityService.Pages.Consent; diff --git a/SecurityService/Program.cs b/SecurityService/Program.cs index 32be9285..dde2e84f 100644 --- a/SecurityService/Program.cs +++ b/SecurityService/Program.cs @@ -210,7 +210,6 @@ builder.Services.AddMediatR(configuration => { configuration.RegisterServicesFromAssembly(typeof(SecurityServiceCommands).Assembly); - configuration.RegisterServicesFromAssembly(typeof(OidcRequestHandler).Assembly); }); builder.Services.ConfigureHttpJsonOptions(jsonOptions => JsonSerializerConfiguration.ConfigureMinimalApi(jsonOptions.SerializerOptions)); builder.Services.AddRazorPages(); From 706f288b8fb9b4b4d5ad339249dfc6e2fd7ea8d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:44:47 +0000 Subject: [PATCH 08/11] Replace OidcActionResult with per-endpoint specific result types Agent-Logs-Url: https://github.com/TransactionProcessing/SecurityService/sessions/466bce18-813c-430d-8157-8c1fc9758113 Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../Oidc/OidcActionResult.cs | 20 ---- .../Oidc/OidcCommands.cs | 8 +- .../Oidc/OidcRequestHandler.cs | 92 +++++++++---------- .../Oidc/OidcResults.cs | 44 +++++++++ .../Oidc/OidcEndpointTests.cs | 4 +- SecurityService/Oidc/OidcEndpoints.cs | 67 +++++++++++--- 6 files changed, 150 insertions(+), 85 deletions(-) delete mode 100644 SecurityService.BusinessLogic/Oidc/OidcActionResult.cs create mode 100644 SecurityService.BusinessLogic/Oidc/OidcResults.cs diff --git a/SecurityService.BusinessLogic/Oidc/OidcActionResult.cs b/SecurityService.BusinessLogic/Oidc/OidcActionResult.cs deleted file mode 100644 index d7c05cac..00000000 --- a/SecurityService.BusinessLogic/Oidc/OidcActionResult.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Authentication; - -namespace SecurityService.BusinessLogic.Oidc; - -public abstract record OidcActionResult; - -public sealed record OidcSignInResult(ClaimsPrincipal Principal, string AuthenticationScheme) : OidcActionResult; - -public sealed record OidcSignOutResult(AuthenticationProperties Properties, IList AuthenticationSchemes) : OidcActionResult; - -public sealed record OidcRedirectResult(string Url) : OidcActionResult; - -public sealed record OidcForbidResult(AuthenticationProperties Properties, IList AuthenticationSchemes) : OidcActionResult; - -public sealed record OidcChallengeResult(AuthenticationProperties Properties, IList AuthenticationSchemes) : OidcActionResult; - -public sealed record OidcJsonResult(Dictionary Data) : OidcActionResult; - -public sealed record OidcBadRequestResult(object Error) : OidcActionResult; diff --git a/SecurityService.BusinessLogic/Oidc/OidcCommands.cs b/SecurityService.BusinessLogic/Oidc/OidcCommands.cs index 76c5a9b0..d139f7d0 100644 --- a/SecurityService.BusinessLogic/Oidc/OidcCommands.cs +++ b/SecurityService.BusinessLogic/Oidc/OidcCommands.cs @@ -6,8 +6,8 @@ namespace SecurityService.BusinessLogic.Oidc; public static class OidcCommands { - public sealed record AuthorizeCommand(HttpContext HttpContext) : IRequest>; - public sealed record TokenCommand(HttpContext HttpContext) : IRequest>; - public sealed record LogoutCommand(HttpContext HttpContext) : IRequest>; - public sealed record UserInfoCommand(HttpContext HttpContext) : IRequest>; + public sealed record AuthorizeCommand(HttpContext HttpContext) : IRequest>; + public sealed record TokenCommand(HttpContext HttpContext) : IRequest>; + public sealed record LogoutCommand(HttpContext HttpContext) : IRequest>; + public sealed record UserInfoCommand(HttpContext HttpContext) : IRequest>; } diff --git a/SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs b/SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs index 9b06b12b..5242b095 100644 --- a/SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs +++ b/SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs @@ -17,10 +17,10 @@ namespace SecurityService.BusinessLogic.Oidc; public sealed class OidcRequestHandler : - IRequestHandler>, - IRequestHandler>, - IRequestHandler>, - IRequestHandler> + IRequestHandler>, + IRequestHandler>, + IRequestHandler>, + IRequestHandler> { private readonly UserManager _userManager; private readonly SignInManager _signInManager; @@ -45,7 +45,7 @@ public OidcRequestHandler( this._dbContext = dbContext; } - public async Task> Handle(OidcCommands.AuthorizeCommand command, CancellationToken cancellationToken) + public async Task> Handle(OidcCommands.AuthorizeCommand command, CancellationToken cancellationToken) { var context = command.HttpContext; var request = context.GetOpenIddictServerRequest() ?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); @@ -54,106 +54,106 @@ public async Task> Handle(OidcCommands.AuthorizeCommand var authenticationResult = await context.AuthenticateAsync(IdentityConstants.ApplicationScheme); if (authenticationResult.Succeeded == false) { - return Result.Success(HandleAuthenticationFailed(request, currentRequestUrl)); + return Result.Success(HandleAuthenticationFailed(request, currentRequestUrl)); } var user = await this._userManager.GetUserAsync(authenticationResult.Principal); if (user is null) { - return Result.Success(ForbidServer(Errors.LoginRequired, "The user account could not be resolved.")); + return Result.Success(AuthorizeForbidServer(Errors.LoginRequired, "The user account could not be resolved.")); } var application = await this._applicationManager.FindByClientIdAsync(request.ClientId!, cancellationToken); if (application is null) { - return Result.Success(new OidcBadRequestResult(new { error = "invalid_client", error_description = "The client application could not be found." })); + return Result.Success(new AuthorizeBadRequestResult(new { error = "invalid_client", error_description = "The client application could not be found." })); } if (context.Request.Query.TryGetValue("consent", out var consentDecision)) { - return Result.Success(await this.HandleConsentDecision(user, request, application, context, consentDecision!, cancellationToken)); + return Result.Success(await this.HandleConsentDecision(user, request, application, context, consentDecision!, cancellationToken)); } - return Result.Success(await this.HandleConsentType(user, request, application, currentRequestUrl, cancellationToken)); + return Result.Success(await this.HandleConsentType(user, request, application, currentRequestUrl, cancellationToken)); } - public async Task> Handle(OidcCommands.TokenCommand command, CancellationToken cancellationToken) + public async Task> Handle(OidcCommands.TokenCommand command, CancellationToken cancellationToken) { var context = command.HttpContext; var request = context.GetOpenIddictServerRequest() ?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType() || request.IsDeviceCodeGrantType()) { - return Result.Success(await this.HandleCodeOrRefreshToken(context, cancellationToken)); + return Result.Success(await this.HandleCodeOrRefreshToken(context, cancellationToken)); } if (request.IsClientCredentialsGrantType()) { - return Result.Success(await this.HandleClientCredentials(request, cancellationToken)); + return Result.Success(await this.HandleClientCredentials(request, cancellationToken)); } if (request.IsPasswordGrantType()) { - return Result.Success(await this.HandlePasswordGrant(request, cancellationToken)); + return Result.Success(await this.HandlePasswordGrant(request, cancellationToken)); } - return Result.Success(new OidcBadRequestResult(new { error = "unsupported_grant_type", error_description = "The specified grant type is not supported by this service." })); + return Result.Success(new TokenBadRequestResult(new { error = "unsupported_grant_type", error_description = "The specified grant type is not supported by this service." })); } - public Task> Handle(OidcCommands.LogoutCommand command, CancellationToken cancellationToken) + public Task> Handle(OidcCommands.LogoutCommand command, CancellationToken cancellationToken) { var context = command.HttpContext; var currentRequestUrl = OidcHelpers.BuildCurrentRequestUrl(context.Request); var confirmed = string.Equals(context.Request.Query["logout"], "confirmed", StringComparison.OrdinalIgnoreCase); if (confirmed) { - OidcActionResult signOut = new OidcSignOutResult( + LogoutCommandResult signOut = new LogoutSignOutResult( new AuthenticationProperties { RedirectUri = "/Account/Logout/LoggedOut" }, [IdentityConstants.ApplicationScheme, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); return Task.FromResult(Result.Success(signOut)); } - OidcActionResult redirect = new OidcRedirectResult($"/Account/Logout?returnUrl={Uri.EscapeDataString(currentRequestUrl)}"); + LogoutCommandResult redirect = new LogoutRedirectResult($"/Account/Logout?returnUrl={Uri.EscapeDataString(currentRequestUrl)}"); return Task.FromResult(Result.Success(redirect)); } - public async Task> Handle(OidcCommands.UserInfoCommand command, CancellationToken cancellationToken) + public async Task> Handle(OidcCommands.UserInfoCommand command, CancellationToken cancellationToken) { var context = command.HttpContext; var authenticationResult = await context.AuthenticateAsync(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme); if (authenticationResult.Succeeded == false || authenticationResult.Principal is null) { - return Result.Success(InvalidToken("The access token is missing or invalid.")); + return Result.Success(InvalidToken("The access token is missing or invalid.")); } var subject = authenticationResult.Principal.GetClaim(Claims.Subject); if (string.IsNullOrWhiteSpace(subject)) { - return Result.Success(InvalidToken("The access token does not contain a subject identifier.")); + return Result.Success(InvalidToken("The access token does not contain a subject identifier.")); } var user = await this._userManager.FindByIdAsync(subject); if (user is null) { - return Result.Success(InvalidToken("The user associated with the token could not be found.")); + return Result.Success(InvalidToken("The user associated with the token could not be found.")); } var scopes = authenticationResult.Principal.GetScopes().ToHashSet(StringComparer.OrdinalIgnoreCase); var response = await this.BuildUserInfoResponse(user, scopes); - return Result.Success(new OidcJsonResult(response.Where(pair => pair.Value is not null).ToDictionary(pair => pair.Key, pair => pair.Value))); + return Result.Success(new UserInfoJsonResult(response.Where(pair => pair.Value is not null).ToDictionary(pair => pair.Key, pair => pair.Value))); } - private static OidcActionResult HandleAuthenticationFailed(OpenIddictRequest request, string currentRequestUrl) + private static AuthorizeCommandResult HandleAuthenticationFailed(OpenIddictRequest request, string currentRequestUrl) { if (request.HasPromptValue(PromptValues.None)) { - return ForbidServer(Errors.LoginRequired, "The user is not logged in."); + return AuthorizeForbidServer(Errors.LoginRequired, "The user is not logged in."); } - return new OidcChallengeResult(new AuthenticationProperties { RedirectUri = currentRequestUrl }, [IdentityConstants.ApplicationScheme]); + return new AuthorizeChallengeResult(new AuthenticationProperties { RedirectUri = currentRequestUrl }, [IdentityConstants.ApplicationScheme]); } - private async Task HandleConsentDecision( + private async Task HandleConsentDecision( ApplicationUser user, OpenIddictRequest request, object application, @@ -163,7 +163,7 @@ private async Task HandleConsentDecision( { if (string.Equals(consentDecision, "denied", StringComparison.OrdinalIgnoreCase)) { - return ForbidServer(Errors.AccessDenied, "The authorization request was denied."); + return AuthorizeForbidServer(Errors.AccessDenied, "The authorization request was denied."); } if (string.Equals(consentDecision, "accepted", StringComparison.OrdinalIgnoreCase)) @@ -174,7 +174,7 @@ private async Task HandleConsentDecision( return await this.HandleConsentType(user, request, application, OidcHelpers.BuildCurrentRequestUrl(context.Request), cancellationToken); } - private async Task HandleConsentType( + private async Task HandleConsentType( ApplicationUser user, OpenIddictRequest request, object application, @@ -194,16 +194,16 @@ private async Task HandleConsentType( { if (request.HasPromptValue(PromptValues.None)) { - return ForbidServer(Errors.ConsentRequired, "Interactive user consent is required."); + return AuthorizeForbidServer(Errors.ConsentRequired, "Interactive user consent is required."); } - return new OidcRedirectResult($"/Consent?returnUrl={Uri.EscapeDataString(currentRequestUrl)}"); + return new AuthorizeRedirectResult($"/Consent?returnUrl={Uri.EscapeDataString(currentRequestUrl)}"); } return await this.CompleteAuthorization(user, request, application, cancellationToken, request.GetScopes()); } - private async Task HandleCodeOrRefreshToken(HttpContext context, CancellationToken cancellationToken) + private async Task HandleCodeOrRefreshToken(HttpContext context, CancellationToken cancellationToken) { var authenticationResult = await context.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); if (authenticationResult.Succeeded == false || authenticationResult.Principal is null) @@ -214,7 +214,7 @@ private async Task HandleCodeOrRefreshToken(HttpContext contex var subject = authenticationResult.Principal.GetClaim(Claims.Subject); if (string.IsNullOrWhiteSpace(subject)) { - return new OidcSignInResult(authenticationResult.Principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return new TokenSignInResult(authenticationResult.Principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } var user = await this._userManager.FindByIdAsync(subject); @@ -226,10 +226,10 @@ private async Task HandleCodeOrRefreshToken(HttpContext contex var scopes = authenticationResult.Principal.GetScopes(); var resources = await this._scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(scopes), cancellationToken).ToListAsync(cancellationToken); var principal = await OidcHelpers.CreatePrincipal(user, this._userManager, scopes, resources, authenticationResult.Principal.GetAuthorizationId()); - return new OidcSignInResult(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return new TokenSignInResult(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } - private async Task HandleClientCredentials(OpenIddictRequest request, CancellationToken cancellationToken) + private async Task HandleClientCredentials(OpenIddictRequest request, CancellationToken cancellationToken) { var grantedScopes = await OidcHelpers.ResolveClientCredentialsScopes(request, this._dbContext, cancellationToken); var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role); @@ -238,10 +238,10 @@ private async Task HandleClientCredentials(OpenIddictRequest r identity.SetScopes(grantedScopes); identity.SetResources(await this._scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(grantedScopes), cancellationToken).ToListAsync(cancellationToken)); identity.SetDestinations(OidcHelpers.GetDestinations); - return new OidcSignInResult(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return new TokenSignInResult(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } - private async Task HandlePasswordGrant(OpenIddictRequest request, CancellationToken cancellationToken) + private async Task HandlePasswordGrant(OpenIddictRequest request, CancellationToken cancellationToken) { var user = await this._userManager.FindByNameAsync(request.Username!); if (user is null) @@ -258,7 +258,7 @@ private async Task HandlePasswordGrant(OpenIddictRequest reque var grantedScopes = await OidcHelpers.ResolveClientCredentialsScopes(request, this._dbContext, cancellationToken); var resources = await this._scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(grantedScopes), cancellationToken).ToListAsync(cancellationToken); var principal = await OidcHelpers.CreatePrincipal(user, this._userManager, grantedScopes, resources, authorizationId: null); - return new OidcSignInResult(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return new TokenSignInResult(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } private async Task> BuildUserInfoResponse(ApplicationUser user, HashSet scopes) @@ -296,8 +296,8 @@ private async Task HandlePasswordGrant(OpenIddictRequest reque return response; } - private static OidcForbidResult ForbidServer(string error, string description) => - new OidcForbidResult( + private static AuthorizeForbidResult AuthorizeForbidServer(string error, string description) => + new AuthorizeForbidResult( new AuthenticationProperties(new Dictionary { [OpenIddictServerAspNetCoreConstants.Properties.Error] = error, @@ -305,7 +305,7 @@ private static OidcForbidResult ForbidServer(string error, string description) = }), [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); - private async Task CompleteAuthorization( + private async Task CompleteAuthorization( ApplicationUser user, OpenIddictRequest request, object application, @@ -333,11 +333,11 @@ private async Task CompleteAuthorization( cancellationToken: cancellationToken); principal.SetAuthorizationId(await this._authorizationManager.GetIdAsync(authorization, cancellationToken)); - return new OidcSignInResult(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return new AuthorizeSignInResult(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } - private static OidcForbidResult InvalidGrant(string description = "The username/password couple is invalid.") => - new OidcForbidResult( + private static TokenForbidResult InvalidGrant(string description = "The username/password couple is invalid.") => + new TokenForbidResult( new AuthenticationProperties(new Dictionary { [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, @@ -345,8 +345,8 @@ private static OidcForbidResult InvalidGrant(string description = "The username/ }), [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); - private static OidcChallengeResult InvalidToken(string description) => - new OidcChallengeResult( + private static UserInfoChallengeResult InvalidToken(string description) => + new UserInfoChallengeResult( new AuthenticationProperties(new Dictionary { [OpenIddictValidationAspNetCoreConstants.Properties.Error] = Errors.InvalidToken, diff --git a/SecurityService.BusinessLogic/Oidc/OidcResults.cs b/SecurityService.BusinessLogic/Oidc/OidcResults.cs new file mode 100644 index 00000000..722bbdee --- /dev/null +++ b/SecurityService.BusinessLogic/Oidc/OidcResults.cs @@ -0,0 +1,44 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; + +namespace SecurityService.BusinessLogic.Oidc; + +// ---- Authorize endpoint ---- + +public abstract record AuthorizeCommandResult; + +public sealed record AuthorizeSignInResult(ClaimsPrincipal Principal, string AuthenticationScheme) : AuthorizeCommandResult; + +public sealed record AuthorizeRedirectResult(string Url) : AuthorizeCommandResult; + +public sealed record AuthorizeForbidResult(AuthenticationProperties Properties, IList AuthenticationSchemes) : AuthorizeCommandResult; + +public sealed record AuthorizeChallengeResult(AuthenticationProperties Properties, IList AuthenticationSchemes) : AuthorizeCommandResult; + +public sealed record AuthorizeBadRequestResult(object Error) : AuthorizeCommandResult; + +// ---- Token endpoint ---- + +public abstract record TokenCommandResult; + +public sealed record TokenSignInResult(ClaimsPrincipal Principal, string AuthenticationScheme) : TokenCommandResult; + +public sealed record TokenForbidResult(AuthenticationProperties Properties, IList AuthenticationSchemes) : TokenCommandResult; + +public sealed record TokenBadRequestResult(object Error) : TokenCommandResult; + +// ---- Logout endpoint ---- + +public abstract record LogoutCommandResult; + +public sealed record LogoutSignOutResult(AuthenticationProperties Properties, IList AuthenticationSchemes) : LogoutCommandResult; + +public sealed record LogoutRedirectResult(string Url) : LogoutCommandResult; + +// ---- UserInfo endpoint ---- + +public abstract record UserInfoCommandResult; + +public sealed record UserInfoJsonResult(Dictionary Data) : UserInfoCommandResult; + +public sealed record UserInfoChallengeResult(AuthenticationProperties Properties, IList AuthenticationSchemes) : UserInfoCommandResult; diff --git a/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs b/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs index 4c15d976..60102b39 100644 --- a/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs +++ b/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs @@ -72,13 +72,13 @@ public async Task UserInfoAsync_WithValidPrincipal_ReturnsJsonPayload() var result = await handler.Handle(new OidcCommands.UserInfoCommand(context), CancellationToken.None); result.IsSuccess.ShouldBeTrue(); - var jsonResult = result.Value.ShouldBeOfType(); + var jsonResult = result.Value.ShouldBeOfType(); jsonResult.Data.ShouldContainKey("sub"); jsonResult.Data["sub"].ShouldBe("user-1"); jsonResult.Data.ShouldContainKey("email"); jsonResult.Data["email"].ShouldBe("alice@example.com"); - var iResult = OidcEndpoints.ToIResult(result.Value!); + var iResult = OidcEndpoints.ToIResult(result); await iResult.ExecuteAsync(context); context.Response.StatusCode.ShouldBe(StatusCodes.Status200OK); context.Response.Body.Position = 0; diff --git a/SecurityService/Oidc/OidcEndpoints.cs b/SecurityService/Oidc/OidcEndpoints.cs index 3879bf1e..2a7f32d9 100644 --- a/SecurityService/Oidc/OidcEndpoints.cs +++ b/SecurityService/Oidc/OidcEndpoints.cs @@ -1,5 +1,4 @@ using MediatR; -using Microsoft.AspNetCore.Authentication; using SecurityService.BusinessLogic.Oidc; using SimpleResults; @@ -39,25 +38,67 @@ public static async Task UserInfoAsync(HttpContext context, IMediator m return ToIResult(result); } - public static IResult ToIResult(Result result) + public static IResult ToIResult(Result result) { if (result.IsSuccess == false) { return Results.Problem(result.Message); } - return ToIResult(result.Value!); + return result.Value switch + { + AuthorizeSignInResult r => Results.SignIn(r.Principal, properties: null, authenticationScheme: r.AuthenticationScheme), + AuthorizeRedirectResult r => Results.Redirect(r.Url), + AuthorizeForbidResult r => Results.Forbid(r.Properties, r.AuthenticationSchemes), + AuthorizeChallengeResult r => Results.Challenge(r.Properties, r.AuthenticationSchemes), + AuthorizeBadRequestResult r => Results.BadRequest(r.Error), + _ => Results.StatusCode(500) + }; + } + + public static IResult ToIResult(Result result) + { + if (result.IsSuccess == false) + { + return Results.Problem(result.Message); + } + + return result.Value switch + { + TokenSignInResult r => Results.SignIn(r.Principal, properties: null, authenticationScheme: r.AuthenticationScheme), + TokenForbidResult r => Results.Forbid(r.Properties, r.AuthenticationSchemes), + TokenBadRequestResult r => Results.BadRequest(r.Error), + _ => Results.StatusCode(500) + }; + } + + public static IResult ToIResult(Result result) + { + if (result.IsSuccess == false) + { + return Results.Problem(result.Message); + } + + return result.Value switch + { + LogoutSignOutResult r => Results.SignOut(r.Properties, r.AuthenticationSchemes), + LogoutRedirectResult r => Results.Redirect(r.Url), + _ => Results.StatusCode(500) + }; } - public static IResult ToIResult(OidcActionResult action) => action switch + public static IResult ToIResult(Result result) { - OidcSignInResult r => Results.SignIn(r.Principal, properties: null, authenticationScheme: r.AuthenticationScheme), - OidcSignOutResult r => Results.SignOut(r.Properties, r.AuthenticationSchemes), - OidcRedirectResult r => Results.Redirect(r.Url), - OidcForbidResult r => Results.Forbid(r.Properties, r.AuthenticationSchemes), - OidcChallengeResult r => Results.Challenge(r.Properties, r.AuthenticationSchemes), - OidcJsonResult r => Results.Json(r.Data), - OidcBadRequestResult r => Results.BadRequest(r.Error), - _ => Results.StatusCode(500) - }; + if (result.IsSuccess == false) + { + return Results.Problem(result.Message); + } + + return result.Value switch + { + UserInfoJsonResult r => Results.Json(r.Data), + UserInfoChallengeResult r => Results.Challenge(r.Properties, r.AuthenticationSchemes), + _ => Results.StatusCode(500) + }; + } } From f94a349bb3c3bc4848b333ca3ddd4e8d3594951f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:04:07 +0000 Subject: [PATCH 09/11] Refactor OidcEndpoints to follow ManagementEndpoints handler-per-endpoint pattern Agent-Logs-Url: https://github.com/TransactionProcessing/SecurityService/sessions/b709f45d-14b2-4359-8f29-2040ee3bbc26 Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../Oidc/OidcEndpointTests.cs | 16 +-- .../Oidc/Handlers/AuthorizeHandler.cs | 32 ++++++ .../Oidc/Handlers/LogoutHandler.cs | 29 +++++ SecurityService/Oidc/Handlers/TokenHandler.cs | 30 ++++++ .../Oidc/Handlers/UserInfoHandler.cs | 29 +++++ SecurityService/Oidc/OidcEndpoints.cs | 100 +----------------- 6 files changed, 125 insertions(+), 111 deletions(-) create mode 100644 SecurityService/Oidc/Handlers/AuthorizeHandler.cs create mode 100644 SecurityService/Oidc/Handlers/LogoutHandler.cs create mode 100644 SecurityService/Oidc/Handlers/TokenHandler.cs create mode 100644 SecurityService/Oidc/Handlers/UserInfoHandler.cs diff --git a/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs b/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs index 60102b39..a7de37a7 100644 --- a/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs +++ b/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs @@ -1,5 +1,4 @@ using System.Security.Claims; -using System.Text; using MediatR; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; @@ -10,7 +9,6 @@ using SecurityService.Database; using SecurityService.Database.DbContexts; using SecurityService.Database.Entities; -using SecurityService.Oidc; using SecurityService.UnitTests.Infrastructure; using Shouldly; @@ -46,11 +44,7 @@ public async Task UserInfoAsync_WithValidPrincipal_ReturnsJsonPayload() var context = new DefaultHttpContext { - RequestServices = services.BuildServiceProvider(), - Response = - { - Body = new MemoryStream() - } + RequestServices = services.BuildServiceProvider() }; var signInManager = IdentityMocks.CreateSignInManager(userManager); @@ -77,14 +71,6 @@ public async Task UserInfoAsync_WithValidPrincipal_ReturnsJsonPayload() jsonResult.Data["sub"].ShouldBe("user-1"); jsonResult.Data.ShouldContainKey("email"); jsonResult.Data["email"].ShouldBe("alice@example.com"); - - var iResult = OidcEndpoints.ToIResult(result); - await iResult.ExecuteAsync(context); - context.Response.StatusCode.ShouldBe(StatusCodes.Status200OK); - context.Response.Body.Position = 0; - var payload = await new StreamReader(context.Response.Body, Encoding.UTF8).ReadToEndAsync(); - payload.ShouldContain("\"sub\":\"user-1\""); - payload.ShouldContain("\"email\":\"alice@example.com\""); } [Fact] diff --git a/SecurityService/Oidc/Handlers/AuthorizeHandler.cs b/SecurityService/Oidc/Handlers/AuthorizeHandler.cs new file mode 100644 index 00000000..e5f78d48 --- /dev/null +++ b/SecurityService/Oidc/Handlers/AuthorizeHandler.cs @@ -0,0 +1,32 @@ +using MediatR; +using SecurityService.BusinessLogic.Oidc; +using SimpleResults; + +namespace SecurityService.Oidc.Handlers; + +public static class AuthorizeHandler +{ + public static async Task AuthorizeAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) + { + var result = await mediator.Send(new OidcCommands.AuthorizeCommand(context), cancellationToken); + return ToIResult(result); + } + + private static IResult ToIResult(Result result) + { + if (result.IsSuccess == false) + { + return Results.Problem(result.Message); + } + + return result.Value switch + { + AuthorizeSignInResult r => Results.SignIn(r.Principal, properties: null, authenticationScheme: r.AuthenticationScheme), + AuthorizeRedirectResult r => Results.Redirect(r.Url), + AuthorizeForbidResult r => Results.Forbid(r.Properties, r.AuthenticationSchemes), + AuthorizeChallengeResult r => Results.Challenge(r.Properties, r.AuthenticationSchemes), + AuthorizeBadRequestResult r => Results.BadRequest(r.Error), + _ => Results.StatusCode(500) + }; + } +} diff --git a/SecurityService/Oidc/Handlers/LogoutHandler.cs b/SecurityService/Oidc/Handlers/LogoutHandler.cs new file mode 100644 index 00000000..c8a41ab7 --- /dev/null +++ b/SecurityService/Oidc/Handlers/LogoutHandler.cs @@ -0,0 +1,29 @@ +using MediatR; +using SecurityService.BusinessLogic.Oidc; +using SimpleResults; + +namespace SecurityService.Oidc.Handlers; + +public static class LogoutHandler +{ + public static async Task LogoutAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) + { + var result = await mediator.Send(new OidcCommands.LogoutCommand(context), cancellationToken); + return ToIResult(result); + } + + private static IResult ToIResult(Result result) + { + if (result.IsSuccess == false) + { + return Results.Problem(result.Message); + } + + return result.Value switch + { + LogoutSignOutResult r => Results.SignOut(r.Properties, r.AuthenticationSchemes), + LogoutRedirectResult r => Results.Redirect(r.Url), + _ => Results.StatusCode(500) + }; + } +} diff --git a/SecurityService/Oidc/Handlers/TokenHandler.cs b/SecurityService/Oidc/Handlers/TokenHandler.cs new file mode 100644 index 00000000..cd1f5f52 --- /dev/null +++ b/SecurityService/Oidc/Handlers/TokenHandler.cs @@ -0,0 +1,30 @@ +using MediatR; +using SecurityService.BusinessLogic.Oidc; +using SimpleResults; + +namespace SecurityService.Oidc.Handlers; + +public static class TokenHandler +{ + public static async Task TokenAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) + { + var result = await mediator.Send(new OidcCommands.TokenCommand(context), cancellationToken); + return ToIResult(result); + } + + private static IResult ToIResult(Result result) + { + if (result.IsSuccess == false) + { + return Results.Problem(result.Message); + } + + return result.Value switch + { + TokenSignInResult r => Results.SignIn(r.Principal, properties: null, authenticationScheme: r.AuthenticationScheme), + TokenForbidResult r => Results.Forbid(r.Properties, r.AuthenticationSchemes), + TokenBadRequestResult r => Results.BadRequest(r.Error), + _ => Results.StatusCode(500) + }; + } +} diff --git a/SecurityService/Oidc/Handlers/UserInfoHandler.cs b/SecurityService/Oidc/Handlers/UserInfoHandler.cs new file mode 100644 index 00000000..a46c2215 --- /dev/null +++ b/SecurityService/Oidc/Handlers/UserInfoHandler.cs @@ -0,0 +1,29 @@ +using MediatR; +using SecurityService.BusinessLogic.Oidc; +using SimpleResults; + +namespace SecurityService.Oidc.Handlers; + +public static class UserInfoHandler +{ + public static async Task UserInfoAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) + { + var result = await mediator.Send(new OidcCommands.UserInfoCommand(context), cancellationToken); + return ToIResult(result); + } + + private static IResult ToIResult(Result result) + { + if (result.IsSuccess == false) + { + return Results.Problem(result.Message); + } + + return result.Value switch + { + UserInfoJsonResult r => Results.Json(r.Data), + UserInfoChallengeResult r => Results.Challenge(r.Properties, r.AuthenticationSchemes), + _ => Results.StatusCode(500) + }; + } +} diff --git a/SecurityService/Oidc/OidcEndpoints.cs b/SecurityService/Oidc/OidcEndpoints.cs index 2a7f32d9..de3e83bb 100644 --- a/SecurityService/Oidc/OidcEndpoints.cs +++ b/SecurityService/Oidc/OidcEndpoints.cs @@ -1,104 +1,12 @@ -using MediatR; -using SecurityService.BusinessLogic.Oidc; -using SimpleResults; - namespace SecurityService.Oidc; public static class OidcEndpoints { public static void MapOidcEndpoints(this IEndpointRouteBuilder endpoints) { - endpoints.MapMethods("/connect/authorize", [HttpMethods.Get, HttpMethods.Post], (Delegate)AuthorizeAsync); - endpoints.MapPost("/connect/token", (Delegate)TokenAsync); - endpoints.MapMethods("/connect/logout", [HttpMethods.Get, HttpMethods.Post], (Delegate)LogoutAsync); - endpoints.MapMethods("/connect/userinfo", [HttpMethods.Get, HttpMethods.Post], (Delegate)UserInfoAsync); - } - - public static async Task AuthorizeAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new OidcCommands.AuthorizeCommand(context), cancellationToken); - return ToIResult(result); - } - - public static async Task TokenAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new OidcCommands.TokenCommand(context), cancellationToken); - return ToIResult(result); - } - - public static async Task LogoutAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new OidcCommands.LogoutCommand(context), cancellationToken); - return ToIResult(result); - } - - public static async Task UserInfoAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new OidcCommands.UserInfoCommand(context), cancellationToken); - return ToIResult(result); - } - - public static IResult ToIResult(Result result) - { - if (result.IsSuccess == false) - { - return Results.Problem(result.Message); - } - - return result.Value switch - { - AuthorizeSignInResult r => Results.SignIn(r.Principal, properties: null, authenticationScheme: r.AuthenticationScheme), - AuthorizeRedirectResult r => Results.Redirect(r.Url), - AuthorizeForbidResult r => Results.Forbid(r.Properties, r.AuthenticationSchemes), - AuthorizeChallengeResult r => Results.Challenge(r.Properties, r.AuthenticationSchemes), - AuthorizeBadRequestResult r => Results.BadRequest(r.Error), - _ => Results.StatusCode(500) - }; - } - - public static IResult ToIResult(Result result) - { - if (result.IsSuccess == false) - { - return Results.Problem(result.Message); - } - - return result.Value switch - { - TokenSignInResult r => Results.SignIn(r.Principal, properties: null, authenticationScheme: r.AuthenticationScheme), - TokenForbidResult r => Results.Forbid(r.Properties, r.AuthenticationSchemes), - TokenBadRequestResult r => Results.BadRequest(r.Error), - _ => Results.StatusCode(500) - }; - } - - public static IResult ToIResult(Result result) - { - if (result.IsSuccess == false) - { - return Results.Problem(result.Message); - } - - return result.Value switch - { - LogoutSignOutResult r => Results.SignOut(r.Properties, r.AuthenticationSchemes), - LogoutRedirectResult r => Results.Redirect(r.Url), - _ => Results.StatusCode(500) - }; - } - - public static IResult ToIResult(Result result) - { - if (result.IsSuccess == false) - { - return Results.Problem(result.Message); - } - - return result.Value switch - { - UserInfoJsonResult r => Results.Json(r.Data), - UserInfoChallengeResult r => Results.Challenge(r.Properties, r.AuthenticationSchemes), - _ => Results.StatusCode(500) - }; + endpoints.MapMethods("/connect/authorize", [HttpMethods.Get, HttpMethods.Post], (Delegate)Handlers.AuthorizeHandler.AuthorizeAsync); + endpoints.MapPost("/connect/token", (Delegate)Handlers.TokenHandler.TokenAsync); + endpoints.MapMethods("/connect/logout", [HttpMethods.Get, HttpMethods.Post], (Delegate)Handlers.LogoutHandler.LogoutAsync); + endpoints.MapMethods("/connect/userinfo", [HttpMethods.Get, HttpMethods.Post], (Delegate)Handlers.UserInfoHandler.UserInfoAsync); } } From 983daa3a6321009b13feba7991a2ac084cd4c4af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:08:11 +0000 Subject: [PATCH 10/11] Extract OIDC ToIResult switches into shared OidcResponseFactory.FromResult Agent-Logs-Url: https://github.com/TransactionProcessing/SecurityService/sessions/8c85d7d2-b64d-4ca5-a79d-a42fea092a5e Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../Factories/OidcResponseFactory.cs | 71 +++++++++++++++++++ .../Oidc/Handlers/AuthorizeHandler.cs | 25 +------ .../Oidc/Handlers/LogoutHandler.cs | 22 +----- SecurityService/Oidc/Handlers/TokenHandler.cs | 23 +----- .../Oidc/Handlers/UserInfoHandler.cs | 22 +----- 5 files changed, 79 insertions(+), 84 deletions(-) create mode 100644 SecurityService/Factories/OidcResponseFactory.cs diff --git a/SecurityService/Factories/OidcResponseFactory.cs b/SecurityService/Factories/OidcResponseFactory.cs new file mode 100644 index 00000000..1b1ba0ef --- /dev/null +++ b/SecurityService/Factories/OidcResponseFactory.cs @@ -0,0 +1,71 @@ +using SecurityService.BusinessLogic.Oidc; +using SimpleResults; + +namespace SecurityService.Factories; + +public static class OidcResponseFactory +{ + public static IResult FromResult(Result result) + { + if (result.IsSuccess == false) + { + return Results.Problem(result.Message); + } + + return result.Value switch + { + AuthorizeSignInResult r => Results.SignIn(r.Principal, properties: null, authenticationScheme: r.AuthenticationScheme), + AuthorizeRedirectResult r => Results.Redirect(r.Url), + AuthorizeForbidResult r => Results.Forbid(r.Properties, r.AuthenticationSchemes), + AuthorizeChallengeResult r => Results.Challenge(r.Properties, r.AuthenticationSchemes), + AuthorizeBadRequestResult r => Results.BadRequest(r.Error), + _ => Results.StatusCode(500) + }; + } + + public static IResult FromResult(Result result) + { + if (result.IsSuccess == false) + { + return Results.Problem(result.Message); + } + + return result.Value switch + { + TokenSignInResult r => Results.SignIn(r.Principal, properties: null, authenticationScheme: r.AuthenticationScheme), + TokenForbidResult r => Results.Forbid(r.Properties, r.AuthenticationSchemes), + TokenBadRequestResult r => Results.BadRequest(r.Error), + _ => Results.StatusCode(500) + }; + } + + public static IResult FromResult(Result result) + { + if (result.IsSuccess == false) + { + return Results.Problem(result.Message); + } + + return result.Value switch + { + LogoutSignOutResult r => Results.SignOut(r.Properties, r.AuthenticationSchemes), + LogoutRedirectResult r => Results.Redirect(r.Url), + _ => Results.StatusCode(500) + }; + } + + public static IResult FromResult(Result result) + { + if (result.IsSuccess == false) + { + return Results.Problem(result.Message); + } + + return result.Value switch + { + UserInfoJsonResult r => Results.Json(r.Data), + UserInfoChallengeResult r => Results.Challenge(r.Properties, r.AuthenticationSchemes), + _ => Results.StatusCode(500) + }; + } +} diff --git a/SecurityService/Oidc/Handlers/AuthorizeHandler.cs b/SecurityService/Oidc/Handlers/AuthorizeHandler.cs index e5f78d48..0334b608 100644 --- a/SecurityService/Oidc/Handlers/AuthorizeHandler.cs +++ b/SecurityService/Oidc/Handlers/AuthorizeHandler.cs @@ -1,32 +1,11 @@ using MediatR; using SecurityService.BusinessLogic.Oidc; -using SimpleResults; +using SecurityService.Factories; namespace SecurityService.Oidc.Handlers; public static class AuthorizeHandler { public static async Task AuthorizeAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new OidcCommands.AuthorizeCommand(context), cancellationToken); - return ToIResult(result); - } - - private static IResult ToIResult(Result result) - { - if (result.IsSuccess == false) - { - return Results.Problem(result.Message); - } - - return result.Value switch - { - AuthorizeSignInResult r => Results.SignIn(r.Principal, properties: null, authenticationScheme: r.AuthenticationScheme), - AuthorizeRedirectResult r => Results.Redirect(r.Url), - AuthorizeForbidResult r => Results.Forbid(r.Properties, r.AuthenticationSchemes), - AuthorizeChallengeResult r => Results.Challenge(r.Properties, r.AuthenticationSchemes), - AuthorizeBadRequestResult r => Results.BadRequest(r.Error), - _ => Results.StatusCode(500) - }; - } + => OidcResponseFactory.FromResult(await mediator.Send(new OidcCommands.AuthorizeCommand(context), cancellationToken)); } diff --git a/SecurityService/Oidc/Handlers/LogoutHandler.cs b/SecurityService/Oidc/Handlers/LogoutHandler.cs index c8a41ab7..efc34f87 100644 --- a/SecurityService/Oidc/Handlers/LogoutHandler.cs +++ b/SecurityService/Oidc/Handlers/LogoutHandler.cs @@ -1,29 +1,11 @@ using MediatR; using SecurityService.BusinessLogic.Oidc; -using SimpleResults; +using SecurityService.Factories; namespace SecurityService.Oidc.Handlers; public static class LogoutHandler { public static async Task LogoutAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new OidcCommands.LogoutCommand(context), cancellationToken); - return ToIResult(result); - } - - private static IResult ToIResult(Result result) - { - if (result.IsSuccess == false) - { - return Results.Problem(result.Message); - } - - return result.Value switch - { - LogoutSignOutResult r => Results.SignOut(r.Properties, r.AuthenticationSchemes), - LogoutRedirectResult r => Results.Redirect(r.Url), - _ => Results.StatusCode(500) - }; - } + => OidcResponseFactory.FromResult(await mediator.Send(new OidcCommands.LogoutCommand(context), cancellationToken)); } diff --git a/SecurityService/Oidc/Handlers/TokenHandler.cs b/SecurityService/Oidc/Handlers/TokenHandler.cs index cd1f5f52..bdba6eb6 100644 --- a/SecurityService/Oidc/Handlers/TokenHandler.cs +++ b/SecurityService/Oidc/Handlers/TokenHandler.cs @@ -1,30 +1,11 @@ using MediatR; using SecurityService.BusinessLogic.Oidc; -using SimpleResults; +using SecurityService.Factories; namespace SecurityService.Oidc.Handlers; public static class TokenHandler { public static async Task TokenAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new OidcCommands.TokenCommand(context), cancellationToken); - return ToIResult(result); - } - - private static IResult ToIResult(Result result) - { - if (result.IsSuccess == false) - { - return Results.Problem(result.Message); - } - - return result.Value switch - { - TokenSignInResult r => Results.SignIn(r.Principal, properties: null, authenticationScheme: r.AuthenticationScheme), - TokenForbidResult r => Results.Forbid(r.Properties, r.AuthenticationSchemes), - TokenBadRequestResult r => Results.BadRequest(r.Error), - _ => Results.StatusCode(500) - }; - } + => OidcResponseFactory.FromResult(await mediator.Send(new OidcCommands.TokenCommand(context), cancellationToken)); } diff --git a/SecurityService/Oidc/Handlers/UserInfoHandler.cs b/SecurityService/Oidc/Handlers/UserInfoHandler.cs index a46c2215..672742e4 100644 --- a/SecurityService/Oidc/Handlers/UserInfoHandler.cs +++ b/SecurityService/Oidc/Handlers/UserInfoHandler.cs @@ -1,29 +1,11 @@ using MediatR; using SecurityService.BusinessLogic.Oidc; -using SimpleResults; +using SecurityService.Factories; namespace SecurityService.Oidc.Handlers; public static class UserInfoHandler { public static async Task UserInfoAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new OidcCommands.UserInfoCommand(context), cancellationToken); - return ToIResult(result); - } - - private static IResult ToIResult(Result result) - { - if (result.IsSuccess == false) - { - return Results.Problem(result.Message); - } - - return result.Value switch - { - UserInfoJsonResult r => Results.Json(r.Data), - UserInfoChallengeResult r => Results.Challenge(r.Properties, r.AuthenticationSchemes), - _ => Results.StatusCode(500) - }; - } + => OidcResponseFactory.FromResult(await mediator.Send(new OidcCommands.UserInfoCommand(context), cancellationToken)); } From 8f746053af2a8e74d0e4398b88f2aa299708556b Mon Sep 17 00:00:00 2001 From: Stuart Ferguson Date: Sun, 12 Apr 2026 10:49:27 +0100 Subject: [PATCH 11/11] Refactor OIDC response factory to use generic Result Generalize OidcResponseFactory.FromResult to accept Result and handle all OIDC command results in a single method, reducing duplication. Introduce TranslateResultStatus for standardized error responses. Update tests and endpoint mappings to align with the new API. Add necessary using directives. --- .../Oidc/OidcHelpers.cs | 1 + .../Oidc/OidcRequestHandler.cs | 1 + .../Oidc/OidcEndpointTests.cs | 2 +- .../Factories/OidcResponseFactory.cs | 114 ++++++++++++------ SecurityService/Oidc/OidcEndpoints.cs | 8 +- 5 files changed, 81 insertions(+), 45 deletions(-) diff --git a/SecurityService.BusinessLogic/Oidc/OidcHelpers.cs b/SecurityService.BusinessLogic/Oidc/OidcHelpers.cs index e90e08e7..93f8a8df 100644 --- a/SecurityService.BusinessLogic/Oidc/OidcHelpers.cs +++ b/SecurityService.BusinessLogic/Oidc/OidcHelpers.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Primitives; diff --git a/SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs b/SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs index 5242b095..bb967483 100644 --- a/SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs +++ b/SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs @@ -3,6 +3,7 @@ using MediatR; using Microsoft.AspNetCore; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; diff --git a/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs b/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs index a7de37a7..aee7b456 100644 --- a/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs +++ b/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs @@ -66,7 +66,7 @@ public async Task UserInfoAsync_WithValidPrincipal_ReturnsJsonPayload() var result = await handler.Handle(new OidcCommands.UserInfoCommand(context), CancellationToken.None); result.IsSuccess.ShouldBeTrue(); - var jsonResult = result.Value.ShouldBeOfType(); + var jsonResult = result.Data.ShouldBeOfType(); jsonResult.Data.ShouldContainKey("sub"); jsonResult.Data["sub"].ShouldBe("user-1"); jsonResult.Data.ShouldContainKey("email"); diff --git a/SecurityService/Factories/OidcResponseFactory.cs b/SecurityService/Factories/OidcResponseFactory.cs index 1b1ba0ef..71db600d 100644 --- a/SecurityService/Factories/OidcResponseFactory.cs +++ b/SecurityService/Factories/OidcResponseFactory.cs @@ -1,71 +1,105 @@ +using Microsoft.AspNetCore.Authentication; using SecurityService.BusinessLogic.Oidc; using SimpleResults; +using System.Net; +using System.Security.Claims; +using Shared.Results.Web; namespace SecurityService.Factories; public static class OidcResponseFactory { - public static IResult FromResult(Result result) + public static IResult FromResult(Result result) { - if (result.IsSuccess == false) - { - return Results.Problem(result.Message); + if (result.IsSuccess == false) { + return TranslateResultStatus(result); } - return result.Value switch + return result.Data switch { AuthorizeSignInResult r => Results.SignIn(r.Principal, properties: null, authenticationScheme: r.AuthenticationScheme), AuthorizeRedirectResult r => Results.Redirect(r.Url), AuthorizeForbidResult r => Results.Forbid(r.Properties, r.AuthenticationSchemes), AuthorizeChallengeResult r => Results.Challenge(r.Properties, r.AuthenticationSchemes), AuthorizeBadRequestResult r => Results.BadRequest(r.Error), + TokenSignInResult r => Results.SignIn(r.Principal, properties: null, authenticationScheme: r.AuthenticationScheme), + TokenForbidResult r => Results.Forbid(r.Properties, r.AuthenticationSchemes), + TokenBadRequestResult r => Results.BadRequest(r.Error), + LogoutSignOutResult r => Results.SignOut(r.Properties, r.AuthenticationSchemes), + LogoutRedirectResult r => Results.Redirect(r.Url), + UserInfoJsonResult r => Results.Json(r.Data), + UserInfoChallengeResult r => Results.Challenge(r.Properties, r.AuthenticationSchemes), _ => Results.StatusCode(500) }; } - public static IResult FromResult(Result result) + internal static IResult TranslateResultStatus(ResultBase result) { - if (result.IsSuccess == false) + ErrorResponse errorResponse = new() { - return Results.Problem(result.Message); - } + Errors = result.Errors.Any() switch + { + true => result.Errors.ToList(), + _ => [result.Message], + } + }; - return result.Value switch + return result.Status switch { - TokenSignInResult r => Results.SignIn(r.Principal, properties: null, authenticationScheme: r.AuthenticationScheme), - TokenForbidResult r => Results.Forbid(r.Properties, r.AuthenticationSchemes), - TokenBadRequestResult r => Results.BadRequest(r.Error), - _ => Results.StatusCode(500) + ResultStatus.Invalid => Microsoft.AspNetCore.Http.Results.BadRequest(errorResponse), + ResultStatus.NotFound => Microsoft.AspNetCore.Http.Results.NotFound(errorResponse), + ResultStatus.Unauthorized => Microsoft.AspNetCore.Http.Results.Unauthorized(), + ResultStatus.Conflict => Microsoft.AspNetCore.Http.Results.Conflict(errorResponse), + ResultStatus.Failure => Microsoft.AspNetCore.Http.Results.InternalServerError(errorResponse), + ResultStatus.CriticalError => Microsoft.AspNetCore.Http.Results.InternalServerError(errorResponse), + ResultStatus.Forbidden => Microsoft.AspNetCore.Http.Results.Forbid(), + _ => Microsoft.AspNetCore.Http.Results.StatusCode((int)HttpStatusCode.NotImplemented) }; } - public static IResult FromResult(Result result) - { - if (result.IsSuccess == false) - { - return Results.Problem(result.Message); - } + //public static IResult FromResult(Result result) + //{ + // if (result.IsSuccess == false) + // { + // return TranslateResultStatus(result); + // } - return result.Value switch - { - LogoutSignOutResult r => Results.SignOut(r.Properties, r.AuthenticationSchemes), - LogoutRedirectResult r => Results.Redirect(r.Url), - _ => Results.StatusCode(500) - }; - } + // return result.Data switch + // { + // TokenSignInResult r => Results.SignIn(r.Principal, properties: null, authenticationScheme: r.AuthenticationScheme), + // TokenForbidResult r => Results.Forbid(r.Properties, r.AuthenticationSchemes), + // TokenBadRequestResult r => Results.BadRequest(r.Error), + // _ => Results.StatusCode(500) + // }; + //} - public static IResult FromResult(Result result) - { - if (result.IsSuccess == false) - { - return Results.Problem(result.Message); - } + //public static IResult FromResult(Result result) + //{ + // if (result.IsSuccess == false) + // { + // return TranslateResultStatus(result); + // } - return result.Value switch - { - UserInfoJsonResult r => Results.Json(r.Data), - UserInfoChallengeResult r => Results.Challenge(r.Properties, r.AuthenticationSchemes), - _ => Results.StatusCode(500) - }; - } + // return result.Data switch + // { + // LogoutSignOutResult r => Results.SignOut(r.Properties, r.AuthenticationSchemes), + // LogoutRedirectResult r => Results.Redirect(r.Url), + // _ => Results.StatusCode(500) + // }; + //} + + //public static IResult FromResult(Result result) + //{ + // if (result.IsSuccess == false) + // { + // return TranslateResultStatus(result); + // } + + // return result.Data switch + // { + // UserInfoJsonResult r => Results.Json(r.Data), + // UserInfoChallengeResult r => Results.Challenge(r.Properties, r.AuthenticationSchemes), + // _ => Results.StatusCode(500) + // }; + //} } diff --git a/SecurityService/Oidc/OidcEndpoints.cs b/SecurityService/Oidc/OidcEndpoints.cs index de3e83bb..fdc89aba 100644 --- a/SecurityService/Oidc/OidcEndpoints.cs +++ b/SecurityService/Oidc/OidcEndpoints.cs @@ -4,9 +4,9 @@ public static class OidcEndpoints { public static void MapOidcEndpoints(this IEndpointRouteBuilder endpoints) { - endpoints.MapMethods("/connect/authorize", [HttpMethods.Get, HttpMethods.Post], (Delegate)Handlers.AuthorizeHandler.AuthorizeAsync); - endpoints.MapPost("/connect/token", (Delegate)Handlers.TokenHandler.TokenAsync); - endpoints.MapMethods("/connect/logout", [HttpMethods.Get, HttpMethods.Post], (Delegate)Handlers.LogoutHandler.LogoutAsync); - endpoints.MapMethods("/connect/userinfo", [HttpMethods.Get, HttpMethods.Post], (Delegate)Handlers.UserInfoHandler.UserInfoAsync); + endpoints.MapMethods("/connect/authorize", [HttpMethods.Get, HttpMethods.Post], Handlers.AuthorizeHandler.AuthorizeAsync); + endpoints.MapPost("/connect/token", Handlers.TokenHandler.TokenAsync); + endpoints.MapMethods("/connect/logout", [HttpMethods.Get, HttpMethods.Post], Handlers.LogoutHandler.LogoutAsync); + endpoints.MapMethods("/connect/userinfo", [HttpMethods.Get, HttpMethods.Post], Handlers.UserInfoHandler.UserInfoAsync); } }