From 891894d136fabad706d58b51d9219992f4541111 Mon Sep 17 00:00:00 2001 From: Mathias Beaulieu-Duncan Date: Tue, 12 May 2026 16:26:41 -0400 Subject: [PATCH] feat(altcha): add Svrnty.CQRS.Altcha.MinimalApi challenge endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single helper extension: MapSvrntyAltchaChallenge() exposes GET /api/altcha/challenge (configurable prefix) that fetches a fresh challenge from IAltchaChallengeProvider and projects it onto the JSON shape the altcha widget v3 expects from its challengeurl — { algorithm, challenge, salt, signature, maxnumber } in lowercase. AllowAnonymous on purpose: the whole point is gating mutations from unauthenticated callers, so the challenge endpoint must be reachable without credentials. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AltchaChallengeDto.cs | 28 +++++++++++ .../EndpointRouteBuilderExtensions.cs | 50 +++++++++++++++++++ .../Svrnty.CQRS.Altcha.MinimalApi.csproj | 37 ++++++++++++++ Svrnty.CQRS.sln | 14 ++++++ 4 files changed, 129 insertions(+) create mode 100644 Svrnty.CQRS.Altcha.MinimalApi/AltchaChallengeDto.cs create mode 100644 Svrnty.CQRS.Altcha.MinimalApi/EndpointRouteBuilderExtensions.cs create mode 100644 Svrnty.CQRS.Altcha.MinimalApi/Svrnty.CQRS.Altcha.MinimalApi.csproj diff --git a/Svrnty.CQRS.Altcha.MinimalApi/AltchaChallengeDto.cs b/Svrnty.CQRS.Altcha.MinimalApi/AltchaChallengeDto.cs new file mode 100644 index 0000000..5fc4b8e --- /dev/null +++ b/Svrnty.CQRS.Altcha.MinimalApi/AltchaChallengeDto.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; + +namespace Svrnty.CQRS.Altcha.MinimalApi; + +/// +/// JSON projection of +/// in the exact shape the +/// altcha widget v3 +/// expects from a challengeurl response. Property names are +/// lowercased and challenge (no underscore) to match the widget. +/// +public sealed class AltchaChallengeDto +{ + [JsonPropertyName("algorithm")] + public required string Algorithm { get; init; } + + [JsonPropertyName("challenge")] + public required string Challenge { get; init; } + + [JsonPropertyName("salt")] + public required string Salt { get; init; } + + [JsonPropertyName("signature")] + public required string Signature { get; init; } + + [JsonPropertyName("maxnumber")] + public required uint MaxNumber { get; init; } +} diff --git a/Svrnty.CQRS.Altcha.MinimalApi/EndpointRouteBuilderExtensions.cs b/Svrnty.CQRS.Altcha.MinimalApi/EndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..ad120dc --- /dev/null +++ b/Svrnty.CQRS.Altcha.MinimalApi/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Svrnty.CQRS.Altcha.Abstractions; + +namespace Svrnty.CQRS.Altcha.MinimalApi; + +public static class EndpointRouteBuilderExtensions +{ + /// + /// Maps GET {routePrefix} (default /api/altcha/challenge) + /// returning a fresh challenge in the JSON shape the + /// altcha widget + /// consumes via its challengeurl attribute. + /// + /// + /// Requires an to be registered + /// (typically by AddSvrntyAltchaGrpcVerifier(...)). The endpoint + /// allows anonymous access — the whole point is gating mutations from + /// unauthenticated callers, so the challenge endpoint must be reachable + /// without credentials. + /// + public static IEndpointRouteBuilder MapSvrntyAltchaChallenge( + this IEndpointRouteBuilder endpoints, + string routePrefix = "/api/altcha/challenge") + { + endpoints.MapGet(routePrefix, async ( + IAltchaChallengeProvider provider, + CancellationToken cancellationToken) => + { + var challenge = await provider.CreateAsync(cancellationToken); + return Results.Ok(new AltchaChallengeDto + { + Algorithm = challenge.Algorithm, + Challenge = challenge.Challenge, + Salt = challenge.Salt, + Signature = challenge.Signature, + MaxNumber = challenge.MaxNumber + }); + }) + .AllowAnonymous() + .WithName("Altcha_Challenge_Get") + .WithTags("Altcha") + .Produces(200) + .Produces(503); + + return endpoints; + } +} diff --git a/Svrnty.CQRS.Altcha.MinimalApi/Svrnty.CQRS.Altcha.MinimalApi.csproj b/Svrnty.CQRS.Altcha.MinimalApi/Svrnty.CQRS.Altcha.MinimalApi.csproj new file mode 100644 index 0000000..861ddd0 --- /dev/null +++ b/Svrnty.CQRS.Altcha.MinimalApi/Svrnty.CQRS.Altcha.MinimalApi.csproj @@ -0,0 +1,37 @@ + + + net10.0 + false + 14 + enable + enable + + Svrnty + David Lebee, Mathias Beaulieu-Duncan + icon.png + README.md + https://git.openharbor.io/svrnty/dotnet-cqrs + git + true + MIT + + portable + true + true + true + snupkg + + + + + + + + + + + + + + + diff --git a/Svrnty.CQRS.sln b/Svrnty.CQRS.sln index 4b753ab..33baa37 100644 --- a/Svrnty.CQRS.sln +++ b/Svrnty.CQRS.sln @@ -49,6 +49,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha", "Svrnt EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha.Grpc", "Svrnty.CQRS.Altcha.Grpc\Svrnty.CQRS.Altcha.Grpc.csproj", "{628DE10C-FCDB-418B-8341-FA246BBCF70E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha.MinimalApi", "Svrnty.CQRS.Altcha.MinimalApi\Svrnty.CQRS.Altcha.MinimalApi.csproj", "{26B24C13-FA06-4611-A371-2B640B8066F2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -299,6 +301,18 @@ Global {628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|x64.Build.0 = Release|Any CPU {628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|x86.ActiveCfg = Release|Any CPU {628DE10C-FCDB-418B-8341-FA246BBCF70E}.Release|x86.Build.0 = Release|Any CPU + {26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|x64.ActiveCfg = Debug|Any CPU + {26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|x64.Build.0 = Debug|Any CPU + {26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|x86.ActiveCfg = Debug|Any CPU + {26B24C13-FA06-4611-A371-2B640B8066F2}.Debug|x86.Build.0 = Debug|Any CPU + {26B24C13-FA06-4611-A371-2B640B8066F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {26B24C13-FA06-4611-A371-2B640B8066F2}.Release|Any CPU.Build.0 = Release|Any CPU + {26B24C13-FA06-4611-A371-2B640B8066F2}.Release|x64.ActiveCfg = Release|Any CPU + {26B24C13-FA06-4611-A371-2B640B8066F2}.Release|x64.Build.0 = Release|Any CPU + {26B24C13-FA06-4611-A371-2B640B8066F2}.Release|x86.ActiveCfg = Release|Any CPU + {26B24C13-FA06-4611-A371-2B640B8066F2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE