Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions SecurityService.BusinessLogic/Oidc/OidcCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ public sealed record LogoutCommand(HttpContext HttpContext) : IRequest<Result<Lo
public sealed record UserInfoCommand(HttpContext HttpContext) : IRequest<Result<UserInfoCommandResult>>;
public sealed record VerifyGetQuery(HttpContext HttpContext) : IRequest<Result<VerifyGetQueryResult>>;
public sealed record VerifyPostCommand(HttpContext HttpContext, string Action, string UserCode) : IRequest<Result<VerifyPostCommandResult>>;
public sealed record ConsentGetQuery(HttpContext HttpContext, string ReturnUrl) : IRequest<Result<ConsentGetQueryResult>>;
public sealed record ConsentPostCommand(string ReturnUrl, string Button, IReadOnlyCollection<string> SelectedScopes) : IRequest<Result<ConsentPostCommandResult>>;
}
17 changes: 17 additions & 0 deletions SecurityService.BusinessLogic/Oidc/OidcResults.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,20 @@ public sealed record VerifyDisplayData(
IReadOnlyCollection<ScopeDisplayItem> IdentityScopes,
IReadOnlyCollection<ScopeDisplayItem> ApiScopes,
string UserCode);

// ---- Consent endpoint ----

public abstract record ConsentGetQueryResult;

public sealed record ConsentGetPageResult(
string ClientName,
IReadOnlyCollection<ScopeDisplayItem> IdentityScopes,
IReadOnlyCollection<ScopeDisplayItem> ApiScopes) : ConsentGetQueryResult;

public sealed record ConsentGetLocalRedirectResult(string Url) : ConsentGetQueryResult;

public abstract record ConsentPostCommandResult;

public sealed record ConsentPostRedirectResult(string Url) : ConsentPostCommandResult;

public sealed record ConsentPostPageResult(string ModelError) : ConsentPostCommandResult;
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using MediatR;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Http;
using OpenIddict.Abstractions;
using SecurityService.BusinessLogic.Oidc;
using SecurityService.Database.DbContexts;
using SimpleResults;

namespace SecurityService.BusinessLogic.RequestHandlers;

public sealed class ConsentRequestHandler :
IRequestHandler<OidcCommands.ConsentGetQuery, Result<ConsentGetQueryResult>>,
IRequestHandler<OidcCommands.ConsentPostCommand, Result<ConsentPostCommandResult>>
{
private readonly IOpenIddictApplicationManager _applicationManager;
private readonly SecurityServiceDbContext _dbContext;

public ConsentRequestHandler(
IOpenIddictApplicationManager applicationManager,
SecurityServiceDbContext dbContext)
{
this._applicationManager = applicationManager;
this._dbContext = dbContext;
}

public async Task<Result<ConsentGetQueryResult>> Handle(OidcCommands.ConsentGetQuery query, CancellationToken cancellationToken)
{
var request = query.HttpContext.GetOpenIddictServerRequest();
if (request is null)
{
return Result.Success<ConsentGetQueryResult>(new ConsentGetLocalRedirectResult(query.ReturnUrl));
}

var application = await this._applicationManager.FindByClientIdAsync(request.ClientId!, cancellationToken);
var clientName = application is null
? request.ClientId!
: await this._applicationManager.GetDisplayNameAsync(application, cancellationToken) ?? request.ClientId!;

var scopes = await OidcHelpers.BuildScopeDisplay(request, this._dbContext, cancellationToken);

return Result.Success<ConsentGetQueryResult>(new ConsentGetPageResult(clientName, scopes.IdentityScopes, scopes.ApiScopes));
}

public Task<Result<ConsentPostCommandResult>> Handle(OidcCommands.ConsentPostCommand command, CancellationToken cancellationToken)
{
if (string.Equals(command.Button, "deny", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(Result.Success<ConsentPostCommandResult>(
new ConsentPostRedirectResult(OidcHelpers.AppendQueryValue(command.ReturnUrl, "consent", "denied"))));
}

if (command.SelectedScopes.Count == 0)
{
return Task.FromResult(Result.Success<ConsentPostCommandResult>(
new ConsentPostPageResult("Select at least one scope to continue.")));
}

var redirectUrl = OidcHelpers.AppendQueryValue(command.ReturnUrl, "consent", "accepted");
redirectUrl = OidcHelpers.AppendQueryValues(redirectUrl, "granted_scope", command.SelectedScopes);
return Task.FromResult(Result.Success<ConsentPostCommandResult>(new ConsentPostRedirectResult(redirectUrl)));
}
}
151 changes: 151 additions & 0 deletions SecurityService.UnitTests/Pages/ConsentPageModelTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
using MediatR;
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 ConsentPageModelTests
{
[Fact]
public async Task OnGetAsync_WhenHandlerReturnsLocalRedirect_ReturnsLocalRedirectResult()
{
var mediator = new Mock<IMediator>();
mediator.Setup(m => m.Send(It.IsAny<OidcCommands.ConsentGetQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Success<ConsentGetQueryResult>(new ConsentGetLocalRedirectResult("/return")));

var model = CreateModel(mediator, new DefaultHttpContext());
model.Input = new SecurityService.Pages.Consent.IndexModel.InputModel { ReturnUrl = "/return" };

var result = await model.OnGetAsync("/return", CancellationToken.None);

var redirect = result.ShouldBeOfType<LocalRedirectResult>();
redirect.Url.ShouldBe("/return");
}

[Fact]
public async Task OnGetAsync_WhenHandlerReturnsPage_SetsPropertiesAndReturnsPage()
{
var identityScopes = new[] { new ScopeDisplayItem("openid", "OpenID", null, true, false) };
var apiScopes = new[] { new ScopeDisplayItem("api", "API", null, false, false) };
var mediator = new Mock<IMediator>();
mediator.Setup(m => m.Send(It.IsAny<OidcCommands.ConsentGetQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Success<ConsentGetQueryResult>(new ConsentGetPageResult("My App", identityScopes, apiScopes)));

var model = CreateModel(mediator, new DefaultHttpContext());

var result = await model.OnGetAsync("/return", CancellationToken.None);

result.ShouldBeOfType<PageResult>();
model.ClientName.ShouldBe("My App");
model.IdentityScopes.Count.ShouldBe(1);
model.ApiScopes.Count.ShouldBe(1);
}

[Fact]
public async Task OnGetAsync_SendsQueryWithCorrectReturnUrl()
{
OidcCommands.ConsentGetQuery? capturedQuery = null;

var mediator = new Mock<IMediator>();
mediator.Setup(m => m.Send(It.IsAny<OidcCommands.ConsentGetQuery>(), It.IsAny<CancellationToken>()))
.Callback<IRequest<Result<ConsentGetQueryResult>>, CancellationToken>((req, _) =>
{
capturedQuery = req.ShouldBeOfType<OidcCommands.ConsentGetQuery>();
})
.ReturnsAsync(Result.Success<ConsentGetQueryResult>(new ConsentGetPageResult(string.Empty, [], [])));

var model = CreateModel(mediator, new DefaultHttpContext());
await model.OnGetAsync("/my-return", CancellationToken.None);

capturedQuery.ShouldNotBeNull();
capturedQuery.ReturnUrl.ShouldBe("/my-return");
}

[Fact]
public async Task OnPostAsync_WhenHandlerReturnsRedirect_ReturnsRedirectResult()
{
var mediator = new Mock<IMediator>();
mediator.Setup(m => m.Send(It.IsAny<OidcCommands.ConsentPostCommand>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Success<ConsentPostCommandResult>(new ConsentPostRedirectResult("/return?consent=denied")));

var model = CreateModel(mediator, new DefaultHttpContext());
model.Input = new SecurityService.Pages.Consent.IndexModel.InputModel
{
ReturnUrl = "/return",
Button = "deny",
SelectedScopes = []
};

var result = await model.OnPostAsync(CancellationToken.None);

var redirect = result.ShouldBeOfType<RedirectResult>();
redirect.Url.ShouldBe("/return?consent=denied");
}

[Fact]
public async Task OnPostAsync_WhenHandlerReturnsPageWithError_AddsModelErrorAndReturnsPage()
{
var mediator = new Mock<IMediator>();
mediator.Setup(m => m.Send(It.IsAny<OidcCommands.ConsentPostCommand>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Success<ConsentPostCommandResult>(new ConsentPostPageResult("Select at least one scope to continue.")));

var model = CreateModel(mediator, new DefaultHttpContext());
model.Input = new SecurityService.Pages.Consent.IndexModel.InputModel
{
ReturnUrl = "/return",
Button = "accept",
SelectedScopes = []
};

var result = await model.OnPostAsync(CancellationToken.None);

result.ShouldBeOfType<PageResult>();
model.ModelState[string.Empty]!.Errors.ShouldContain(e => e.ErrorMessage == "Select at least one scope to continue.");
}

[Fact]
public async Task OnPostAsync_SendsCommandWithCorrectValues()
{
OidcCommands.ConsentPostCommand? capturedCommand = null;

var mediator = new Mock<IMediator>();
mediator.Setup(m => m.Send(It.IsAny<OidcCommands.ConsentPostCommand>(), It.IsAny<CancellationToken>()))
.Callback<IRequest<Result<ConsentPostCommandResult>>, CancellationToken>((req, _) =>
{
capturedCommand = req.ShouldBeOfType<OidcCommands.ConsentPostCommand>();
})
.ReturnsAsync(Result.Success<ConsentPostCommandResult>(new ConsentPostRedirectResult("/return?consent=accepted")));

var model = CreateModel(mediator, new DefaultHttpContext());
model.Input = new SecurityService.Pages.Consent.IndexModel.InputModel
{
ReturnUrl = "/return",
Button = "accept",
SelectedScopes = ["openid", "profile"]
};

await model.OnPostAsync(CancellationToken.None);

capturedCommand.ShouldNotBeNull();
capturedCommand.ReturnUrl.ShouldBe("/return");
capturedCommand.Button.ShouldBe("accept");
capturedCommand.SelectedScopes.ShouldContain("openid");
capturedCommand.SelectedScopes.ShouldContain("profile");
}

private static SecurityService.Pages.Consent.IndexModel CreateModel(Mock<IMediator> mediator, HttpContext httpContext)
{
return new SecurityService.Pages.Consent.IndexModel(mediator.Object)
{
PageContext = new PageContext
{
HttpContext = httpContext
}
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using OpenIddict.Abstractions;
using SecurityService.BusinessLogic.Oidc;
using SecurityService.BusinessLogic.RequestHandlers;
using SecurityService.Database.DbContexts;
using SecurityService.UnitTests.Infrastructure;
using Shouldly;

namespace SecurityService.UnitTests.RequestHandlers;

public class ConsentRequestHandlerTests
{
[Fact]
public async Task ConsentGetQuery_WhenNoOpenIddictServerRequest_ReturnsLocalRedirect()
{
var appManager = new Mock<IOpenIddictApplicationManager>();
using var serviceProvider = TestServiceProviderFactory.Create(nameof(ConsentGetQuery_WhenNoOpenIddictServerRequest_ReturnsLocalRedirect));
using var scope = serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<SecurityServiceDbContext>();

var handler = new ConsentRequestHandler(appManager.Object, dbContext);
var context = new DefaultHttpContext();

var result = await handler.Handle(new OidcCommands.ConsentGetQuery(context, "/return"), CancellationToken.None);

result.IsSuccess.ShouldBeTrue();
var redirect = result.Data.ShouldBeOfType<ConsentGetLocalRedirectResult>();
redirect.Url.ShouldBe("/return");
}

[Fact]
public async Task ConsentPostCommand_WhenDenyButton_ReturnsRedirectWithDenied()
{
var appManager = new Mock<IOpenIddictApplicationManager>();
using var serviceProvider = TestServiceProviderFactory.Create(nameof(ConsentPostCommand_WhenDenyButton_ReturnsRedirectWithDenied));
using var scope = serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<SecurityServiceDbContext>();

var handler = new ConsentRequestHandler(appManager.Object, dbContext);

var result = await handler.Handle(
new OidcCommands.ConsentPostCommand("/return", "deny", Array.Empty<string>()),
CancellationToken.None);

result.IsSuccess.ShouldBeTrue();
var redirect = result.Data.ShouldBeOfType<ConsentPostRedirectResult>();
redirect.Url.ShouldContain("consent=denied");
}

[Fact]
public async Task ConsentPostCommand_WhenNoScopesSelected_ReturnsPageWithError()
{
var appManager = new Mock<IOpenIddictApplicationManager>();
using var serviceProvider = TestServiceProviderFactory.Create(nameof(ConsentPostCommand_WhenNoScopesSelected_ReturnsPageWithError));
using var scope = serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<SecurityServiceDbContext>();

var handler = new ConsentRequestHandler(appManager.Object, dbContext);

var result = await handler.Handle(
new OidcCommands.ConsentPostCommand("/return", "accept", Array.Empty<string>()),
CancellationToken.None);

result.IsSuccess.ShouldBeTrue();
var page = result.Data.ShouldBeOfType<ConsentPostPageResult>();
page.ModelError.ShouldBe("Select at least one scope to continue.");
}

[Fact]
public async Task ConsentPostCommand_WhenScopesSelected_ReturnsRedirectWithAccepted()
{
var appManager = new Mock<IOpenIddictApplicationManager>();
using var serviceProvider = TestServiceProviderFactory.Create(nameof(ConsentPostCommand_WhenScopesSelected_ReturnsRedirectWithAccepted));
using var scope = serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<SecurityServiceDbContext>();

var handler = new ConsentRequestHandler(appManager.Object, dbContext);

var result = await handler.Handle(
new OidcCommands.ConsentPostCommand("/return", "accept", ["openid", "profile"]),
CancellationToken.None);

result.IsSuccess.ShouldBeTrue();
var redirect = result.Data.ShouldBeOfType<ConsentPostRedirectResult>();
redirect.Url.ShouldContain("consent=accepted");
redirect.Url.ShouldContain("granted_scope=openid");
redirect.Url.ShouldContain("granted_scope=profile");
}
}
Loading
Loading