From fc23846adbc9802a78ad9e2a783fcd4b3f041e88 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:13:55 +0000 Subject: [PATCH 1/3] Initial plan From c6d26bf1ca2c22cf2edcc39d44f520c7a2fffb85 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:32:01 +0000 Subject: [PATCH 2/3] Refactor Connect Verify page to use MediatR commands/queries pattern Agent-Logs-Url: https://github.com/TransactionProcessing/SecurityService/sessions/c5c1ee77-7657-4d1d-a76b-65efb95e2fee Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../Oidc/OidcCommands.cs | 2 + .../Oidc/OidcResults.cs | 25 ++ .../RequestHandlers/VerifyRequestHandler.cs | 128 ++++++++ .../Pages/VerifyPageModelTests.cs | 203 +++++++++++++ .../VerifyRequestHandlerTests.cs | 277 ++++++++++++++++++ .../Pages/Connect/Verify.cshtml.cs | 113 +++---- 6 files changed, 668 insertions(+), 80 deletions(-) create mode 100644 SecurityService.BusinessLogic/RequestHandlers/VerifyRequestHandler.cs create mode 100644 SecurityService.UnitTests/Pages/VerifyPageModelTests.cs create mode 100644 SecurityService.UnitTests/RequestHandlers/VerifyRequestHandlerTests.cs diff --git a/SecurityService.BusinessLogic/Oidc/OidcCommands.cs b/SecurityService.BusinessLogic/Oidc/OidcCommands.cs index d139f7d0..4a879e90 100644 --- a/SecurityService.BusinessLogic/Oidc/OidcCommands.cs +++ b/SecurityService.BusinessLogic/Oidc/OidcCommands.cs @@ -10,4 +10,6 @@ public sealed record AuthorizeCommand(HttpContext HttpContext) : IRequest>; public sealed record LogoutCommand(HttpContext HttpContext) : IRequest>; public sealed record UserInfoCommand(HttpContext HttpContext) : IRequest>; + public sealed record VerifyGetQuery(HttpContext HttpContext) : IRequest>; + public sealed record VerifyPostCommand(HttpContext HttpContext, string Action, string UserCode) : IRequest>; } diff --git a/SecurityService.BusinessLogic/Oidc/OidcResults.cs b/SecurityService.BusinessLogic/Oidc/OidcResults.cs index 722bbdee..b1313c2b 100644 --- a/SecurityService.BusinessLogic/Oidc/OidcResults.cs +++ b/SecurityService.BusinessLogic/Oidc/OidcResults.cs @@ -42,3 +42,28 @@ public abstract record UserInfoCommandResult; public sealed record UserInfoJsonResult(Dictionary Data) : UserInfoCommandResult; public sealed record UserInfoChallengeResult(AuthenticationProperties Properties, IList AuthenticationSchemes) : UserInfoCommandResult; + +// ---- Verify endpoint ---- + +public abstract record VerifyGetQueryResult; + +public sealed record VerifyGetPageResult(string StatusMessage, VerifyDisplayData? Data) : VerifyGetQueryResult; + +public sealed record VerifyGetRedirectResult(string Url) : VerifyGetQueryResult; + +public abstract record VerifyPostCommandResult; + +public sealed record VerifyPostPageResult(string? ModelError, VerifyDisplayData? Data) : VerifyPostCommandResult; + +public sealed record VerifyPostRedirectResult(string Url) : VerifyPostCommandResult; + +public sealed record VerifyPostForbidResult(string AuthenticationScheme) : VerifyPostCommandResult; + +public sealed record VerifyPostSignInResult(ClaimsPrincipal Principal, AuthenticationProperties Properties, string AuthenticationScheme) : VerifyPostCommandResult; + +public sealed record VerifyDisplayData( + string ClientName, + IReadOnlyCollection RequestedScopes, + IReadOnlyCollection IdentityScopes, + IReadOnlyCollection ApiScopes, + string UserCode); diff --git a/SecurityService.BusinessLogic/RequestHandlers/VerifyRequestHandler.cs b/SecurityService.BusinessLogic/RequestHandlers/VerifyRequestHandler.cs new file mode 100644 index 00000000..30a0af4d --- /dev/null +++ b/SecurityService.BusinessLogic/RequestHandlers/VerifyRequestHandler.cs @@ -0,0 +1,128 @@ +using System.Collections.Immutable; +using MediatR; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using SecurityService.BusinessLogic.Oidc; +using SecurityService.Database; +using SecurityService.Database.DbContexts; +using SimpleResults; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace SecurityService.BusinessLogic.RequestHandlers; + +public sealed class VerifyRequestHandler : + IRequestHandler>, + IRequestHandler> +{ + private readonly IOpenIddictApplicationManager _applicationManager; + private readonly IOpenIddictScopeManager _scopeManager; + private readonly SecurityServiceDbContext _dbContext; + private readonly UserManager _userManager; + + public VerifyRequestHandler( + IOpenIddictApplicationManager applicationManager, + IOpenIddictScopeManager scopeManager, + SecurityServiceDbContext dbContext, + UserManager userManager) + { + this._applicationManager = applicationManager; + this._scopeManager = scopeManager; + this._dbContext = dbContext; + this._userManager = userManager; + } + + public async Task> Handle(OidcCommands.VerifyGetQuery query, CancellationToken cancellationToken) + { + var context = query.HttpContext; + var userCode = context.Request.Query["user_code"].FirstOrDefault() ?? string.Empty; + + var authenticationResult = await context.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + if (authenticationResult.Succeeded == false || authenticationResult.Principal?.GetClaim(Claims.ClientId) is null) + { + var statusMessage = string.IsNullOrWhiteSpace(userCode) ? string.Empty : "The specified user code is invalid."; + return Result.Success(new VerifyGetPageResult(statusMessage, null)); + } + + if (context.User.Identity?.IsAuthenticated != true) + { + var loginUrl = $"/Account/Login?returnUrl={Uri.EscapeDataString(OidcHelpers.BuildCurrentRequestUrl(context.Request))}"; + return Result.Success(new VerifyGetRedirectResult(loginUrl)); + } + + var data = await this.BuildDisplayDataAsync(authenticationResult, userCode, cancellationToken); + return Result.Success(new VerifyGetPageResult(string.Empty, data)); + } + + public async Task> Handle(OidcCommands.VerifyPostCommand command, CancellationToken cancellationToken) + { + var context = command.HttpContext; + + if (string.Equals(command.Action, "lookup", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(command.UserCode)) + { + return Result.Success(new VerifyPostPageResult("Enter the user code shown on the device.", null)); + } + + return Result.Success(new VerifyPostRedirectResult($"/connect/verify?user_code={Uri.EscapeDataString(command.UserCode)}")); + } + + var authenticationResult = await context.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + if (authenticationResult.Succeeded == false || authenticationResult.Principal?.GetClaim(Claims.ClientId) is null) + { + return Result.Success(new VerifyPostPageResult("The specified user code is invalid.", null)); + } + + if (context.User.Identity?.IsAuthenticated != true) + { + var loginUrl = $"/Account/Login?returnUrl={Uri.EscapeDataString(OidcHelpers.BuildCurrentRequestUrl(context.Request))}"; + return Result.Success(new VerifyPostRedirectResult(loginUrl)); + } + + if (string.Equals(command.Action, "deny", StringComparison.OrdinalIgnoreCase)) + { + return Result.Success(new VerifyPostForbidResult(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)); + } + + var user = await this._userManager.GetUserAsync(context.User); + if (user is null) + { + var loginUrl = $"/Account/Login?returnUrl={Uri.EscapeDataString(OidcHelpers.BuildCurrentRequestUrl(context.Request))}"; + return Result.Success(new VerifyPostRedirectResult(loginUrl)); + } + + 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, authorizationId: null); + + return Result.Success(new VerifyPostSignInResult( + principal, + new AuthenticationProperties { RedirectUri = "/" }, + OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)); + } + + private async Task BuildDisplayDataAsync(AuthenticateResult authenticationResult, string userCodeFromQuery, CancellationToken cancellationToken) + { + var requestedScopes = authenticationResult.Principal!.GetScopes().ToArray(); + var scopeDisplay = await OidcHelpers.BuildScopeDisplay(requestedScopes, this._dbContext, cancellationToken); + + var userCode = authenticationResult.Properties?.GetTokenValue(OpenIddictServerAspNetCoreConstants.Tokens.UserCode) + ?? userCodeFromQuery; + + var clientId = authenticationResult.Principal?.GetClaim(Claims.ClientId); + string clientName; + if (string.IsNullOrWhiteSpace(clientId)) + { + clientName = string.Empty; + } + else + { + var application = await this._applicationManager.FindByClientIdAsync(clientId, cancellationToken); + clientName = application is null ? clientId : await this._applicationManager.GetDisplayNameAsync(application, cancellationToken) ?? clientId; + } + + return new VerifyDisplayData(clientName, requestedScopes, scopeDisplay.IdentityScopes, scopeDisplay.ApiScopes, userCode); + } +} diff --git a/SecurityService.UnitTests/Pages/VerifyPageModelTests.cs b/SecurityService.UnitTests/Pages/VerifyPageModelTests.cs new file mode 100644 index 00000000..49adb115 --- /dev/null +++ b/SecurityService.UnitTests/Pages/VerifyPageModelTests.cs @@ -0,0 +1,203 @@ +using System.Security.Claims; +using MediatR; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Moq; +using SecurityService.BusinessLogic.Oidc; +using Shouldly; +using SimpleResults; + +namespace SecurityService.UnitTests.Pages; + +public class VerifyPageModelTests +{ + [Fact] + public async Task OnGetAsync_WhenHandlerReturnsRedirect_ReturnsRedirectResult() + { + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(new VerifyGetRedirectResult("/Account/Login?returnUrl=%2Fconnect%2Fverify"))); + + var model = CreateModel(mediator, new DefaultHttpContext()); + + var result = await model.OnGetAsync(CancellationToken.None); + + var redirect = result.ShouldBeOfType(); + redirect.Url.ShouldBe("/Account/Login?returnUrl=%2Fconnect%2Fverify"); + } + + [Fact] + public async Task OnGetAsync_WhenHandlerReturnsPageWithStatusMessage_SetsStatusMessageAndReturnsPage() + { + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(new VerifyGetPageResult("The specified user code is invalid.", null))); + + var model = CreateModel(mediator, new DefaultHttpContext()); + + var result = await model.OnGetAsync(CancellationToken.None); + + result.ShouldBeOfType(); + model.StatusMessage.ShouldBe("The specified user code is invalid."); + model.ClientName.ShouldBe(string.Empty); + model.RequestedScopes.ShouldBeEmpty(); + } + + [Fact] + public async Task OnGetAsync_WhenHandlerReturnsPageWithDisplayData_AppliesDisplayDataAndReturnsPage() + { + var displayData = new VerifyDisplayData( + "My App", + ["openid", "profile"], + [new ScopeDisplayItem("openid", "OpenID", null, true, false)], + [], + "ABCD-1234"); + + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(new VerifyGetPageResult(string.Empty, displayData))); + + var model = CreateModel(mediator, new DefaultHttpContext()); + + var result = await model.OnGetAsync(CancellationToken.None); + + result.ShouldBeOfType(); + model.StatusMessage.ShouldBe(string.Empty); + model.ClientName.ShouldBe("My App"); + model.RequestedScopes.ShouldBe(["openid", "profile"]); + model.IdentityScopes.Count.ShouldBe(1); + model.Input.UserCode.ShouldBe("ABCD-1234"); + } + + [Fact] + public async Task OnGetAsync_SendsQueryWithCorrectHttpContext() + { + var httpContext = new DefaultHttpContext(); + OidcCommands.VerifyGetQuery? capturedQuery = null; + + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .Callback>, CancellationToken>((req, _) => + { + capturedQuery = req.ShouldBeOfType(); + }) + .ReturnsAsync(Result.Success(new VerifyGetPageResult(string.Empty, null))); + + var model = CreateModel(mediator, httpContext); + await model.OnGetAsync(CancellationToken.None); + + capturedQuery.ShouldNotBeNull(); + capturedQuery.HttpContext.ShouldBe(httpContext); + } + + [Fact] + public async Task OnPostAsync_WhenHandlerReturnsRedirect_ReturnsRedirectResult() + { + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(new VerifyPostRedirectResult("/connect/verify?user_code=ABCD-1234"))); + + var model = CreateModel(mediator, new DefaultHttpContext()); + model.Input = new SecurityService.Pages.Connect.VerifyModel.InputModel + { + Action = "lookup", + UserCode = "ABCD-1234" + }; + + var result = await model.OnPostAsync(CancellationToken.None); + + var redirect = result.ShouldBeOfType(); + redirect.Url.ShouldBe("/connect/verify?user_code=ABCD-1234"); + } + + [Fact] + public async Task OnPostAsync_WhenHandlerReturnsForbid_ReturnsForbidResult() + { + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(new VerifyPostForbidResult("OpenIddict.Server.AspNetCore"))); + + var model = CreateModel(mediator, new DefaultHttpContext()); + model.Input = new SecurityService.Pages.Connect.VerifyModel.InputModel { Action = "deny" }; + + var result = await model.OnPostAsync(CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task OnPostAsync_WhenHandlerReturnsSignIn_ReturnsSignInResult() + { + var principal = new ClaimsPrincipal(new ClaimsIdentity()); + var properties = new AuthenticationProperties { RedirectUri = "/" }; + + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(new VerifyPostSignInResult(principal, properties, "OpenIddict.Server.AspNetCore"))); + + var model = CreateModel(mediator, new DefaultHttpContext()); + model.Input = new SecurityService.Pages.Connect.VerifyModel.InputModel { Action = "authorize" }; + + var result = await model.OnPostAsync(CancellationToken.None); + + var signIn = result.ShouldBeOfType(); + signIn.Principal.ShouldBe(principal); + signIn.Properties.ShouldBe(properties); + } + + [Fact] + public async Task OnPostAsync_WhenHandlerReturnsPageWithError_AddsModelErrorAndReturnsPage() + { + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(new VerifyPostPageResult("Enter the user code shown on the device.", null))); + + var model = CreateModel(mediator, new DefaultHttpContext()); + model.Input = new SecurityService.Pages.Connect.VerifyModel.InputModel { Action = "lookup", UserCode = string.Empty }; + + var result = await model.OnPostAsync(CancellationToken.None); + + result.ShouldBeOfType(); + model.ModelState[string.Empty]!.Errors.ShouldContain(e => e.ErrorMessage == "Enter the user code shown on the device."); + } + + [Fact] + public async Task OnPostAsync_SendsCommandWithCorrectActionAndUserCode() + { + OidcCommands.VerifyPostCommand? capturedCommand = null; + + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .Callback>, CancellationToken>((req, _) => + { + capturedCommand = req.ShouldBeOfType(); + }) + .ReturnsAsync(Result.Success(new VerifyPostRedirectResult("/connect/verify?user_code=ABCD-1234"))); + + var model = CreateModel(mediator, new DefaultHttpContext()); + model.Input = new SecurityService.Pages.Connect.VerifyModel.InputModel + { + Action = "lookup", + UserCode = "ABCD-1234" + }; + + await model.OnPostAsync(CancellationToken.None); + + capturedCommand.ShouldNotBeNull(); + capturedCommand.Action.ShouldBe("lookup"); + capturedCommand.UserCode.ShouldBe("ABCD-1234"); + } + + private static SecurityService.Pages.Connect.VerifyModel CreateModel(Mock mediator, HttpContext httpContext) + { + return new SecurityService.Pages.Connect.VerifyModel(mediator.Object) + { + PageContext = new PageContext + { + HttpContext = httpContext + } + }; + } +} diff --git a/SecurityService.UnitTests/RequestHandlers/VerifyRequestHandlerTests.cs b/SecurityService.UnitTests/RequestHandlers/VerifyRequestHandlerTests.cs new file mode 100644 index 00000000..82fe74d8 --- /dev/null +++ b/SecurityService.UnitTests/RequestHandlers/VerifyRequestHandlerTests.cs @@ -0,0 +1,277 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using OpenIddict.Server.AspNetCore; +using SecurityService.BusinessLogic.Oidc; +using SecurityService.BusinessLogic.RequestHandlers; +using SecurityService.Database; +using SecurityService.Database.DbContexts; +using SecurityService.UnitTests.Infrastructure; +using Shouldly; + +namespace SecurityService.UnitTests.RequestHandlers; + +public class VerifyRequestHandlerTests +{ + [Fact] + public async Task VerifyGetQuery_WhenAuthFails_ReturnsPageResultWithNoData() + { + var userManager = IdentityMocks.CreateUserManager(); + var scopeManager = new Mock(); + var appManager = new Mock(); + using var serviceProvider = TestServiceProviderFactory.Create(nameof(this.VerifyGetQuery_WhenAuthFails_ReturnsPageResultWithNoData)); + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var handler = new VerifyRequestHandler(appManager.Object, scopeManager.Object, dbContext, userManager.Object); + var context = CreateHttpContext(AuthenticateResult.Fail("no ticket"), isAuthenticated: false, userCode: string.Empty); + + var result = await handler.Handle(new OidcCommands.VerifyGetQuery(context), CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + var page = result.Data.ShouldBeOfType(); + page.StatusMessage.ShouldBe(string.Empty); + page.Data.ShouldBeNull(); + } + + [Fact] + public async Task VerifyGetQuery_WhenAuthFailsWithUserCode_ReturnsPageWithInvalidCodeMessage() + { + var userManager = IdentityMocks.CreateUserManager(); + var scopeManager = new Mock(); + var appManager = new Mock(); + using var serviceProvider = TestServiceProviderFactory.Create(nameof(this.VerifyGetQuery_WhenAuthFailsWithUserCode_ReturnsPageWithInvalidCodeMessage)); + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var handler = new VerifyRequestHandler(appManager.Object, scopeManager.Object, dbContext, userManager.Object); + var context = CreateHttpContext(AuthenticateResult.Fail("no ticket"), isAuthenticated: false, userCode: "ABCD-1234"); + + var result = await handler.Handle(new OidcCommands.VerifyGetQuery(context), CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + var page = result.Data.ShouldBeOfType(); + page.StatusMessage.ShouldBe("The specified user code is invalid."); + page.Data.ShouldBeNull(); + } + + [Fact] + public async Task VerifyGetQuery_WhenAuthSucceedsButUserNotAuthenticated_ReturnsRedirectToLogin() + { + var userManager = IdentityMocks.CreateUserManager(); + var scopeManager = new Mock(); + var appManager = new Mock(); + using var serviceProvider = TestServiceProviderFactory.Create(nameof(this.VerifyGetQuery_WhenAuthSucceedsButUserNotAuthenticated_ReturnsRedirectToLogin)); + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var authTicket = CreateAuthTicket("client-1"); + var handler = new VerifyRequestHandler(appManager.Object, scopeManager.Object, dbContext, userManager.Object); + var context = CreateHttpContext(AuthenticateResult.Success(authTicket), isAuthenticated: false, userCode: string.Empty); + + var result = await handler.Handle(new OidcCommands.VerifyGetQuery(context), CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + var redirect = result.Data.ShouldBeOfType(); + redirect.Url.ShouldContain("/Account/Login"); + } + + [Fact] + public async Task VerifyPostCommand_WithLookupAction_WhenEmptyUserCode_ReturnsPageWithError() + { + var userManager = IdentityMocks.CreateUserManager(); + var scopeManager = new Mock(); + var appManager = new Mock(); + using var serviceProvider = TestServiceProviderFactory.Create(nameof(this.VerifyPostCommand_WithLookupAction_WhenEmptyUserCode_ReturnsPageWithError)); + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var handler = new VerifyRequestHandler(appManager.Object, scopeManager.Object, dbContext, userManager.Object); + var context = CreateHttpContext(AuthenticateResult.Fail("irrelevant"), isAuthenticated: false, userCode: string.Empty); + + var result = await handler.Handle( + new OidcCommands.VerifyPostCommand(context, "lookup", string.Empty), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + var page = result.Data.ShouldBeOfType(); + page.ModelError.ShouldBe("Enter the user code shown on the device."); + } + + [Fact] + public async Task VerifyPostCommand_WithLookupAction_WhenValidUserCode_ReturnsRedirect() + { + var userManager = IdentityMocks.CreateUserManager(); + var scopeManager = new Mock(); + var appManager = new Mock(); + using var serviceProvider = TestServiceProviderFactory.Create(nameof(this.VerifyPostCommand_WithLookupAction_WhenValidUserCode_ReturnsRedirect)); + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var handler = new VerifyRequestHandler(appManager.Object, scopeManager.Object, dbContext, userManager.Object); + var context = CreateHttpContext(AuthenticateResult.Fail("irrelevant"), isAuthenticated: false, userCode: string.Empty); + + var result = await handler.Handle( + new OidcCommands.VerifyPostCommand(context, "lookup", "ABCD-1234"), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + var redirect = result.Data.ShouldBeOfType(); + redirect.Url.ShouldBe("/connect/verify?user_code=ABCD-1234"); + } + + [Fact] + public async Task VerifyPostCommand_WhenAuthFails_ReturnsPageWithInvalidCodeError() + { + var userManager = IdentityMocks.CreateUserManager(); + var scopeManager = new Mock(); + var appManager = new Mock(); + using var serviceProvider = TestServiceProviderFactory.Create(nameof(this.VerifyPostCommand_WhenAuthFails_ReturnsPageWithInvalidCodeError)); + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var handler = new VerifyRequestHandler(appManager.Object, scopeManager.Object, dbContext, userManager.Object); + var context = CreateHttpContext(AuthenticateResult.Fail("no ticket"), isAuthenticated: false, userCode: string.Empty); + + var result = await handler.Handle( + new OidcCommands.VerifyPostCommand(context, "authorize", "ABCD-1234"), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + var page = result.Data.ShouldBeOfType(); + page.ModelError.ShouldBe("The specified user code is invalid."); + } + + [Fact] + public async Task VerifyPostCommand_WhenAuthSucceedsButUserNotAuthenticated_ReturnsRedirectToLogin() + { + var userManager = IdentityMocks.CreateUserManager(); + var scopeManager = new Mock(); + var appManager = new Mock(); + using var serviceProvider = TestServiceProviderFactory.Create(nameof(this.VerifyPostCommand_WhenAuthSucceedsButUserNotAuthenticated_ReturnsRedirectToLogin)); + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var authTicket = CreateAuthTicket("client-1"); + var handler = new VerifyRequestHandler(appManager.Object, scopeManager.Object, dbContext, userManager.Object); + var context = CreateHttpContext(AuthenticateResult.Success(authTicket), isAuthenticated: false, userCode: string.Empty); + + var result = await handler.Handle( + new OidcCommands.VerifyPostCommand(context, "authorize", "ABCD-1234"), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + var redirect = result.Data.ShouldBeOfType(); + redirect.Url.ShouldContain("/Account/Login"); + } + + [Fact] + public async Task VerifyPostCommand_WithDenyAction_WhenAuthenticatedUser_ReturnsForbid() + { + var userManager = IdentityMocks.CreateUserManager(); + var scopeManager = new Mock(); + var appManager = new Mock(); + using var serviceProvider = TestServiceProviderFactory.Create(nameof(this.VerifyPostCommand_WithDenyAction_WhenAuthenticatedUser_ReturnsForbid)); + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var authTicket = CreateAuthTicket("client-1"); + var handler = new VerifyRequestHandler(appManager.Object, scopeManager.Object, dbContext, userManager.Object); + var context = CreateHttpContext(AuthenticateResult.Success(authTicket), isAuthenticated: true, userCode: string.Empty); + + var result = await handler.Handle( + new OidcCommands.VerifyPostCommand(context, "deny", "ABCD-1234"), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + var forbid = result.Data.ShouldBeOfType(); + forbid.AuthenticationScheme.ShouldBe(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + [Fact] + public async Task VerifyPostCommand_WhenAuthenticatedUserNotFoundInStore_ReturnsRedirectToLogin() + { + var userManager = IdentityMocks.CreateUserManager(); + userManager.Setup(m => m.GetUserAsync(It.IsAny())).ReturnsAsync((ApplicationUser?)null); + var scopeManager = new Mock(); + var appManager = new Mock(); + using var serviceProvider = TestServiceProviderFactory.Create(nameof(this.VerifyPostCommand_WhenAuthenticatedUserNotFoundInStore_ReturnsRedirectToLogin)); + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var authTicket = CreateAuthTicket("client-1"); + var handler = new VerifyRequestHandler(appManager.Object, scopeManager.Object, dbContext, userManager.Object); + var context = CreateHttpContext(AuthenticateResult.Success(authTicket), isAuthenticated: true, userCode: string.Empty); + + var result = await handler.Handle( + new OidcCommands.VerifyPostCommand(context, "authorize", "ABCD-1234"), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + var redirect = result.Data.ShouldBeOfType(); + redirect.Url.ShouldContain("/Account/Login"); + } + + private static HttpContext CreateHttpContext(AuthenticateResult authResult, bool isAuthenticated, string userCode) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new FakeAuthenticationService(authResult)); + + var context = new DefaultHttpContext + { + RequestServices = services.BuildServiceProvider() + }; + + if (!string.IsNullOrEmpty(userCode)) + { + context.Request.QueryString = new QueryString($"?user_code={Uri.EscapeDataString(userCode)}"); + } + + if (isAuthenticated) + { + var identity = new ClaimsIdentity("Test"); + identity.AddClaim(new Claim(ClaimTypes.Name, "testuser")); + context.User = new ClaimsPrincipal(identity); + } + + return context; + } + + private static AuthenticationTicket CreateAuthTicket(string clientId) + { + var identity = new ClaimsIdentity("OpenIddict.Server.AspNetCore"); + identity.AddClaim(new Claim(OpenIddictConstants.Claims.ClientId, clientId)); + var principal = new ClaimsPrincipal(identity); + return new AuthenticationTicket(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + private sealed class FakeAuthenticationService : IAuthenticationService + { + private readonly AuthenticateResult _authenticateResult; + + public FakeAuthenticationService(AuthenticateResult authenticateResult) + { + this._authenticateResult = authenticateResult; + } + + public Task AuthenticateAsync(HttpContext context, string? scheme) => + Task.FromResult(this._authenticateResult); + + public Task ChallengeAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) => + Task.CompletedTask; + + public Task ForbidAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) => + Task.CompletedTask; + + public Task SignInAsync(HttpContext context, string? scheme, ClaimsPrincipal principal, AuthenticationProperties? properties) => + Task.CompletedTask; + + public Task SignOutAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) => + Task.CompletedTask; + } +} + diff --git a/SecurityService/Pages/Connect/Verify.cshtml.cs b/SecurityService/Pages/Connect/Verify.cshtml.cs index 0b4ccaa0..d55b920e 100644 --- a/SecurityService/Pages/Connect/Verify.cshtml.cs +++ b/SecurityService/Pages/Connect/Verify.cshtml.cs @@ -1,30 +1,18 @@ -using System.Collections.Immutable; +using MediatR; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -using OpenIddict.Abstractions; -using OpenIddict.Server.AspNetCore; -using SecurityService.Database; -using SecurityService.Database.DbContexts; using SecurityService.BusinessLogic.Oidc; -using static OpenIddict.Abstractions.OpenIddictConstants; namespace SecurityService.Pages.Connect; public sealed class VerifyModel : PageModel { - private readonly IOpenIddictApplicationManager _applicationManager; - private readonly IOpenIddictScopeManager _scopeManager; - private readonly SecurityServiceDbContext _dbContext; - private readonly UserManager _userManager; + private readonly IMediator _mediator; - public VerifyModel(IOpenIddictApplicationManager applicationManager, IOpenIddictScopeManager scopeManager, SecurityServiceDbContext dbContext, UserManager userManager) + public VerifyModel(IMediator mediator) { - this._applicationManager = applicationManager; - this._scopeManager = scopeManager; - this._dbContext = dbContext; - this._userManager = userManager; + this._mediator = mediator; } [BindProperty] @@ -42,95 +30,59 @@ public VerifyModel(IOpenIddictApplicationManager applicationManager, IOpenIddict public async Task OnGetAsync(CancellationToken cancellationToken) { - var result = await this.LoadVerificationContextAsync(cancellationToken); - if (result.Valid == false) + var result = await this._mediator.Send(new OidcCommands.VerifyGetQuery(this.HttpContext), cancellationToken); + + if (result.Data is VerifyGetRedirectResult redirect) { - this.StatusMessage = string.IsNullOrWhiteSpace(this.Input.UserCode) ? string.Empty : "The specified user code is invalid."; - return this.Page(); + return this.Redirect(redirect.Url); } - if (this.User.Identity?.IsAuthenticated != true) + if (result.Data is VerifyGetPageResult page) { - return this.Redirect($"/Account/Login?returnUrl={Uri.EscapeDataString(OidcHelpers.BuildCurrentRequestUrl(this.Request))}"); + this.StatusMessage = page.StatusMessage; + this.ApplyDisplayData(page.Data); } - await this.PopulateAsync(result.AuthenticationResult!, cancellationToken); return this.Page(); } public async Task OnPostAsync(CancellationToken cancellationToken) { - if (string.Equals(this.Input.Action, "lookup", StringComparison.OrdinalIgnoreCase)) - { - if (string.IsNullOrWhiteSpace(this.Input.UserCode)) - { - this.ModelState.AddModelError(string.Empty, "Enter the user code shown on the device."); - return this.Page(); - } - - return this.Redirect($"/connect/verify?user_code={Uri.EscapeDataString(this.Input.UserCode)}"); - } - - var result = await this.LoadVerificationContextAsync(cancellationToken); - if (result.Valid == false || result.AuthenticationResult is null) - { - this.ModelState.AddModelError(string.Empty, "The specified user code is invalid."); - return this.Page(); - } - - if (this.User.Identity?.IsAuthenticated != true) - { - return this.Redirect($"/Account/Login?returnUrl={Uri.EscapeDataString(OidcHelpers.BuildCurrentRequestUrl(this.Request))}"); - } + var result = await this._mediator.Send(new OidcCommands.VerifyPostCommand(this.HttpContext, this.Input.Action, this.Input.UserCode), cancellationToken); - if (string.Equals(this.Input.Action, "deny", StringComparison.OrdinalIgnoreCase)) + return result.Data switch { - return this.Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); - } - - var user = await this._userManager.GetUserAsync(this.User); - if (user is null) - { - return this.Redirect($"/Account/Login?returnUrl={Uri.EscapeDataString(OidcHelpers.BuildCurrentRequestUrl(this.Request))}"); - } - - var scopes = result.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, authorizationId: null); - - return this.SignIn(principal, new AuthenticationProperties { RedirectUri = "/" }, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + VerifyPostRedirectResult redirect => this.Redirect(redirect.Url), + VerifyPostForbidResult forbid => this.Forbid(forbid.AuthenticationScheme), + VerifyPostSignInResult signIn => this.SignIn(signIn.Principal, signIn.Properties, signIn.AuthenticationScheme), + VerifyPostPageResult page => this.HandlePageResult(page), + _ => this.Page() + }; } - private async Task PopulateAsync(AuthenticateResult authenticationResult, CancellationToken cancellationToken) + private IActionResult HandlePageResult(VerifyPostPageResult page) { - this.RequestedScopes = authenticationResult.Principal!.GetScopes().ToArray(); - 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; - - var clientId = authenticationResult.Principal?.GetClaim(Claims.ClientId); - if (string.IsNullOrWhiteSpace(clientId)) + if (page.ModelError is not null) { - this.ClientName = string.Empty; - return; + this.ModelState.AddModelError(string.Empty, page.ModelError); } - var application = await this._applicationManager.FindByClientIdAsync(clientId, cancellationToken); - this.ClientName = application is null ? clientId : await this._applicationManager.GetDisplayNameAsync(application, cancellationToken) ?? clientId; + this.ApplyDisplayData(page.Data); + return this.Page(); } - private async Task<(bool Valid, AuthenticateResult? AuthenticationResult)> LoadVerificationContextAsync(CancellationToken cancellationToken) + private void ApplyDisplayData(VerifyDisplayData? data) { - this.Input.UserCode = this.Request.Query["user_code"].FirstOrDefault() ?? this.Input.UserCode; - var authenticationResult = await this.HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); - if (authenticationResult.Succeeded == false || authenticationResult.Principal?.GetClaim(Claims.ClientId) is null) + if (data is null) { - return (false, null); + return; } - await this.PopulateAsync(authenticationResult, cancellationToken); - return (true, authenticationResult); + this.ClientName = data.ClientName; + this.RequestedScopes = data.RequestedScopes; + this.IdentityScopes = data.IdentityScopes; + this.ApiScopes = data.ApiScopes; + this.Input.UserCode = data.UserCode; } public sealed class InputModel @@ -140,3 +92,4 @@ public sealed class InputModel public string Action { get; set; } = "lookup"; } } + From 852abe62f0eb96fe76a0b180304dde87ccc3c2fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:02:19 +0000 Subject: [PATCH 3/3] Fix Codacy complexity: extract HandleLookupPost and HandleVerifiedPostAsync from Handle(VerifyPostCommand) Agent-Logs-Url: https://github.com/TransactionProcessing/SecurityService/sessions/3efa1b42-9706-4459-9471-edb8c30ace12 Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../RequestHandlers/VerifyRequestHandler.cs | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/SecurityService.BusinessLogic/RequestHandlers/VerifyRequestHandler.cs b/SecurityService.BusinessLogic/RequestHandlers/VerifyRequestHandler.cs index 30a0af4d..365d1a0e 100644 --- a/SecurityService.BusinessLogic/RequestHandlers/VerifyRequestHandler.cs +++ b/SecurityService.BusinessLogic/RequestHandlers/VerifyRequestHandler.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using MediatR; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using OpenIddict.Abstractions; using OpenIddict.Server.AspNetCore; @@ -61,12 +62,7 @@ public async Task> Handle(OidcCommands.VerifyPos if (string.Equals(command.Action, "lookup", StringComparison.OrdinalIgnoreCase)) { - if (string.IsNullOrWhiteSpace(command.UserCode)) - { - return Result.Success(new VerifyPostPageResult("Enter the user code shown on the device.", null)); - } - - return Result.Success(new VerifyPostRedirectResult($"/connect/verify?user_code={Uri.EscapeDataString(command.UserCode)}")); + return HandleLookupPost(command.UserCode); } var authenticationResult = await context.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); @@ -81,7 +77,22 @@ public async Task> Handle(OidcCommands.VerifyPos return Result.Success(new VerifyPostRedirectResult(loginUrl)); } - if (string.Equals(command.Action, "deny", StringComparison.OrdinalIgnoreCase)) + return await this.HandleVerifiedPostAsync(command.Action, authenticationResult, context, cancellationToken); + } + + private static Result HandleLookupPost(string userCode) + { + if (string.IsNullOrWhiteSpace(userCode)) + { + return Result.Success(new VerifyPostPageResult("Enter the user code shown on the device.", null)); + } + + return Result.Success(new VerifyPostRedirectResult($"/connect/verify?user_code={Uri.EscapeDataString(userCode)}")); + } + + private async Task> HandleVerifiedPostAsync(string action, AuthenticateResult authenticationResult, HttpContext context, CancellationToken cancellationToken) + { + if (string.Equals(action, "deny", StringComparison.OrdinalIgnoreCase)) { return Result.Success(new VerifyPostForbidResult(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)); }