diff --git a/Svrnty.CQRS.Altcha/AltchaAuthorizationCheck.cs b/Svrnty.CQRS.Altcha/AltchaAuthorizationCheck.cs
new file mode 100644
index 0000000..8b74d3a
--- /dev/null
+++ b/Svrnty.CQRS.Altcha/AltchaAuthorizationCheck.cs
@@ -0,0 +1,93 @@
+using System.Reflection;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Svrnty.CQRS.Abstractions.Security;
+using Svrnty.CQRS.Altcha.Abstractions;
+
+namespace Svrnty.CQRS.Altcha;
+
+///
+/// The single check that powers the . Plays both
+/// and
+/// roles so a request payload of either kind is gated identically.
+///
+///
+/// Self-applying: the check no-ops (returns )
+/// for any request whose type isn't decorated with .
+/// Resolves from the request scope per call so the
+/// check is agnostic to the verifier's lifetime.
+///
+public sealed class AltchaAuthorizationCheck : ICommandAuthorizationCheck, IQueryAuthorizationCheck
+{
+ internal const string ReasonItemKey = "altcha_reason";
+ internal const string MobileAttestedItemKey = "mobile_attested";
+
+ private readonly ILogger _logger;
+
+ public AltchaAuthorizationCheck(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public Task CheckAsync(
+ CommandAuthorizationCheckContext context,
+ CancellationToken cancellationToken = default)
+ => CheckCoreAsync(context, context.CommandType, context.Command, cancellationToken);
+
+ public Task CheckAsync(
+ QueryAuthorizationCheckContext context,
+ CancellationToken cancellationToken = default)
+ => CheckCoreAsync(context, context.QueryType, context.Query, cancellationToken);
+
+ private async Task CheckCoreAsync(
+ AuthorizationCheckContext context,
+ Type subjectType,
+ object subject,
+ CancellationToken cancellationToken)
+ {
+ var altchaAttr = subjectType.GetCustomAttribute(inherit: false);
+ if (altchaAttr is null)
+ return AuthorizationResult.Allowed;
+
+ if (altchaAttr.AllowMobileAttestationBypass
+ && context.Items.TryGetValue(MobileAttestedItemKey, out var attested)
+ && attested is true)
+ {
+ _logger.LogDebug("Altcha bypassed for {Type}: mobile attestation present.", subjectType.FullName);
+ return AuthorizationResult.Allowed;
+ }
+
+ if (subject is not IHasAltchaSolution carrier)
+ {
+ // Developer error: [Altcha] on a request that doesn't carry the solution field.
+ _logger.LogError(
+ "[Altcha] is set on {Type} but the type does not implement IHasAltchaSolution. " +
+ "Verification cannot proceed — treating as forbidden.",
+ subjectType.FullName);
+ context.Items[ReasonItemKey] = "misconfigured";
+ return AuthorizationResult.Forbidden;
+ }
+
+ if (string.IsNullOrWhiteSpace(carrier.AltchaSolution))
+ {
+ _logger.LogWarning("Altcha required for {Type} but no solution was supplied.", subjectType.FullName);
+ context.Items[ReasonItemKey] = "missing";
+ return AuthorizationResult.Unauthorized;
+ }
+
+ var verifier = context.Services.GetRequiredService();
+ var result = await verifier.VerifyAsync(carrier.AltchaSolution, cancellationToken);
+
+ if (!result.Ok)
+ {
+ _logger.LogWarning(
+ "Altcha verification rejected {Type}: reason={Reason}",
+ subjectType.FullName,
+ result.Reason ?? "unspecified");
+ context.Items[ReasonItemKey] = result.Reason ?? "invalid";
+ return AuthorizationResult.Unauthorized;
+ }
+
+ return AuthorizationResult.Allowed;
+ }
+}
diff --git a/Svrnty.CQRS.Altcha/ServiceCollectionExtensions.cs b/Svrnty.CQRS.Altcha/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..0bccdc2
--- /dev/null
+++ b/Svrnty.CQRS.Altcha/ServiceCollectionExtensions.cs
@@ -0,0 +1,30 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Svrnty.CQRS.Abstractions.Security;
+
+namespace Svrnty.CQRS.Altcha;
+
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Registers as both an
+ /// and an
+ /// . The check is a no-op until
+ /// an
+ /// implementation is also registered (typically via
+ /// AddSvrntyAltchaGrpcVerifier(...) from
+ /// Svrnty.CQRS.Altcha.Grpc).
+ ///
+ ///
+ /// Idempotent for the concrete check; the multi-instance interface
+ /// registrations are added unconditionally, so callers should invoke
+ /// this exactly once per application startup.
+ ///
+ public static IServiceCollection AddSvrntyAltcha(this IServiceCollection services)
+ {
+ services.TryAddSingleton();
+ services.AddSingleton(sp => sp.GetRequiredService());
+ services.AddSingleton(sp => sp.GetRequiredService());
+ return services;
+ }
+}
diff --git a/Svrnty.CQRS.Altcha/Svrnty.CQRS.Altcha.csproj b/Svrnty.CQRS.Altcha/Svrnty.CQRS.Altcha.csproj
new file mode 100644
index 0000000..18e690d
--- /dev/null
+++ b/Svrnty.CQRS.Altcha/Svrnty.CQRS.Altcha.csproj
@@ -0,0 +1,39 @@
+
+
+ net10.0
+ true
+ 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 7ea601d..9212eca 100644
--- a/Svrnty.CQRS.sln
+++ b/Svrnty.CQRS.sln
@@ -45,6 +45,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.RabbitMQ
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha.Abstractions", "Svrnty.CQRS.Altcha.Abstractions\Svrnty.CQRS.Altcha.Abstractions.csproj", "{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Altcha", "Svrnty.CQRS.Altcha\Svrnty.CQRS.Altcha.csproj", "{9986C034-D585-4045-9F6C-99896B8A385B}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -271,6 +273,18 @@ Global
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|x64.Build.0 = Release|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|x86.ActiveCfg = Release|Any CPU
{753AF4E2-958B-4285-B4DF-8B654E5CF0FC}.Release|x86.Build.0 = Release|Any CPU
+ {9986C034-D585-4045-9F6C-99896B8A385B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9986C034-D585-4045-9F6C-99896B8A385B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9986C034-D585-4045-9F6C-99896B8A385B}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9986C034-D585-4045-9F6C-99896B8A385B}.Debug|x64.Build.0 = Debug|Any CPU
+ {9986C034-D585-4045-9F6C-99896B8A385B}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {9986C034-D585-4045-9F6C-99896B8A385B}.Debug|x86.Build.0 = Debug|Any CPU
+ {9986C034-D585-4045-9F6C-99896B8A385B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9986C034-D585-4045-9F6C-99896B8A385B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9986C034-D585-4045-9F6C-99896B8A385B}.Release|x64.ActiveCfg = Release|Any CPU
+ {9986C034-D585-4045-9F6C-99896B8A385B}.Release|x64.Build.0 = Release|Any CPU
+ {9986C034-D585-4045-9F6C-99896B8A385B}.Release|x86.ActiveCfg = Release|Any CPU
+ {9986C034-D585-4045-9F6C-99896B8A385B}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE