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
3 changes: 2 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.12" />
<PackageVersion Include="ClientProxyBase" Version="2026.9.4" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.Extensions" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.12" />
<PackageVersion Include="Reqnroll" Version="3.3.4" />
<PackageVersion Include="Reqnroll.Tools.MsBuild.Generation" Version="3.3.4" />
Expand All @@ -54,4 +55,4 @@
<PackageVersion Include="Selenium.WebDriver" Version="4.49.0" />
<PackageVersion Include="Reqnroll.NUnit" Version="3.3.4" />
</ItemGroup>
</Project>
</Project>
48 changes: 48 additions & 0 deletions SecurityService.BusinessLogic/Oidc/ConsentTransactionProtector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Text.Json;
using System.Security.Cryptography;
using Microsoft.AspNetCore.DataProtection;

namespace SecurityService.BusinessLogic.Oidc;

public sealed record ConsentTransaction(
string UserId,
string AuthorizationUrl,
string ClientId,
IReadOnlyCollection<string> RequestedScopes,
DateTimeOffset ExpiresAt);

public sealed class ConsentTransactionProtector
{
private readonly IDataProtector _protector;

public ConsentTransactionProtector(IDataProtector protector)
{
this._protector = protector;
}

public string Protect(ConsentTransaction transaction)
{
ArgumentNullException.ThrowIfNull(transaction);
return this._protector.Protect(JsonSerializer.Serialize(transaction));
}

public ConsentTransaction? Unprotect(string? token, DateTimeOffset now)
{
if (string.IsNullOrWhiteSpace(token))
{
return null;
}

try
{
var transaction = JsonSerializer.Deserialize<ConsentTransaction>(this._protector.Unprotect(token));
return transaction is { ExpiresAt: var expiresAt } && expiresAt > now
? transaction
: null;
}
catch (Exception exception) when (exception is FormatException or JsonException or CryptographicException)
{
return null;
}
}
}
4 changes: 2 additions & 2 deletions SecurityService.BusinessLogic/Oidc/OidcCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ 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>>;
public sealed record ConsentGetQuery(HttpContext HttpContext, string TransactionId) : IRequest<Result<ConsentGetQueryResult>>;
public sealed record ConsentPostCommand(HttpContext HttpContext, string TransactionId, string Button, IReadOnlyCollection<string> SelectedScopes) : IRequest<Result<ConsentPostCommandResult>>;
public sealed record DiagnosticsQuery(HttpContext HttpContext) : IRequest<Result<DiagnosticsQueryResult>>;
}
45 changes: 43 additions & 2 deletions SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Abstractions;
Expand All @@ -29,21 +30,24 @@ public sealed class OidcRequestHandler :
private readonly IOpenIddictAuthorizationManager _authorizationManager;
private readonly IOpenIddictScopeManager _scopeManager;
private readonly SecurityServiceDbContext _dbContext;
private readonly ConsentTransactionProtector _consentTransactionProtector;

public OidcRequestHandler(
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
IOpenIddictApplicationManager applicationManager,
IOpenIddictAuthorizationManager authorizationManager,
IOpenIddictScopeManager scopeManager,
SecurityServiceDbContext dbContext)
SecurityServiceDbContext dbContext,
ConsentTransactionProtector consentTransactionProtector)
{
this._userManager = userManager;
this._signInManager = signInManager;
this._applicationManager = applicationManager;
this._authorizationManager = authorizationManager;
this._scopeManager = scopeManager;
this._dbContext = dbContext;
this._consentTransactionProtector = consentTransactionProtector;
}

public async Task<Result<AuthorizeCommandResult>> Handle(OidcCommands.AuthorizeCommand command, CancellationToken cancellationToken)
Expand Down Expand Up @@ -72,6 +76,16 @@ public async Task<Result<AuthorizeCommandResult>> Handle(OidcCommands.AuthorizeC

if (context.Request.Query.TryGetValue("consent", out var consentDecision))
{
var transaction = this._consentTransactionProtector.Unprotect(
context.Request.Query["consent_transaction"], DateTimeOffset.UtcNow);
if (transaction is null || !string.Equals(transaction.UserId, user.Id, StringComparison.Ordinal) ||
!string.Equals(transaction.ClientId, request.ClientId, StringComparison.Ordinal) ||
!this.MatchesOriginalRequest(context, transaction))
{
return Result.Success<AuthorizeCommandResult>(AuthorizeForbidServer(
Errors.InvalidRequest, "The consent request is missing, invalid, or expired."));
}

return Result.Success<AuthorizeCommandResult>(await this.HandleConsentDecision(user, request, application, context, consentDecision!, cancellationToken));
}

Expand Down Expand Up @@ -198,7 +212,13 @@ private async Task<AuthorizeCommandResult> HandleConsentType(
return AuthorizeForbidServer(Errors.ConsentRequired, "Interactive user consent is required.");
}

return new AuthorizeRedirectResult($"/Consent?returnUrl={Uri.EscapeDataString(currentRequestUrl)}");
var transaction = new ConsentTransaction(
user.Id,
currentRequestUrl,
request.ClientId!,
request.GetScopes().ToArray(),
DateTimeOffset.UtcNow.AddMinutes(10));
return new AuthorizeRedirectResult($"/Consent?transactionId={Uri.EscapeDataString(this._consentTransactionProtector.Protect(transaction))}");
}

return await this.CompleteAuthorization(user, request, application, cancellationToken, request.GetScopes());
Expand Down Expand Up @@ -337,6 +357,27 @@ private async Task<AuthorizeCommandResult> CompleteAuthorization(
return new AuthorizeSignInResult(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}

private bool MatchesOriginalRequest(HttpContext context, ConsentTransaction transaction)
{
if (!Uri.TryCreate(transaction.AuthorizationUrl, UriKind.Relative, out var originalUri) ||
!string.Equals(originalUri.AbsolutePath, context.Request.PathBase + context.Request.Path, StringComparison.Ordinal))
Comment thread
StuartFerguson marked this conversation as resolved.
Resolved
{
return false;
}

var original = QueryHelpers.ParseQuery(originalUri.Query);
foreach (var pair in original)
{
if (!context.Request.Query.TryGetValue(pair.Key, out var current) || !current.SequenceEqual(pair.Value))
{
return false;
}
}

return context.Request.Query.Keys.All(key =>
original.ContainsKey(key) || key is "consent" or "consent_transaction" or "granted_scope");
}

private static TokenForbidResult InvalidGrant(string description = "The username/password couple is invalid.") =>
new TokenForbidResult(
new AuthenticationProperties(new Dictionary<string, string?>
Expand Down
4 changes: 3 additions & 1 deletion SecurityService.BusinessLogic/Oidc/OidcResults.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,16 @@ public sealed record ConsentGetPageResult(
IReadOnlyCollection<ScopeDisplayItem> IdentityScopes,
IReadOnlyCollection<ScopeDisplayItem> ApiScopes) : ConsentGetQueryResult;

public sealed record ConsentGetLocalRedirectResult(string Url) : ConsentGetQueryResult;
public sealed record ConsentGetInvalidResult(string Message) : ConsentGetQueryResult;

public abstract record ConsentPostCommandResult;

public sealed record ConsentPostRedirectResult(string Url) : ConsentPostCommandResult;

public sealed record ConsentPostPageResult(string ModelError) : ConsentPostCommandResult;

public sealed record ConsentPostInvalidResult(string Message) : ConsentPostCommandResult;

// ---- Diagnostics endpoint ----

public abstract record DiagnosticsQueryResult;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using MediatR;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using OpenIddict.Abstractions;
using SecurityService.BusinessLogic.Oidc;
using SecurityService.Database.DbContexts;
Expand All @@ -14,48 +16,80 @@ public sealed class ConsentRequestHandler :
{
private readonly IOpenIddictApplicationManager ApplicationManager;
private readonly SecurityServiceDbContext DbContext;
private readonly ConsentTransactionProtector TransactionProtector;

public ConsentRequestHandler(IOpenIddictApplicationManager applicationManager,
SecurityServiceDbContext dbContext)
SecurityServiceDbContext dbContext,
ConsentTransactionProtector transactionProtector)
{
this.ApplicationManager = applicationManager;
this.DbContext = dbContext;
this.TransactionProtector = transactionProtector;
}

public async Task<Result<ConsentGetQueryResult>> Handle(OidcCommands.ConsentGetQuery query, CancellationToken cancellationToken)
{
var request = query.HttpContext.GetOpenIddictServerRequest();
if (request is null)
var transaction = this.TransactionProtector.Unprotect(query.TransactionId, DateTimeOffset.UtcNow);
var userId = await GetUserId(query.HttpContext);
if (transaction is null || userId is null || !string.Equals(transaction.UserId, userId, StringComparison.Ordinal))
{
return Result.Success<ConsentGetQueryResult>(new ConsentGetLocalRedirectResult(query.ReturnUrl));
return Result.Success<ConsentGetQueryResult>(new ConsentGetInvalidResult("The consent request is missing, invalid, or expired."));
}

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

var scopes = await OidcHelpers.BuildScopeDisplay(request, this.DbContext, cancellationToken);
var scopes = await OidcHelpers.BuildScopeDisplay(transaction.RequestedScopes, this.DbContext, cancellationToken);

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

public Task<Result<ConsentPostCommandResult>> Handle(OidcCommands.ConsentPostCommand command, CancellationToken cancellationToken)
public async Task<Result<ConsentPostCommandResult>> Handle(OidcCommands.ConsentPostCommand command, CancellationToken cancellationToken)
{
var transaction = this.TransactionProtector.Unprotect(command.TransactionId, DateTimeOffset.UtcNow);
var userId = await GetUserId(command.HttpContext);
if (transaction is null || userId is null || !string.Equals(transaction.UserId, userId, StringComparison.Ordinal))
{
return Result.Success<ConsentPostCommandResult>(new ConsentPostInvalidResult("The consent request is missing, invalid, or expired."));
}

if (string.Equals(command.Button, "deny", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(Result.Success<ConsentPostCommandResult>(
new ConsentPostRedirectResult(OidcHelpers.AppendQueryValue(command.ReturnUrl, "consent", "denied"))));
return Result.Success<ConsentPostCommandResult>(
new ConsentPostRedirectResult(this.BuildDecisionUrl(transaction, "denied", Array.Empty<string>())));
}

var grantedScopes = command.SelectedScopes
.Where(scope => transaction.RequestedScopes.Contains(scope, StringComparer.Ordinal))
.Distinct(StringComparer.Ordinal)
.ToArray();
if (grantedScopes.Length == 0)
{
return Result.Success<ConsentPostCommandResult>(
new ConsentPostPageResult("Select at least one scope to continue."));
}

if (command.SelectedScopes.Count == 0)
return Result.Success<ConsentPostCommandResult>(new ConsentPostRedirectResult(
this.BuildDecisionUrl(transaction, "accepted", grantedScopes)));
}

private string BuildDecisionUrl(ConsentTransaction transaction, string decision, IEnumerable<string> grantedScopes)
{
var redirectUrl = OidcHelpers.AppendQueryValue(transaction.AuthorizationUrl, "consent", decision);
redirectUrl = OidcHelpers.AppendQueryValue(redirectUrl, "consent_transaction", this.TransactionProtector.Protect(transaction));
return OidcHelpers.AppendQueryValues(redirectUrl, "granted_scope", grantedScopes);
}

private static async Task<string?> GetUserId(HttpContext context)
{
if (context.RequestServices is null)
{
return Task.FromResult(Result.Success<ConsentPostCommandResult>(
new ConsentPostPageResult("Select at least one scope to continue.")));
return null;
}

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)));
var result = await context.AuthenticateAsync(IdentityConstants.ApplicationScheme);
return result.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<PackageReference Include="MediatR" />
<PackageReference Include="MessagingService.Client" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.Extensions" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="OpenIddict.Abstractions" />
<PackageReference Include="OpenIddict.Server" />
Expand Down
50 changes: 50 additions & 0 deletions SecurityService.UnitTests/Oidc/ConsentTransactionProtectorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using Microsoft.AspNetCore.DataProtection;
using SecurityService.BusinessLogic.Oidc;
using Shouldly;

namespace SecurityService.UnitTests.Oidc;

public sealed class ConsentTransactionProtectorTests
{
[Fact]
public void ProtectAndUnprotect_RoundTripsTransaction()
{
var protector = new ConsentTransactionProtector(
new EphemeralDataProtectionProvider().CreateProtector("consent"));
var transaction = new ConsentTransaction(
UserId: "user-1",
AuthorizationUrl: "/connect/authorize?client_id=client&scope=openid%20profile",
ClientId: "client",
RequestedScopes: ["openid", "profile"],
ExpiresAt: DateTimeOffset.UtcNow.AddMinutes(5));

var token = protector.Protect(transaction);
var restored = protector.Unprotect(token, DateTimeOffset.UtcNow);

restored.ShouldNotBeNull();
restored.UserId.ShouldBe("user-1");
restored.AuthorizationUrl.ShouldBe(transaction.AuthorizationUrl);
restored.ClientId.ShouldBe("client");
restored.RequestedScopes.ShouldBe(["openid", "profile"]);
}

[Fact]
public void Unprotect_WhenTokenIsInvalid_ReturnsNull()
{
var protector = new ConsentTransactionProtector(
new EphemeralDataProtectionProvider().CreateProtector("consent"));

protector.Unprotect("not-a-consent-token", DateTimeOffset.UtcNow).ShouldBeNull();
}

[Fact]
public void Unprotect_WhenTransactionIsExpired_ReturnsNull()
{
var protector = new ConsentTransactionProtector(
new EphemeralDataProtectionProvider().CreateProtector("consent"));
var token = protector.Protect(new ConsentTransaction(
"user-1", "/connect/authorize", "client", ["openid"], DateTimeOffset.UtcNow.AddMinutes(-1)));

protector.Unprotect(token, DateTimeOffset.UtcNow).ShouldBeNull();
}
}
4 changes: 3 additions & 1 deletion SecurityService.UnitTests/Oidc/OidcEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.DataProtection;
using Imposter.Abstractions;
using OpenIddict.Validation.AspNetCore;
using SecurityService.BusinessLogic.Oidc;
Expand Down Expand Up @@ -61,7 +62,8 @@ public async Task UserInfoAsync_WithValidPrincipal_ReturnsJsonPayload()
appManager.Instance(),
authManager.Instance(),
scopeManager.Instance(),
dbContext);
dbContext,
new ConsentTransactionProtector(new EphemeralDataProtectionProvider().CreateProtector("consent")));

var result = await handler.Handle(new OidcCommands.UserInfoCommand(context), CancellationToken.None);

Expand Down
Loading
Loading