From 268135d15e680eeababc377b075ad5d2a022c3dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 11 Oct 2026 22:54:59 +0300 Subject: [PATCH 1/5] fix(server): map invalid JSON requests to validation errors --- .../HttpContext/HttpContextJsonExtensions.cs | 16 +- .../HttpContextJsonContractTests.cs | 208 ++++++++++++++++++ 2 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/HttpContextJsonContractTests.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs index 262f8aee..6a528849 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using CodeBeam.UltimateAuth.Core.Errors; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Extensions; @@ -15,10 +16,10 @@ public static async Task ReadJsonAsync(this HttpContext ctx, CancellationT var request = ctx.Request; if (!request.HasJsonContentType()) - throw new InvalidOperationException("Request content type must be application/json."); + throw new UAuthValidationException("request_content_type_invalid"); if (request.Body == null || request.ContentLength == 0) - throw new InvalidOperationException("Request body is empty."); + throw new UAuthValidationException("request_body_required"); request.EnableBuffering(); @@ -28,16 +29,19 @@ public static async Task ReadJsonAsync(this HttpContext ctx, CancellationT { var result = await JsonSerializer.DeserializeAsync(request.Body, JsonOptions, ct); - request.Body.Position = 0; - if (result == null) - throw new InvalidOperationException("Request body could not be deserialized."); + throw new UAuthValidationException("request_body_invalid"); return result; } catch (JsonException) { - throw new InvalidOperationException("Invalid JSON"); + throw new UAuthValidationException("request_json_invalid"); + } + finally + { + if (request.Body.CanSeek) + request.Body.Position = 0; } } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/HttpContextJsonContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/HttpContextJsonContractTests.cs new file mode 100644 index 00000000..3711c0da --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/HttpContextJsonContractTests.cs @@ -0,0 +1,208 @@ +using CodeBeam.UltimateAuth.Server.Extensions; +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.TestHost; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public sealed class HttpContextJsonContractTests +{ + private const string Endpoint = "/test/json-contract"; + + public static TheoryData InvalidJsonRequests => new() + { + { + "text/plain", + """{"name":"test"}""", + "request_content_type_invalid" + }, + { + "application/json", + "", + "request_body_required" + }, + { + "application/json", + "null", + "request_body_invalid" + }, + { + "application/json", + "{", + "request_json_invalid" + }, + { + "application/json", + "{}", + "request_json_invalid" + } + }; + + [Theory] + [MemberData(nameof(InvalidJsonRequests))] + public async Task InvalidJsonRequest_ShouldReturn400ProblemDetails( + string contentType, + string body, + string expectedCode) + { + await using var host = await JsonTestHost.CreateAsync(); + + using var request = new HttpRequestMessage( + HttpMethod.Post, + Endpoint); + + request.Content = new StringContent( + body, + Encoding.UTF8); + + request.Content.Headers.ContentType = + MediaTypeHeaderValue.Parse(contentType); + + using var response = await host.Client.SendAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + response.Content.Headers.ContentType! + .MediaType.Should().Be("application/problem+json"); + + var problem = await response.Content + .ReadFromJsonAsync(); + + problem.Should().NotBeNull(); + problem!.Status.Should().Be(400); + problem.Detail.Should().Be(expectedCode); + problem.Type.Should().Contain(expectedCode); + problem.Extensions.Should().ContainKey("traceId"); + } + + [Fact] + public async Task ValidJsonRequest_ShouldReturn200WithTypedResult() + { + await using var host = await JsonTestHost.CreateAsync(); + + using var response = await host.Client.PostAsJsonAsync( + Endpoint, + new { name = "UltimateAuth" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content + .ReadFromJsonAsync(); + + result.Should().NotBeNull(); + result!.Name.Should().Be("UltimateAuth"); + } + + [Fact] + public async Task MissingContentType_ShouldReturn400ProblemDetails() + { + await using var host = await JsonTestHost.CreateAsync(); + + using var request = new HttpRequestMessage( + HttpMethod.Post, + Endpoint) + { + Content = new ByteArrayContent( + Encoding.UTF8.GetBytes("""{"name":"test"}""")) + }; + + using var response = await host.Client.SendAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var problem = await response.Content + .ReadFromJsonAsync(); + + problem!.Detail.Should().Be("request_content_type_invalid"); + } + + [Fact] + public async Task JsonReader_ShouldRewindBodyAfterSuccess() + { + await using var host = await JsonTestHost.CreateAsync(); + + using var response = await host.Client.PostAsJsonAsync( + "/test/json-rewind", + new { name = "UltimateAuth" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content + .ReadFromJsonAsync(); + + result.Should().NotBeNull(); + result!.Name.Should().Be("UltimateAuth"); + result.BodyStartsWithJson.Should().BeTrue(); + } + + private sealed class JsonTestHost : IAsyncDisposable + { + private readonly WebApplication _app; + + public HttpClient Client { get; } + + private JsonTestHost(WebApplication app) + { + _app = app; + Client = app.GetTestClient(); + } + + public static async Task CreateAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + + app.UseUAuthExceptionHandling(); + + app.MapPost(Endpoint, async (HttpContext ctx) => + { + var request = await ctx.ReadJsonAsync( + ctx.RequestAborted); + + return Results.Ok(new JsonTestResult(request.Name)); + }); + + app.MapPost("/test/json-rewind", async (HttpContext ctx) => + { + var request = await ctx.ReadJsonAsync( + ctx.RequestAborted); + + var firstByte = ctx.Request.Body.ReadByte(); + + return Results.Ok(new BodyRewindResult( + request.Name, + firstByte == '{')); + }); + + await app.StartAsync(); + + return new JsonTestHost(app); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + + private sealed class JsonTestRequest + { + public required string Name { get; init; } + } + + private sealed record JsonTestResult(string Name); + + private sealed record BodyRewindResult( + string Name, + bool BodyStartsWithJson); +} From 07a746d9a8de6ab6e6aba2150b720efa7d65740f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 11 Oct 2026 23:08:09 +0300 Subject: [PATCH 2/5] fix(server): normalize invalid request values to validation errors --- .../HttpContext/HttpContextJsonExtensions.cs | 5 ++ .../Redirect/ReturnUrlParser.cs | 8 +-- .../Services/PkceService.cs | 5 +- .../HttpContextJsonContractTests.cs | 54 ++++++++++++++++++- .../Server/ReturnUrlParserTests.cs | 5 +- 5 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs index 6a528849..abefa272 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs @@ -38,6 +38,11 @@ public static async Task ReadJsonAsync(this HttpContext ctx, CancellationT { throw new UAuthValidationException("request_json_invalid"); } + // TODO: Remove this catch block in the future, as it is a workaround. + catch (ArgumentException ex) when (ex.ParamName == "value" && ex.Message.Contains("permission_required", StringComparison.Ordinal)) + { + throw new UAuthValidationException("permission_required"); + } finally { if (request.Body.CanSeek) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlParser.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlParser.cs index ba204e7a..2126605d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlParser.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlParser.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Core.Errors; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; internal static class ReturnUrlParser { @@ -22,8 +24,8 @@ public static ReturnUrlInfo Parse(string? returnUrl) } if (returnUrl.StartsWith("//", StringComparison.Ordinal)) - throw new InvalidOperationException("Invalid returnUrl."); + throw new UAuthValidationException("return_url_invalid"); - throw new InvalidOperationException("Invalid returnUrl."); + throw new UAuthValidationException("return_url_invalid"); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs b/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs index 44b15f1e..b14bccad 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Flows; using CodeBeam.UltimateAuth.Server.Options; @@ -31,10 +32,10 @@ public PkceService(IAuthStore authStore, IPkceAuthorizationValidator validator, public async Task AuthorizeAsync(PkceAuthorizeCommand command, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(command.CodeChallenge)) - throw new InvalidOperationException("code_challenge is required."); + throw new UAuthValidationException("code_challenge is required."); if (!string.Equals(command.ChallengeMethod, "S256", StringComparison.Ordinal)) - throw new InvalidOperationException("Only S256 supported."); + throw new UAuthValidationException("Only S256 supported."); var authorizationCode = AuthArtifactKey.New(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/HttpContextJsonContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/HttpContextJsonContractTests.cs index 3711c0da..c43bdac8 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/HttpContextJsonContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/HttpContextJsonContractTests.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Server.Extensions; using FluentAssertions; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -141,6 +142,41 @@ public async Task JsonReader_ShouldRewindBodyAfterSuccess() result.BodyStartsWithJson.Should().BeTrue(); } + [Fact] + public async Task InvalidPermission_ShouldReturn400ProblemDetails() + { + await using var host = await JsonTestHost.CreateAsync(); + + using var content = new StringContent( + """ + { + "name": "TestRole", + "permissions": [ + { "value": "" } + ] + } + """, + Encoding.UTF8, + "application/json"); + + using var response = await host.Client.PostAsync( + "/test/json-permission", + content); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + response.Content.Headers.ContentType! + .MediaType.Should().Be("application/problem+json"); + + var problem = await response.Content + .ReadFromJsonAsync(); + + problem.Should().NotBeNull(); + problem!.Status.Should().Be(400); + problem.Detail.Should().Be("permission_required"); + } + + private sealed class JsonTestHost : IAsyncDisposable { private readonly WebApplication _app; @@ -182,6 +218,15 @@ public static async Task CreateAsync() firstByte == '{')); }); + + app.MapPost("/test/json-permission", async (HttpContext ctx) => + { + var request = await ctx.ReadJsonAsync( + ctx.RequestAborted); + + return Results.Ok(request.Name); + }); + await app.StartAsync(); return new JsonTestHost(app); @@ -202,6 +247,13 @@ private sealed class JsonTestRequest private sealed record JsonTestResult(string Name); + private sealed class PermissionTestRequest + { + public required string Name { get; init; } + + public required Permission[] Permissions { get; init; } + } + private sealed record BodyRewindResult( string Name, bool BodyStartsWithJson); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ReturnUrlParserTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ReturnUrlParserTests.cs index e3968775..71b34e5c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ReturnUrlParserTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ReturnUrlParserTests.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; @@ -40,6 +41,6 @@ public void Absolute_Https_Url_Is_Treated_As_Absolute() public void Parser_Rejects_Unsafe_Schemes(string returnUrl) { Action act = () => ReturnUrlParser.Parse(returnUrl); - act.Should().Throw().WithMessage("*Invalid returnUrl*"); + act.Should().Throw().Which.Code.Should().Be("return_url_invalid"); } } From 915cf390914712185598b91a828397bd4358a000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 11 Oct 2026 23:19:48 +0300 Subject: [PATCH 3/5] fix(core): unify runtime exception status codes with HTTP responses fix(server): map resource reauthentication to challenge required --- .../Errors/Runtime/UAuthConflictException.cs | 2 + .../Errors/Runtime/UAuthForbiddenException.cs | 2 + .../Errors/Runtime/UAuthNotFoundException.cs | 2 +- .../Runtime/UAuthUnauthorizedException.cs | 2 + .../UAuthResourceAccessOrchestrator.cs | 4 +- .../UAuthExceptionHandlingExtensions.cs | 34 ++++++------- .../ResourceApiAuthorizationTests.cs | 49 +++++++++++++++++++ .../Server/PkceServiceTests.cs | 25 ++++------ .../UAuthResourceAccessOrchestratorTests.cs | 40 +++++++++++++-- 9 files changed, 121 insertions(+), 39 deletions(-) diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthConflictException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthConflictException.cs index fd9de8bc..bcae50b8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthConflictException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthConflictException.cs @@ -2,6 +2,8 @@ public sealed class UAuthConflictException : UAuthRuntimeException { + public override int StatusCode => 409; + public UAuthConflictException(string code) : base(code, "A resource conflict occurred.") { } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthForbiddenException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthForbiddenException.cs index 4c2cfb76..85a65fc9 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthForbiddenException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthForbiddenException.cs @@ -2,6 +2,8 @@ public sealed class UAuthForbiddenException : UAuthRuntimeException { + public override int StatusCode => 403; + public UAuthForbiddenException(string code = "forbidden") : base(code, "Forbidden.") { } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthNotFoundException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthNotFoundException.cs index cc2468a0..4a6a6290 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthNotFoundException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthNotFoundException.cs @@ -2,7 +2,7 @@ public class UAuthNotFoundException : UAuthRuntimeException { - public override int StatusCode => 400; + public override int StatusCode => 404; public override string Title => "The resource is not found."; diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthUnauthorizedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthUnauthorizedException.cs index 77e90e64..dc86071a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthUnauthorizedException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthUnauthorizedException.cs @@ -2,6 +2,8 @@ public sealed class UAuthUnauthorizedException : UAuthRuntimeException { + public override int StatusCode => 401; + public UAuthUnauthorizedException(string code = "unauthorized") : base(code, "Unauthorized.") { } diff --git a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs index 4774e393..b7451e38 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs @@ -35,7 +35,7 @@ public async Task ExecuteAsync(AccessContext context, IAccessCommand command, Ca var decision = _authority.Decide(context, policies); if (decision.RequiresReauthentication) - throw new InvalidOperationException("Requires reauthentication."); + throw new UAuthChallengeRequiredException(); if (decision.IsDenied) throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied"); @@ -53,7 +53,7 @@ public async Task ExecuteAsync(AccessContext context, IAccessC var decision = _authority.Decide(context, policies); if (decision.RequiresReauthentication) - throw new InvalidOperationException("Requires reauthentication."); + throw new UAuthChallengeRequiredException(); if (decision.IsDenied) throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied"); diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs index 9ba89be2..503bd102 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs @@ -27,7 +27,7 @@ public static IApplicationBuilder UseUAuthExceptionHandling(this IApplicationBui private static Task WriteProblemDetails(HttpContext context, UAuthRuntimeException ex) { - var status = MapStatusCode(ex); + var status = ex.StatusCode; var problem = new ProblemDetails { @@ -45,20 +45,20 @@ private static Task WriteProblemDetails(HttpContext context, UAuthRuntimeExcepti return context.Response.WriteAsJsonAsync(problem, options: null, contentType: "application/problem+json"); } - private static int MapStatusCode(UAuthRuntimeException ex) => - ex switch - { - UAuthAuthenticationException => StatusCodes.Status401Unauthorized, - UAuthAuthorizationException => StatusCodes.Status403Forbidden, - UAuthConflictException => StatusCodes.Status409Conflict, - UAuthValidationException => StatusCodes.Status400BadRequest, - UAuthUnauthorizedException => StatusCodes.Status401Unauthorized, - UAuthForbiddenException => StatusCodes.Status403Forbidden, - UAuthNotFoundException => StatusCodes.Status404NotFound, - UAuthChallengeRequiredException => StatusCodes.Status401Unauthorized, - UAuthIdentifierNotFoundException => StatusCodes.Status404NotFound, - UAuthIdentifierConflictException => StatusCodes.Status409Conflict, - UAuthIdentifierValidationException => StatusCodes.Status400BadRequest, - _ => StatusCodes.Status400BadRequest - }; + //private static int MapStatusCode(UAuthRuntimeException ex) => + // ex switch + // { + // UAuthAuthenticationException => StatusCodes.Status401Unauthorized, + // UAuthAuthorizationException => StatusCodes.Status403Forbidden, + // UAuthConflictException => StatusCodes.Status409Conflict, + // UAuthValidationException => StatusCodes.Status400BadRequest, + // UAuthUnauthorizedException => StatusCodes.Status401Unauthorized, + // UAuthForbiddenException => StatusCodes.Status403Forbidden, + // UAuthNotFoundException => StatusCodes.Status404NotFound, + // UAuthChallengeRequiredException => StatusCodes.Status401Unauthorized, + // UAuthIdentifierNotFoundException => StatusCodes.Status404NotFound, + // UAuthIdentifierConflictException => StatusCodes.Status409Conflict, + // UAuthIdentifierValidationException => StatusCodes.Status400BadRequest, + // _ => StatusCodes.Status400BadRequest + // }; } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthorizationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthorizationTests.cs index 271e73a1..81dd27c5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthorizationTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthorizationTests.cs @@ -7,10 +7,12 @@ using CodeBeam.UltimateAuth.Policies.Abstractions; using CodeBeam.UltimateAuth.Server; using FluentAssertions; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Moq; using System.Net; using System.Net.Http.Headers; +using System.Net.Http.Json; namespace CodeBeam.UltimateAuth.Tests.Integration.ResourceApi; @@ -321,6 +323,53 @@ await CreateHostAsync( .Be("products"); } + [Fact] + public async Task PolicyEndpoint_WhenReauthenticationRequired_ShouldReturn401ProblemDetails() + { + var validator = CreateValidValidator( + ("uauth:permission", TestResourceController.ProductsRead)); + + var authority = new Mock(); + + authority + .Setup(x => x.Decide( + It.IsAny(), + It.IsAny>())) + .Returns(AccessDecision.ReauthenticationRequired()); + + var policyProvider = CreatePolicyProvider(); + + await using var host = await CreateHostAsync( + validator.Object, + authority.Object, + policyProvider.Object); + + AddSession(host.Client); + + using var response = await host.Client.GetAsync( + "/__tests/resource/products/read"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + response.Content.Headers.ContentType! + .MediaType.Should().Be("application/problem+json"); + + var problem = await response.Content + .ReadFromJsonAsync(); + + problem.Should().NotBeNull(); + problem!.Status.Should().Be(401); + problem.Detail.Should().Be("challenge_required"); + problem.Type.Should().EndWith("/challenge_required"); + + authority.Verify( + x => x.Decide( + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + private static async Task CreateHostAsync( ISessionValidator validator, IAccessAuthority authority, diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceServiceTests.cs index 7c83cf0e..7246bf2f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceServiceTests.cs @@ -3,6 +3,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Auth; @@ -32,19 +33,15 @@ public sealed class PkceServiceTests [InlineData(null)] [InlineData("")] [InlineData(" ")] - public async Task AuthorizeAsync_WhenCodeChallengeIsMissing_ShouldReject( - string? codeChallenge) + public async Task AuthorizeAsync_WhenCodeChallengeIsMissing_ShouldReject(string? codeChallenge) { var fixture = CreateFixture(); - var command = CreateAuthorizeCommand( - codeChallenge: codeChallenge!); + var command = CreateAuthorizeCommand(codeChallenge: codeChallenge!); - var act = () => - fixture.Sut.AuthorizeAsync(command); + var act = () => fixture.Sut.AuthorizeAsync(command); - await act.Should() - .ThrowAsync(); + await act.Should().ThrowAsync(); fixture.Store.Verify( x => x.StoreAsync( @@ -59,19 +56,15 @@ await act.Should() [InlineData("plain")] [InlineData("s256")] [InlineData("S512")] - public async Task AuthorizeAsync_WhenChallengeMethodIsNotExactS256_ShouldReject( - string challengeMethod) + public async Task AuthorizeAsync_WhenChallengeMethodIsNotExactS256_ShouldReject(string challengeMethod) { var fixture = CreateFixture(); - var command = CreateAuthorizeCommand( - challengeMethod: challengeMethod); + var command = CreateAuthorizeCommand(challengeMethod: challengeMethod); - var act = () => - fixture.Sut.AuthorizeAsync(command); + var act = () => fixture.Sut.AuthorizeAsync(command); - await act.Should() - .ThrowAsync(); + await act.Should().ThrowAsync(); fixture.Store.Verify( x => x.StoreAsync( diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs index 17f2f0b2..d005bf41 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs @@ -173,9 +173,7 @@ public async Task ExecuteAsync_WhenReauthenticationRequired_ShouldNotExecuteComm var act = () => _sut.ExecuteAsync(context, command); - await act.Should() - .ThrowAsync() - .WithMessage("*reauthentication*"); + await act.Should().ThrowAsync(); executed.Should().BeFalse(); } @@ -391,6 +389,42 @@ await act.Should() Times.Never); } + [Fact] + public async Task ExecuteAsyncOfT_WhenReauthenticationRequired_ShouldNotExecuteCommand() + { + var policies = Array.Empty(); + + _policyProvider + .Setup(x => x.GetPolicies(It.IsAny())) + .Returns(policies); + + _authority + .Setup(x => x.Decide( + It.IsAny(), + policies)) + .Returns(AccessDecision.ReauthenticationRequired()); + + var executed = false; + + var command = new AccessCommand(_ => + { + executed = true; + return Task.FromResult("result"); + }); + + Func act = async () => + await _sut.ExecuteAsync(CreateContext(), command); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should().Be("challenge_required"); + exception.Which.StatusCode.Should().Be(401); + + executed.Should().BeFalse(); + } + + private static ClaimsPrincipal CreatePrincipal( params (string Type, string Value)[] claims) { From 83282fc605fdcb3fe901f28cc28e193e874d6ba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 11 Oct 2026 23:29:37 +0300 Subject: [PATCH 4/5] fix(client): normalize network and PKCE authorization errors --- .../TScripts/uauth.js | 54 +++++++++--- .../wwwroot/uauth.min.js | 2 +- .../Services/UAuthFlowClient.cs | 82 ++++++------------- 3 files changed, 70 insertions(+), 68 deletions(-) diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js index 01565046..8cdc2053 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js @@ -203,12 +203,27 @@ window.uauth.post = async function (options) { headers["Content-Type"] = "application/x-www-form-urlencoded"; } - const response = await fetch(url, { - method: "POST", - credentials: "include", - headers: headers, - body: body - }); + let response; + + try { + response = await fetch(url, { + method: "POST", + credentials: "include", + headers: headers, + body: body + }); + } catch (error) { + if (error instanceof TypeError) { + return { + ok: false, + status: 0, + refreshOutcome: null, + body: null + }; + } + + throw error; + } let responseBody = null; try { @@ -243,12 +258,27 @@ window.uauth.postJson = async function (options) { "X-Requested-With": "UAuth" }; - const response = await fetch(url, { - method: "POST", - credentials: "include", - headers: headers, - body: payload ? JSON.stringify(payload) : null - }); + let response; + + try { + response = await fetch(url, { + method: "POST", + credentials: "include", + headers: headers, + body: payload ? JSON.stringify(payload) : null + }); + } catch (error) { + if (error instanceof TypeError) { + return { + ok: false, + status: 0, + refreshOutcome: null, + body: null + }; + } + + throw error; + } let responseBody = null; try { diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js index 197576de..361da1fb 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js @@ -1 +1 @@ -window.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(!n)return;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");let t=n.querySelector("input[name='__uauth_device']");t||(t=document.createElement("input"),t.type="hidden",t.name="__uauth_device",n.appendChild(t));t.value=window.uauth.deviceId;n.submit()};window.uauth.tryAndCommit=async function(n){const{tryUrl:h,commitUrl:c,data:f,clientProfile:e}=n,o=window.uauth.deviceId;if(!o)throw new Error("UAuth deviceId is not initialized.");const r=await window.uauth.postJson({url:h,payload:f,clientProfile:e});if(!r||!Number.isInteger(r.status))throw new Error("Invalid UAuth transport response.");const i=r.status,l=i>=200&&i<300,s=i>=400&&i<500;if(!l&&!s)throw new Error(`Unexpected UAuth try response: HTTP ${i}.`);const t=r.body;if(!t||typeof t!="object"||Array.isArray(t)||typeof t.isSuccess!="boolean")throw new Error("Invalid UAuth try response body.");if(s&&t.isSuccess)throw new Error("Successful authentication result returned with HTTP error.");const u={isSuccess:t.isSuccess,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1,previewReceipt:t.previewReceipt??null};if(u.isSuccess){const n=document.createElement("form");n.method="POST";n.action=c;for(const t of Object.keys(f)){const i=document.createElement("input");i.type="hidden";i.name=t;i.value=f[t]??"";n.appendChild(i)}if(u.previewReceipt){const t=document.createElement("input");t.type="hidden";t.name="PreviewReceipt";t.value=u.previewReceipt;n.appendChild(t)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=e??"";n.appendChild(t);const i=document.createElement("input");i.type="hidden";i.name="__uauth_device";i.value=o;n.appendChild(i);document.body.appendChild(n);n.submit()}return u};window.uauth.post=async function(n){const{url:f,mode:s,data:t,clientProfile:e}=n;if(s==="navigate"){const n=document.createElement("form");n.method="POST";n.action=f;const i=document.createElement("input");i.type="hidden";i.name="__uauth_client_profile";i.value=e??"";n.appendChild(i);const r=document.createElement("input");if(r.type="hidden",r.name="__uauth_device",r.value=window.uauth.deviceId,n.appendChild(r),t)for(const i in t){const r=document.createElement("input");r.type="hidden";r.name=i;r.value=t[i];n.appendChild(r)}return document.body.appendChild(n),n.submit(),null}let r=null;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const o={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":e,"X-Requested-With":"UAuth"};if(t){r=new URLSearchParams;for(const n in t)r.append(n,t[n]);o["Content-Type"]="application/x-www-form-urlencoded"}const i=await fetch(f,{method:"POST",credentials:"include",headers:o,body:r});let u=null;try{u=await i.json()}catch{u=null}return{ok:i.ok,status:i.status,refreshOutcome:i.headers.get("X-UAuth-Refresh"),body:u}};window.uauth.postJson=async function(n){const{url:u,payload:r,clientProfile:f}=n;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const e={"Content-Type":"application/json","X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":f??"","X-Requested-With":"UAuth"},t=await fetch(u,{method:"POST",credentials:"include",headers:e,body:r?JSON.stringify(r):null});let i=null;try{i=await t.json()}catch{i=null}return{ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:i}};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}} \ No newline at end of file +window.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(!n)return;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");let t=n.querySelector("input[name='__uauth_device']");t||(t=document.createElement("input"),t.type="hidden",t.name="__uauth_device",n.appendChild(t));t.value=window.uauth.deviceId;n.submit()};window.uauth.tryAndCommit=async function(n){const{tryUrl:h,commitUrl:c,data:f,clientProfile:e}=n,o=window.uauth.deviceId;if(!o)throw new Error("UAuth deviceId is not initialized.");const r=await window.uauth.postJson({url:h,payload:f,clientProfile:e});if(!r||!Number.isInteger(r.status))throw new Error("Invalid UAuth transport response.");const i=r.status,l=i>=200&&i<300,s=i>=400&&i<500;if(!l&&!s)throw new Error(`Unexpected UAuth try response: HTTP ${i}.`);const t=r.body;if(!t||typeof t!="object"||Array.isArray(t)||typeof t.isSuccess!="boolean")throw new Error("Invalid UAuth try response body.");if(s&&t.isSuccess)throw new Error("Successful authentication result returned with HTTP error.");const u={isSuccess:t.isSuccess,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1,previewReceipt:t.previewReceipt??null};if(u.isSuccess){const n=document.createElement("form");n.method="POST";n.action=c;for(const t of Object.keys(f)){const i=document.createElement("input");i.type="hidden";i.name=t;i.value=f[t]??"";n.appendChild(i)}if(u.previewReceipt){const t=document.createElement("input");t.type="hidden";t.name="PreviewReceipt";t.value=u.previewReceipt;n.appendChild(t)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=e??"";n.appendChild(t);const i=document.createElement("input");i.type="hidden";i.name="__uauth_device";i.value=o;n.appendChild(i);document.body.appendChild(n);n.submit()}return u};window.uauth.post=async function(n){const{url:f,mode:h,data:t,clientProfile:e}=n;if(h==="navigate"){const n=document.createElement("form");n.method="POST";n.action=f;const i=document.createElement("input");i.type="hidden";i.name="__uauth_client_profile";i.value=e??"";n.appendChild(i);const r=document.createElement("input");if(r.type="hidden",r.name="__uauth_device",r.value=window.uauth.deviceId,n.appendChild(r),t)for(const i in t){const r=document.createElement("input");r.type="hidden";r.name=i;r.value=t[i];n.appendChild(r)}return document.body.appendChild(n),n.submit(),null}let r=null;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const o={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":e,"X-Requested-With":"UAuth"};if(t){r=new URLSearchParams;for(const n in t)r.append(n,t[n]);o["Content-Type"]="application/x-www-form-urlencoded"}let i;try{i=await fetch(f,{method:"POST",credentials:"include",headers:o,body:r})}catch(s){if(s instanceof TypeError)return{ok:!1,status:0,refreshOutcome:null,body:null};throw s;}let u=null;try{u=await i.json()}catch{u=null}return{ok:i.ok,status:i.status,refreshOutcome:i.headers.get("X-UAuth-Refresh"),body:u}};window.uauth.postJson=async function(n){const{url:f,payload:r,clientProfile:e}=n;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const o={"Content-Type":"application/json","X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":e??"","X-Requested-With":"UAuth"};let t;try{t=await fetch(f,{method:"POST",credentials:"include",headers:o,body:r?JSON.stringify(r):null})}catch(u){if(u instanceof TypeError)return{ok:!1,status:0,refreshOutcome:null,body:null};throw u;}let i=null;try{i=await t.json()}catch{i=null}return{ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:i}};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}} \ No newline at end of file diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index 42a889ee..41ede98f 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -186,55 +186,6 @@ public async Task RefreshAsync(bool isAuto = false) }; } - //public async Task RefreshAsync(bool isAuto = false) - //{ - // if (isAuto == false) - // { - // _diagnostics.MarkManualRefresh(); - // } - - // var url = Url(_options.Endpoints.Refresh); - // var result = await _post.SendFormAsync(url); - - // if (result.Status == 401) - // { - // _diagnostics.MarkRefreshReauthRequired(); - // return new RefreshResult - // { - // IsSuccess = false, - // Status = result.Status, - // Outcome = RefreshOutcome.ReauthRequired - // }; - // } - - // var refreshOutcome = RefreshOutcomeParser.Parse(result.RefreshOutcome); - // switch (refreshOutcome) - // { - // case RefreshOutcome.NoOp: - // _diagnostics.MarkRefreshNoOp(); - // break; - // case RefreshOutcome.Touched: - // _diagnostics.MarkRefreshTouched(); - // break; - // case RefreshOutcome.Rotated: - // _diagnostics.MarkRefreshRotated(); - // break; - // case RefreshOutcome.ReauthRequired: - // _diagnostics.MarkRefreshReauthRequired(); - // break; - // case RefreshOutcome.Success: - // _diagnostics.MarkRefreshSuccess(); - // break; - // } - - // return new RefreshResult - // { - // IsSuccess = result.Ok, - // Status = result.Status, - // Outcome = refreshOutcome - // }; - //} - //public async Task ReauthAsync() //{ // var url = Url(_options.Endpoints.Reauth); @@ -304,14 +255,35 @@ public async Task BeginPkceAsync(string? returnUrl = null) ["challenge_method"] = "S256" }); - if (!raw.Ok || raw.Body is null) - throw new InvalidOperationException("PKCE authorize failed."); + EnsureValidHttpStatus(raw); - var response = raw.Body.Value.Deserialize( - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (raw.Status is >= 400 and < 500) + { + throw new UAuthProtocolException($"PKCE authorize request failed: {raw.Status}."); + } + + if (raw.Body is null || raw.Body.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + throw new UAuthProtocolException("Missing PKCE authorize response body."); + } + + PkceAuthorizeResponse response; + + try + { + response = raw.Body.Value.Deserialize( + new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) ?? throw new UAuthProtocolException("Invalid PKCE authorize response."); + } + catch (JsonException ex) + { + throw new UAuthProtocolException("Invalid PKCE authorize response format.", ex); + } - if (response is null || string.IsNullOrWhiteSpace(response.AuthorizationCode)) - throw new InvalidOperationException("Invalid PKCE authorize response."); + if (string.IsNullOrWhiteSpace(response.AuthorizationCode)) + throw new UAuthProtocolException("PKCE authorize response has no authorization code."); if (pkce.OnAuthorized is not null) await pkce.OnAuthorized(response); From cd3b7bb2059402e442b08e1c8be99d2332098282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 11 Oct 2026 23:40:18 +0300 Subject: [PATCH 5/5] fix(security): propagate unexpected Argon2 verification failures --- .../UAuthExceptionHandlingExtensions.cs | 2 + .../Infrastructure/UAuthResultMapper.cs | 2 + .../Argon2PasswordHasher.cs | 39 ++++++++++++------- .../Client/UAuthFlowClientTests.cs | 3 +- .../Security/Argon2PasswordHasherTest.cs | 17 ++++++++ 5 files changed, 46 insertions(+), 17 deletions(-) diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs index 503bd102..d0567081 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs @@ -25,6 +25,8 @@ public static IApplicationBuilder UseUAuthExceptionHandling(this IApplicationBui }); } + // TODO: Standardize runtime error codes as stable machine-readable identifiers. + // Keep human-readable details separate from codes and ProblemDetails.Type. private static Task WriteProblemDetails(HttpContext context, UAuthRuntimeException ex) { var status = ex.StatusCode; diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs index da59de93..17bc8eef 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs @@ -110,6 +110,8 @@ private static void EnsureTransport(UAuthTransportResult raw) }; } + // TODO: Preserve flattened ProblemDetails extensions during deserialization. + // Ensure custom extensions and traceId survive the server-to-SDK round-trip. private static UAuthProblem? TryDeserializeProblem(UAuthTransportResult raw) { if (raw.Body is null || raw.Body.Value.ValueKind != JsonValueKind.Object) diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs index f72fa2ae..800d9d68 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs @@ -49,27 +49,36 @@ public bool Verify(PasswordHash hash, string secret) !int.TryParse(parts[2], out var parallelism)) return false; - try - { - var salt = Convert.FromBase64String(parts[3]); - var expectedHash = Convert.FromBase64String(parts[4]); - - var argon2 = new Argon2id(Encoding.UTF8.GetBytes(secret)) - { - Salt = salt, - Iterations = iterations, - MemorySize = memory, - DegreeOfParallelism = parallelism - }; + if (iterations <= 0 || memory <= 0 || parallelism <= 0) + return false; - var actualHash = argon2.GetBytes(expectedHash.Length); + byte[] salt; + byte[] expectedHash; - return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash); + try + { + salt = Convert.FromBase64String(parts[3]); + expectedHash = Convert.FromBase64String(parts[4]); } - catch + catch (FormatException) { return false; } + + if (salt.Length == 0 || expectedHash.Length == 0) + return false; + + var argon2 = new Argon2id(Encoding.UTF8.GetBytes(secret)) + { + Salt = salt, + Iterations = iterations, + MemorySize = memory, + DegreeOfParallelism = parallelism + }; + + var actualHash = argon2.GetBytes(expectedHash.Length); + + return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash); } public bool NeedsRehash(PasswordHash hash) diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs index bc65d66a..830b225b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs @@ -515,8 +515,7 @@ public async Task BeginPkce_Should_Throw_When_AuthorizationCode_Missing() Func act = async () => await client.BeginPkceAsync(); - await act.Should().ThrowAsync() - .WithMessage("*Invalid PKCE authorize response*"); + await act.Should().ThrowAsync().WithMessage("*authorization code*"); } [Fact] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs index ea9342a7..e2e1414f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs @@ -291,6 +291,23 @@ public void NeedsRehash_Should_Return_True_When_Parallelism_Changed() .BeTrue(); } + [Theory] + [InlineData("0.65536.1.c2FsdA==.aGFzaA==")] + [InlineData("3.0.1.c2FsdA==.aGFzaA==")] + [InlineData("3.65536.0.c2FsdA==.aGFzaA==")] + [InlineData("3.65536.1..aGFzaA==")] + [InlineData("3.65536.1.c2FsdA==.")] + public void Verify_ShouldReturnFalse_ForMalformedHash(string encoded) + { + var hasher = CreateHasher(); + + var hash = PasswordHash.Create( + PasswordAlgorithms.Argon2, + encoded); + + hasher.Verify(hash, "password123").Should().BeFalse(); + } + private static Argon2PasswordHasher CreateHasher() { return CreateHasher(new Argon2Options());