diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthAuthority.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthAuthority.cs index 4e5eface..b2b7775a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthAuthority.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthAuthority.cs @@ -4,5 +4,14 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions; public interface IAuthAuthority { - AccessDecisionResult Decide(AuthContext context, IEnumerable? policies = null); + [Obsolete("Synchronous authority evaluation is no longer supported. Use DecideAsync instead.")] + AccessDecisionResult Decide(AuthContext context, IEnumerable? policies = null) + => throw new NotSupportedException("Synchronous authority evaluation is not supported. Use DecideAsync."); + + ValueTask DecideAsync(AuthContext context, IEnumerable? policies = null, CancellationToken ct = default) + { +#pragma warning disable CS0618 + return ValueTask.FromResult(Decide(context, policies)); +#pragma warning restore CS0618 + } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs index e8e11ca7..640e26b3 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs @@ -5,5 +5,14 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions; // TODO: Add ClockSkewInvariant to handle cases where client and server clocks are not synchronized, which can lead to valid tokens being rejected due to "not valid yet" or "expired" errors. This invariant would check the token's "nbf" (not before) and "exp" (expiration) claims against the current time, allowing for a configurable clock skew (e.g., 5 minutes) to accommodate minor discrepancies in system clocks. public interface IAuthorityInvariant { - AccessDecisionResult Decide(AuthContext context); + [Obsolete("Implement DecideAsync instead.")] + AccessDecisionResult Decide(AuthContext context) + => throw new NotSupportedException("Implement DecideAsync for asynchronous invariant evaluation."); + + ValueTask DecideAsync(AuthContext context, CancellationToken ct = default) + { +#pragma warning disable CS0618 + return ValueTask.FromResult(Decide(context)); +#pragma warning restore CS0618 + } } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs index 5d2bc41d..ef69f7d8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs @@ -5,5 +5,15 @@ namespace CodeBeam.UltimateAuth.Core.Abstractions; public interface IAuthorityPolicy { bool AppliesTo(AuthContext context); - AccessDecisionResult Decide(AuthContext context); + + [Obsolete("Implement DecideAsync instead.")] + AccessDecisionResult Decide(AuthContext context) + => throw new NotSupportedException("Implement DecideAsync for asynchronous policy evaluation."); + + ValueTask DecideAsync(AuthContext context, CancellationToken ct = default) + { +#pragma warning disable CS0618 + return ValueTask.FromResult(Decide(context)); +#pragma warning restore CS0618 + } } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/AuthAuthority.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/AuthAuthority.cs index 9f5179ce..6e7aa3bf 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/AuthAuthority.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/AuthAuthority.cs @@ -14,11 +14,17 @@ public AuthAuthority(IEnumerable invariants, IEnumerable(); } - public AccessDecisionResult Decide(AuthContext context, IEnumerable? policies = null) + public async ValueTask DecideAsync(AuthContext context, IEnumerable? policies = null, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + foreach (var invariant in _invariants) { - var result = invariant.Decide(context); + ct.ThrowIfCancellationRequested(); + + var result = await invariant.DecideAsync( + context, ct); + if (!result.IsAllowed) return result; } @@ -29,10 +35,12 @@ public AccessDecisionResult Decide(AuthContext context, IEnumerable DecideAsync(AuthContext context, CancellationToken ct = default) { if (!RequiresDevice(context)) - return AccessDecisionResult.Allow(); + return ValueTask.FromResult(AccessDecisionResult.Allow()); if (!context.Device.HasDeviceId) - return AccessDecisionResult.Deny("DeviceId is required for this operation."); + return ValueTask.FromResult(AccessDecisionResult.Deny("DeviceId is required for this operation.")); - return AccessDecisionResult.Allow(); + return ValueTask.FromResult(AccessDecisionResult.Allow()); } private static bool RequiresDevice(AuthContext context) diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs index 6ef97ad7..6a88daba 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs @@ -6,21 +6,21 @@ namespace CodeBeam.UltimateAuth.Core.Infrastructure; public sealed class ExpiredSessionInvariant : IAuthorityInvariant { - public AccessDecisionResult Decide(AuthContext context) + public ValueTask DecideAsync(AuthContext context, CancellationToken ct = default) { if (context.Operation == AuthOperation.Login) - return AccessDecisionResult.Allow(); + return ValueTask.FromResult(AccessDecisionResult.Allow()); var session = context.Session; if (session is null) - return AccessDecisionResult.Allow(); + return ValueTask.FromResult(AccessDecisionResult.Allow()); if (session.State == SessionState.Expired) { - return AccessDecisionResult.Deny("Session has expired."); + return ValueTask.FromResult(AccessDecisionResult.Deny("Session has expired.")); } - return AccessDecisionResult.Allow(); + return ValueTask.FromResult(AccessDecisionResult.Allow()); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs index 0b971799..27b6176c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs @@ -6,15 +6,15 @@ namespace CodeBeam.UltimateAuth.Core.Infrastructure; public sealed class InvalidOrRevokedSessionInvariant : IAuthorityInvariant { - public AccessDecisionResult Decide(AuthContext context) + public ValueTask DecideAsync(AuthContext context, CancellationToken ct = default) { if (context.Operation == AuthOperation.Login) - return AccessDecisionResult.Allow(); + return ValueTask.FromResult(AccessDecisionResult.Allow()); var session = context.Session; if (session is null) - return AccessDecisionResult.Deny("Session is required for this operation."); + return ValueTask.FromResult(AccessDecisionResult.Deny("Session is required for this operation.")); if (session.State == SessionState.Invalid || session.State == SessionState.NotFound || @@ -22,9 +22,9 @@ public AccessDecisionResult Decide(AuthContext context) session.State == SessionState.SecurityMismatch || session.State == SessionState.DeviceMismatch) { - return AccessDecisionResult.Deny($"Session state is invalid: {session.State}"); + return ValueTask.FromResult(AccessDecisionResult.Deny($"Session state is invalid: {session.State}")); } - return AccessDecisionResult.Allow(); + return ValueTask.FromResult(AccessDecisionResult.Allow()); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/TenantResolvedInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/TenantResolvedInvariant.cs index 7f8b6be3..f9fc9390 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/TenantResolvedInvariant.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/TenantResolvedInvariant.cs @@ -5,13 +5,14 @@ namespace CodeBeam.UltimateAuth.Core.Infrastructure; public sealed class TenantResolvedInvariant : IAuthorityInvariant { - public AccessDecisionResult Decide(AuthContext context) + public ValueTask DecideAsync(AuthContext context, CancellationToken ct = default) { - if (context.Tenant.IsUnresolved) - { - return AccessDecisionResult.Deny("Tenant is not resolved."); - } + ct.ThrowIfCancellationRequested(); - return AccessDecisionResult.Allow(); + var result = context.Tenant.IsUnresolved + ? AccessDecisionResult.Deny("Tenant is not resolved.") + : AccessDecisionResult.Allow(); + + return ValueTask.FromResult(result); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs index 502f7ed6..996b2d16 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs @@ -23,7 +23,7 @@ public async Task ExecuteAsync(AuthContext authContext, ISessi _executed = true; - var decision = _authority.Decide(authContext); + var decision = await _authority.DecideAsync(authContext, ct: ct); switch (decision.Decision) { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthorizationManagementTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthorizationManagementTests.cs index a3eaa9e6..55b2be56 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthorizationManagementTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthorizationManagementTests.cs @@ -33,8 +33,7 @@ public async Task AssignRole_TargetMismatch_ShouldRejectWithoutMutation() var target = await factory.CreateLoginUserAsync(); var other = await factory.CreateLoginUserAsync(); - await GrantAsync(factory, admin, - UAuthActions.Authorization.Roles.AssignAdmin); + await GrantAsync(factory, admin, UAuthActions.Authorization.Roles.AssignAdmin); var role = await SeedRoleAsync(factory); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AuthAuthorityTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AuthAuthorityTests.cs new file mode 100644 index 00000000..d6c0eaba --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AuthAuthorityTests.cs @@ -0,0 +1,160 @@ +using AwesomeAssertions; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Infrastructure; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server; + +public sealed class AuthAuthorityTests +{ + [Fact] + public async Task DecideAsync_WithLegacyInvariant_ShouldWork() + { + var invariant = new LegacyInvariant(); + + var authority = new AuthAuthority( + new IAuthorityInvariant[] { invariant }, + Array.Empty()); + + var result = await authority.DecideAsync(CreateContext()); + + result.Decision.Should().Be(AuthorizationDecision.Deny); + result.Reason.Should().Be("legacy_invariant_denied"); + invariant.CallCount.Should().Be(1); + } + + [Fact] + public async Task DecideAsync_ShouldAwaitAsyncPolicy() + { + var policy = new DelayedPolicy(); + + var authority = new AuthAuthority( + Array.Empty(), + new IAuthorityPolicy[] { policy }); + + var evaluation = authority.DecideAsync(CreateContext()); + + // Ensure the policy has started evaluating. + await policy.Started; + + // The authority must not finish before the policy. + evaluation.IsCompleted.Should().BeFalse(); + + // Complete the asynchronous policy evaluation. + policy.Complete(AccessDecisionResult.Deny("async_policy_denied")); + + var result = await evaluation; + + result.Decision.Should().Be(AuthorizationDecision.Deny); + result.Reason.Should().Be("async_policy_denied"); + } + + [Fact] + public async Task DecideAsync_ShouldForwardCancellationToken() + { + using var cts = new CancellationTokenSource(); + + var invariant = new TokenCapturingInvariant(); + var policy = new TokenCapturingPolicy(); + + var authority = new AuthAuthority( + new IAuthorityInvariant[] { invariant }, + new IAuthorityPolicy[] { policy }); + + var result = await authority.DecideAsync(CreateContext(), ct: cts.Token); + + result.Decision.Should().Be(AuthorizationDecision.Allow); + + invariant.ReceivedToken.Should().Be(cts.Token); + policy.ReceivedToken.Should().Be(cts.Token); + + invariant.CallCount.Should().Be(1); + policy.CallCount.Should().Be(1); + } + + private static AuthContext CreateContext() + => new() + { + // These tests exercise authority dispatch only. + // No built-in invariant accesses Device. + Device = null! + }; + + private sealed class LegacyInvariant : IAuthorityInvariant + { + public int CallCount { get; private set; } + +#pragma warning disable CS0672 + public AccessDecisionResult Decide(AuthContext context) +#pragma warning restore CS0672 + { + CallCount++; + + return AccessDecisionResult.Deny( + "legacy_invariant_denied"); + } + } + + private sealed class DelayedPolicy : IAuthorityPolicy + { + private readonly TaskCompletionSource _started = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Started => _started.Task; + + public bool AppliesTo(AuthContext context) => true; + + public async ValueTask DecideAsync( + AuthContext context, + CancellationToken ct = default) + { + _started.TrySetResult(true); + + return await _completion.Task.WaitAsync(ct); + } + + public void Complete(AccessDecisionResult decision) + => _completion.TrySetResult(decision); + } + + private sealed class TokenCapturingInvariant : IAuthorityInvariant + { + public CancellationToken ReceivedToken { get; private set; } + + public int CallCount { get; private set; } + + public ValueTask DecideAsync( + AuthContext context, + CancellationToken ct = default) + { + ReceivedToken = ct; + CallCount++; + + return ValueTask.FromResult( + AccessDecisionResult.Allow()); + } + } + + private sealed class TokenCapturingPolicy : IAuthorityPolicy + { + public CancellationToken ReceivedToken { get; private set; } + + public int CallCount { get; private set; } + + public bool AppliesTo(AuthContext context) => true; + + public ValueTask DecideAsync( + AuthContext context, + CancellationToken ct = default) + { + ReceivedToken = ct; + CallCount++; + + return ValueTask.FromResult( + AccessDecisionResult.Allow()); + } + } +}