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
13 changes: 13 additions & 0 deletions SecurityService.BusinessLogic/Oidc/OidcCommands.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using MediatR;
using Microsoft.AspNetCore.Http;
using SimpleResults;

namespace SecurityService.BusinessLogic.Oidc;

public static class OidcCommands
{
public sealed record AuthorizeCommand(HttpContext HttpContext) : IRequest<Result<AuthorizeCommandResult>>;
public sealed record TokenCommand(HttpContext HttpContext) : IRequest<Result<TokenCommandResult>>;
public sealed record LogoutCommand(HttpContext HttpContext) : IRequest<Result<LogoutCommandResult>>;
public sealed record UserInfoCommand(HttpContext HttpContext) : IRequest<Result<UserInfoCommandResult>>;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Primitives;
Expand All @@ -9,7 +10,7 @@
using SecurityService.Database.Entities;
using static OpenIddict.Abstractions.OpenIddictConstants;

namespace SecurityService.Oidc;
namespace SecurityService.BusinessLogic.Oidc;

public static class OidcHelpers
{
Expand Down Expand Up @@ -38,7 +39,7 @@ public static string AppendQueryValues(string url, string name, IEnumerable<stri
public static IReadOnlyCollection<string> ReadMultiValue(IQueryCollection query, string key)
=> query.TryGetValue(key, out StringValues values) ? values.Where(value => value is not null).Cast<string>().ToArray() : Array.Empty<string>();

public static async Task<ClaimsPrincipal> CreatePrincipalAsync(
public static async Task<ClaimsPrincipal> CreatePrincipal(
ApplicationUser user,
UserManager<ApplicationUser> userManager,
IEnumerable<string> scopes,
Expand Down Expand Up @@ -116,15 +117,15 @@ public static IEnumerable<string> GetDestinations(Claim claim)
};
}

public static async Task<(IReadOnlyCollection<ScopeDisplayItem> IdentityScopes, IReadOnlyCollection<ScopeDisplayItem> ApiScopes)> BuildScopeDisplayAsync(
public static async Task<(IReadOnlyCollection<ScopeDisplayItem> IdentityScopes, IReadOnlyCollection<ScopeDisplayItem> 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<ScopeDisplayItem> IdentityScopes, IReadOnlyCollection<ScopeDisplayItem> ApiScopes)> BuildScopeDisplayAsync(
public static async Task<(IReadOnlyCollection<ScopeDisplayItem> IdentityScopes, IReadOnlyCollection<ScopeDisplayItem> ApiScopes)> BuildScopeDisplay(
IEnumerable<string> scopeNames,
SecurityServiceDbContext dbContext,
CancellationToken cancellationToken)
Expand Down Expand Up @@ -156,6 +157,25 @@ public static IEnumerable<string> GetDestinations(Claim claim)

return (identityScopes, apiScopes);
}

public static async Task<IReadOnlyCollection<string>> ResolveClientCredentialsScopes(
OpenIddictRequest request,
SecurityServiceDbContext dbContext,
CancellationToken cancellationToken)
{
IReadOnlyCollection<string> 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<string>()
: JsonListSerializer.Deserialize(clientDefinition.AllowedScopesJson);
}
}

public sealed record ScopeDisplayItem(string Name, string DisplayName, string? Description, bool Required, bool Emphasize);
357 changes: 357 additions & 0 deletions SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions SecurityService.BusinessLogic/Oidc/OidcResults.cs
Original file line number Diff line number Diff line change
@@ -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<string> AuthenticationSchemes) : AuthorizeCommandResult;

public sealed record AuthorizeChallengeResult(AuthenticationProperties Properties, IList<string> 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<string> AuthenticationSchemes) : TokenCommandResult;

public sealed record TokenBadRequestResult(object Error) : TokenCommandResult;

// ---- Logout endpoint ----

public abstract record LogoutCommandResult;

public sealed record LogoutSignOutResult(AuthenticationProperties Properties, IList<string> AuthenticationSchemes) : LogoutCommandResult;

public sealed record LogoutRedirectResult(string Url) : LogoutCommandResult;

// ---- UserInfo endpoint ----

public abstract record UserInfoCommandResult;

public sealed record UserInfoJsonResult(Dictionary<string, object?> Data) : UserInfoCommandResult;

public sealed record UserInfoChallengeResult(AuthenticationProperties Properties, IList<string> AuthenticationSchemes) : UserInfoCommandResult;
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
<PackageReference Include="OpenIddict.Abstractions" Version="7.3.0" />
<PackageReference Include="OpenIddict.Server" Version="7.3.0" />
<PackageReference Include="OpenIddict.Server.AspNetCore" Version="7.3.0" />
<PackageReference Include="OpenIddict.Validation.AspNetCore" Version="7.3.0" />
<PackageReference Include="Shared.Results" Version="2026.3.1" />
<PackageReference Include="SimpleResults" Version="4.0.0" />
</ItemGroup>
Expand Down
50 changes: 31 additions & 19 deletions SecurityService.UnitTests/Oidc/OidcEndpointTests.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
using System.Security.Claims;
using System.Text;
using MediatR;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
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;
using SecurityService.Oidc;
using SecurityService.UnitTests.Infrastructure;
using Shouldly;

Expand Down Expand Up @@ -44,27 +44,39 @@ public async Task UserInfoAsync_WithValidPrincipal_ReturnsJsonPayload()

var context = new DefaultHttpContext
{
RequestServices = services.BuildServiceProvider(),
Response =
{
Body = new MemoryStream()
}
RequestServices = services.BuildServiceProvider()
};

var result = await OidcEndpoints.UserInfoAsync(context, userManager.Object);
await result.ExecuteAsync(context);
var signInManager = IdentityMocks.CreateSignInManager(userManager);
var appManager = new Mock<OpenIddict.Abstractions.IOpenIddictApplicationManager>();
var authManager = new Mock<OpenIddict.Abstractions.IOpenIddictAuthorizationManager>();
var scopeManager = new Mock<OpenIddict.Abstractions.IOpenIddictScopeManager>();
using var provider = TestServiceProviderFactory.Create(nameof(this.UserInfoAsync_WithValidPrincipal_ReturnsJsonPayload));
using var scope = provider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<SecurityServiceDbContext>();

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\"");
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);

result.IsSuccess.ShouldBeTrue();
var jsonResult = result.Data.ShouldBeOfType<UserInfoJsonResult>();
jsonResult.Data.ShouldContainKey("sub");
jsonResult.Data["sub"].ShouldBe("user-1");
jsonResult.Data.ShouldContainKey("email");
jsonResult.Data["email"].ShouldBe("alice@example.com");
}

[Fact]
public async Task TokenAsync_ClientCredentialsWithoutRequestedScopes_FallsBackToConfiguredClientScopes()
public async Task ResolveClientCredentialsScopes_WithoutRequestedScopes_FallsBackToConfiguredClientScopes()
{
using var provider = TestServiceProviderFactory.Create(nameof(this.TokenAsync_ClientCredentialsWithoutRequestedScopes_FallsBackToConfiguredClientScopes));
using var provider = TestServiceProviderFactory.Create(nameof(this.ResolveClientCredentialsScopes_WithoutRequestedScopes_FallsBackToConfiguredClientScopes));
using var scope = provider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<SecurityServiceDbContext>();

Expand All @@ -88,7 +100,7 @@ public async Task TokenAsync_ClientCredentialsWithoutRequestedScopes_FallsBackTo
ClientId = "serviceClient"
};

var scopes = await OidcEndpoints.ResolveClientCredentialsScopesAsync(
var scopes = await OidcHelpers.ResolveClientCredentialsScopes(
request,
dbContext,
CancellationToken.None);
Expand All @@ -97,7 +109,7 @@ public async Task TokenAsync_ClientCredentialsWithoutRequestedScopes_FallsBackTo
}

[Fact]
public async Task CreatePrincipalAsync_WhenUserClaimsDuplicateBuiltInClaims_DoesNotDuplicateValues()
public async Task CreatePrincipal_WhenUserClaimsDuplicateBuiltInClaims_DoesNotDuplicateValues()
{
var userManager = IdentityMocks.CreateUserManager();
var user = new ApplicationUser
Expand All @@ -118,7 +130,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],
Expand Down
105 changes: 105 additions & 0 deletions SecurityService/Factories/OidcResponseFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +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<T>(Result<T> result)
{
if (result.IsSuccess == false) {
return TranslateResultStatus(result);
}

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)
};
}

internal static IResult TranslateResultStatus(ResultBase result)
{
ErrorResponse errorResponse = new()
{
Errors = result.Errors.Any() switch
{
true => result.Errors.ToList(),
_ => [result.Message],
}
};

return result.Status switch
{
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<TokenCommandResult> result)
//{

Check notice on line 61 in SecurityService/Factories/OidcResponseFactory.cs

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

SecurityService/Factories/OidcResponseFactory.cs#L61

Remove this commented out code.
// if (result.IsSuccess == false)
// {
// return TranslateResultStatus(result);
// }

// 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<LogoutCommandResult> result)
//{
// if (result.IsSuccess == false)
// {
// return TranslateResultStatus(result);
// }

// 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<UserInfoCommandResult> 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)
// };
//}
}
11 changes: 11 additions & 0 deletions SecurityService/Oidc/Handlers/AuthorizeHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using MediatR;
using SecurityService.BusinessLogic.Oidc;
using SecurityService.Factories;

namespace SecurityService.Oidc.Handlers;

public static class AuthorizeHandler
{
public static async Task<IResult> AuthorizeAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken)
=> OidcResponseFactory.FromResult(await mediator.Send(new OidcCommands.AuthorizeCommand(context), cancellationToken));
}
11 changes: 11 additions & 0 deletions SecurityService/Oidc/Handlers/LogoutHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using MediatR;
using SecurityService.BusinessLogic.Oidc;
using SecurityService.Factories;

namespace SecurityService.Oidc.Handlers;

public static class LogoutHandler
{
public static async Task<IResult> LogoutAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken)
=> OidcResponseFactory.FromResult(await mediator.Send(new OidcCommands.LogoutCommand(context), cancellationToken));
}
11 changes: 11 additions & 0 deletions SecurityService/Oidc/Handlers/TokenHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using MediatR;
using SecurityService.BusinessLogic.Oidc;
using SecurityService.Factories;

namespace SecurityService.Oidc.Handlers;

public static class TokenHandler
{
public static async Task<IResult> TokenAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken)
=> OidcResponseFactory.FromResult(await mediator.Send(new OidcCommands.TokenCommand(context), cancellationToken));
}
11 changes: 11 additions & 0 deletions SecurityService/Oidc/Handlers/UserInfoHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using MediatR;
using SecurityService.BusinessLogic.Oidc;
using SecurityService.Factories;

namespace SecurityService.Oidc.Handlers;

public static class UserInfoHandler
{
public static async Task<IResult> UserInfoAsync(HttpContext context, IMediator mediator, CancellationToken cancellationToken)
=> OidcResponseFactory.FromResult(await mediator.Send(new OidcCommands.UserInfoCommand(context), cancellationToken));
}
Loading
Loading