From d2963792af3cdf6d76d462c3680320a79a7a2a89 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 8 Oct 2026 20:10:51 +0300 Subject: [PATCH 01/13] feat: phase 6c descriptor and resolver (AUTH-1..AUTH-7) --- CHANGELOG.md | 5 + src/Dexpace.Sdk.Core/Auth/AuthDescriptor.cs | 73 ++++++++ src/Dexpace.Sdk.Core/Auth/AuthRequirement.cs | 133 ++++++++++++++ src/Dexpace.Sdk.Core/Auth/AuthResolver.cs | 48 ++++++ src/Dexpace.Sdk.Core/Auth/AuthScheme.cs | 30 ++++ .../Errors/AuthResolutionException.cs | 41 +++++ .../Http/Request/RequestOptions.cs | 27 ++- src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt | 42 +++++ .../Auth/AuthDescriptorTests.cs | 163 ++++++++++++++++++ .../Auth/AuthResolverTests.cs | 113 ++++++++++++ .../Http/Request/RequestOptionsTests.cs | 49 ++++++ 11 files changed, 722 insertions(+), 2 deletions(-) create mode 100644 src/Dexpace.Sdk.Core/Auth/AuthDescriptor.cs create mode 100644 src/Dexpace.Sdk.Core/Auth/AuthRequirement.cs create mode 100644 src/Dexpace.Sdk.Core/Auth/AuthResolver.cs create mode 100644 src/Dexpace.Sdk.Core/Auth/AuthScheme.cs create mode 100644 src/Dexpace.Sdk.Core/Errors/AuthResolutionException.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Auth/AuthDescriptorTests.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Auth/AuthResolverTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3382f72..276b5cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -282,6 +282,8 @@ Roadmap phase 1, defects S1–S9, each pinned by a `[Trait("Category", "Security - **Breaking:** `OperationPolicy`'s parameterless constructor becomes `OperationPolicy(TimeProvider? timeProvider = null)` (source-compatible, binary-incompatible); `AddStandardResilience` passes its `timeProvider` to it, so a fake clock drives the deadline. +- **Breaking:** `RequestOptions` equality now includes the new `Auth` and `OperationAuth` descriptors (`AUTH-4`); two + options differing only in a descriptor were equal before. - **Breaking (phase 6b redirect, PR 1):** `RedirectOptions` is reshaped (`REDIR-3`, `REDIR-4`, `REDIR-5`, `REDIR-15`, `REDIR-17`, `REDIR-20`, `REDIR-26`). `StripSensitiveHeadersOnCrossOrigin` is removed (it has had no effect since phase 1; stripping always @@ -490,5 +492,8 @@ Roadmap phase 1, defects S1–S9, each pinned by a `[Trait("Category", "Security cross-origin hop or a retry across one), the NativeAOT smoke check `CheckPhase6bRedirectAsync`, and `docs/sdk-documentation/redirect.md`. - `docs/sdk-documentation/retry.md`; the AOT smoke covers `RetryPolicy`, `RetryRecovery` through `RecoveryDispatcher`, `OperationTimeoutException`, a custom `IRetryableError` and the `RetryOptions` validation. +- Phase 6c descriptor and resolver: `AuthScheme`, `AuthRequirement`, `AuthDescriptor`, `AuthResolver` (per-call, operation and + client tiers, no fall-through), `AuthResolutionException`, and the `RequestOptions.Auth` / `OperationAuth` carriers + (`AUTH-1`..`AUTH-7`). [Unreleased]: https://github.com/dexpace/dotnet-sdk/commits/main diff --git a/src/Dexpace.Sdk.Core/Auth/AuthDescriptor.cs b/src/Dexpace.Sdk.Core/Auth/AuthDescriptor.cs new file mode 100644 index 0000000..8a08579 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/AuthDescriptor.cs @@ -0,0 +1,73 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Collections.Immutable; +using System.Text; + +namespace Dexpace.Sdk.Core.Auth; + +/// +/// An ordered set of alternative s, strongest preference first (AUTH-3). +/// +/// +/// The requirements are copied at construction and exposed read-only. There is no init member, so a +/// with expression cannot empty the descriptor (P6c-4). Equality is by the ordered requirements. +/// +public sealed record AuthDescriptor +{ + private readonly ImmutableArray _requirements; + + /// Initializes a descriptor over . + /// The alternatives, in preference order. + /// There are none, or one is . + public AuthDescriptor(params IEnumerable requirements) + { + ArgumentNullException.ThrowIfNull(requirements); + var builder = ImmutableArray.CreateBuilder(); + foreach (var requirement in requirements) + { + if (requirement is null) + { + throw new ArgumentException("A requirement must not be null.", nameof(requirements)); + } + + builder.Add(requirement); + } + + if (builder.Count == 0) + { + throw new ArgumentException("A descriptor needs at least one requirement.", nameof(requirements)); + } + + _requirements = builder.ToImmutable(); + AllowsAnonymous = _requirements.Any(static r => r.Scheme == AuthScheme.NoAuth); + } + + /// The alternatives, in preference order. + public IReadOnlyList Requirements => _requirements; + + /// when any requirement is . + public bool AllowsAnonymous { get; } + + /// + public bool Equals(AuthDescriptor? other) => + other is not null && (ReferenceEquals(this, other) || _requirements.AsSpan().SequenceEqual(other._requirements.AsSpan())); + + /// + public override int GetHashCode() + { + var hash = new HashCode(); + foreach (var requirement in _requirements) + { + hash.Add(requirement); + } + + return hash.ToHashCode(); + } + + private bool PrintMembers(StringBuilder builder) + { + builder.Append("Requirements = [").Append(string.Join(", ", _requirements.Select(static r => r.Scheme))).Append(']'); + return true; + } +} diff --git a/src/Dexpace.Sdk.Core/Auth/AuthRequirement.cs b/src/Dexpace.Sdk.Core/Auth/AuthRequirement.cs new file mode 100644 index 0000000..b21ae51 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/AuthRequirement.cs @@ -0,0 +1,133 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Collections.Immutable; +using System.Text; + +namespace Dexpace.Sdk.Core.Auth; + +/// +/// One alternative a call accepts: a scheme with optional scopes and free-form parameters. +/// +/// +/// Scopes and parameters are preserved and never interpreted by the resolver (AUTH-2). Equality is by content: the +/// scheme, the scopes in order and the parameters; two requirements built from different but equal lists are equal. +/// +public sealed record AuthRequirement +{ + private static readonly ImmutableDictionary s_noParameters = + ImmutableDictionary.Empty.WithComparers(StringComparer.Ordinal); + + private ImmutableArray _scopes = []; + private ImmutableDictionary _parameters = s_noParameters; + + /// Initializes a requirement for . + /// The scheme. + /// is not a defined value. + public AuthRequirement(AuthScheme scheme) + { + if (!Enum.IsDefined(scheme)) + { + throw new ArgumentOutOfRangeException(nameof(scheme), scheme, "The authentication scheme is not a defined value."); + } + + Scheme = scheme; + } + + /// The anonymous requirement: a call that sends no credential. + public static AuthRequirement NoAuth { get; } = new(AuthScheme.NoAuth); + + /// The scheme. + public AuthScheme Scheme { get; } + + /// The scopes, copied at initialisation; empty by default. + /// The value, or an element of it, is . + public IReadOnlyList Scopes + { + get => _scopes.IsDefault ? [] : _scopes; + init + { + ArgumentNullException.ThrowIfNull(value); + var builder = ImmutableArray.CreateBuilder(); + foreach (var scope in value) + { + if (scope is null) + { + throw new ArgumentException("A scope must not be null.", nameof(value)); + } + + builder.Add(scope); + } + + _scopes = builder.ToImmutable(); + } + } + + /// The free-form parameters, copied at initialisation with ordinal keys; empty by default. + public IReadOnlyDictionary Parameters + { + get => _parameters; + init + { + ArgumentNullException.ThrowIfNull(value); + _parameters = s_noParameters.AddRange(value); + } + } + + /// + public bool Equals(AuthRequirement? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + if (Scheme != other.Scheme || !Scopes.SequenceEqual(other.Scopes, StringComparer.Ordinal) + || _parameters.Count != other._parameters.Count) + { + return false; + } + + foreach (var (key, value) in _parameters) + { + if (!other._parameters.TryGetValue(key, out var theirs) || !string.Equals(value, theirs, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + /// + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(Scheme); + foreach (var scope in Scopes) + { + hash.Add(scope, StringComparer.Ordinal); + } + + var parameterHash = 0; + foreach (var (key, value) in _parameters) + { + parameterHash += HashCode.Combine(key, value); + } + + hash.Add(parameterHash); + return hash.ToHashCode(); + } + + private bool PrintMembers(StringBuilder builder) + { + // A parameter value may be a tenant identifier; it is not printed. + builder.Append("Scheme = ").Append(Scheme).Append(", Scopes = [").Append(string.Join(", ", Scopes)).Append(']'); + return true; + } +} diff --git a/src/Dexpace.Sdk.Core/Auth/AuthResolver.cs b/src/Dexpace.Sdk.Core/Auth/AuthResolver.cs new file mode 100644 index 0000000..b8eadec --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/AuthResolver.cs @@ -0,0 +1,48 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Errors; + +namespace Dexpace.Sdk.Core.Auth; + +/// +/// Picks the requirement a call is authenticated with (AUTH-4 to AUTH-7). +/// +/// +/// A pure, stateless function. The tiers are, in order, the per-call descriptor, the operation descriptor and the +/// client descriptor; the first tier that is present is the only one consulted, so a higher tier that cannot be +/// satisfied never falls through to a lower one (AUTH-4). +/// +public static class AuthResolver +{ + /// Resolves the requirement to use. + /// The per-call tier, or . + /// The operation tier, or . + /// The client tier, or . + /// The schemes the caller can serve. + /// The first requirement in preference order that is or available. + /// All three tiers are . + /// No requirement of the selected tier is satisfiable. + public static AuthRequirement Resolve( + AuthDescriptor? perCall, + AuthDescriptor? operation, + AuthDescriptor? client, + IReadOnlyCollection availableSchemes) + { + ArgumentNullException.ThrowIfNull(availableSchemes); + var selected = perCall ?? operation ?? client + ?? throw new ArgumentException("At least one authentication tier must be present."); + + foreach (var requirement in selected.Requirements) + { + if (requirement.Scheme == AuthScheme.NoAuth || availableSchemes.Contains(requirement.Scheme)) + { + return requirement; + } + } + + var required = selected.Requirements.Select(static r => r.Scheme).Distinct().ToArray(); + var available = availableSchemes.Distinct().OrderBy(static s => (int)s).ToArray(); + throw new AuthResolutionException(required, available); + } +} diff --git a/src/Dexpace.Sdk.Core/Auth/AuthScheme.cs b/src/Dexpace.Sdk.Core/Auth/AuthScheme.cs new file mode 100644 index 0000000..c2132e4 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/AuthScheme.cs @@ -0,0 +1,30 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Auth; + +/// +/// The authentication schemes a call can require (AUTH-1). +/// +/// +/// The values are explicit and stable. is never written to the wire, and 0 is deliberately +/// not the permissive value: default(AuthScheme) is , so an uninitialised scheme can never +/// silently mean "anonymous" (P6c-3). +/// +public enum AuthScheme +{ + /// OAuth 2.0 / bearer token authentication. + OAuth2 = 0, + + /// An API key sent in a header. + ApiKey = 1, + + /// HTTP Basic authentication (RFC 7617). + Basic = 2, + + /// HTTP Digest authentication (RFC 7616). + Digest = 3, + + /// No authentication: the call is sent anonymously. + NoAuth = 4, +} diff --git a/src/Dexpace.Sdk.Core/Errors/AuthResolutionException.cs b/src/Dexpace.Sdk.Core/Errors/AuthResolutionException.cs new file mode 100644 index 0000000..5af49bf --- /dev/null +++ b/src/Dexpace.Sdk.Core/Errors/AuthResolutionException.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Collections.Immutable; +using Dexpace.Sdk.Core.Auth; + +namespace Dexpace.Sdk.Core.Errors; + +/// +/// No configured authentication scheme satisfies the requirements of a call (AUTH-6). +/// +/// +/// Raised before anything is sent. It is not a and is never retryable (P6c-41). +/// The message lists scheme names only, never a scope or a parameter. +/// +public sealed class AuthResolutionException : SdkException +{ + /// Initializes the exception. + /// The schemes the selected descriptor accepts, in preference order. + /// The schemes the policy can serve. + public AuthResolutionException(IReadOnlyList required, IReadOnlyList available) + : base(BuildMessage(required, available)) + { + Required = [.. required]; + Available = [.. available]; + } + + /// The schemes the selected descriptor accepts, in preference order. + public IReadOnlyList Required { get; } + + /// The schemes the policy can serve, in enum order. + public IReadOnlyList Available { get; } + + private static string BuildMessage(IReadOnlyList required, IReadOnlyList available) + { + ArgumentNullException.ThrowIfNull(required); + ArgumentNullException.ThrowIfNull(available); + return $"No configured authentication scheme satisfies the operation: required [{string.Join(", ", required)}], " + + $"available [{string.Join(", ", available)}]."; + } +} diff --git a/src/Dexpace.Sdk.Core/Http/Request/RequestOptions.cs b/src/Dexpace.Sdk.Core/Http/Request/RequestOptions.cs index 112cb27..af2c1ac 100644 --- a/src/Dexpace.Sdk.Core/Http/Request/RequestOptions.cs +++ b/src/Dexpace.Sdk.Core/Http/Request/RequestOptions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. See LICENSE in the repository root for details. using System.Collections.Immutable; +using Dexpace.Sdk.Core.Auth; namespace Dexpace.Sdk.Core.Http.Request; @@ -63,6 +64,27 @@ public ImmutableDictionary Tags } } = s_noTags; + /// + /// The per-call authentication tier (AUTH-4): the descriptor the call is authenticated with, taking precedence over + /// and the policy's own descriptor. (the default) defers to them. + /// + /// + /// + /// A descriptor holding sends the call anonymously through any auth policy. + /// + /// + /// Breaking: equality now includes and ; two options differing + /// only in a descriptor were equal before. + /// + /// + public AuthDescriptor? Auth { get; init; } + + /// + /// The operation authentication tier (AUTH-4), set by generated code from the operation's security list; consulted + /// when is . + /// + public AuthDescriptor? OperationAuth { get; init; } + /// Returns a copy with the tag added or replaced. /// The tag key (ordinal). /// The tag value. @@ -90,7 +112,8 @@ public bool Equals(RequestOptions? other) return true; } - if (Timeout != other.Timeout || MaxRetries != other.MaxRetries || Tags.Count != other.Tags.Count) + if (Timeout != other.Timeout || MaxRetries != other.MaxRetries || Tags.Count != other.Tags.Count + || !Equals(Auth, other.Auth) || !Equals(OperationAuth, other.OperationAuth)) { return false; } @@ -115,7 +138,7 @@ public override int GetHashCode() tagHash += HashCode.Combine(key, value); } - return HashCode.Combine(Timeout, MaxRetries, tagHash); + return HashCode.Combine(Timeout, MaxRetries, tagHash, Auth, OperationAuth); } private static TimeSpan? RequireNullOrPositive(TimeSpan? value) diff --git a/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt b/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt index ab389e4..1300f5b 100644 --- a/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt +++ b/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt @@ -72,6 +72,28 @@ Dexpace.Sdk.Core.Auth.ApiKeyCredential.ApiKeyCredential(string! key, Dexpace.Sdk Dexpace.Sdk.Core.Auth.ApiKeyCredential.HeaderName.get -> Dexpace.Sdk.Core.Http.Common.HttpHeaderName! Dexpace.Sdk.Core.Auth.ApiKeyCredential.Key.get -> string! Dexpace.Sdk.Core.Auth.ApiKeyCredential.Scheme.get -> string? +Dexpace.Sdk.Core.Auth.AuthDescriptor +Dexpace.Sdk.Core.Auth.AuthDescriptor.$() -> Dexpace.Sdk.Core.Auth.AuthDescriptor! +Dexpace.Sdk.Core.Auth.AuthDescriptor.AllowsAnonymous.get -> bool +Dexpace.Sdk.Core.Auth.AuthDescriptor.AuthDescriptor(params System.Collections.Generic.IEnumerable! requirements) -> void +Dexpace.Sdk.Core.Auth.AuthDescriptor.Equals(Dexpace.Sdk.Core.Auth.AuthDescriptor? other) -> bool +Dexpace.Sdk.Core.Auth.AuthDescriptor.Requirements.get -> System.Collections.Generic.IReadOnlyList! +Dexpace.Sdk.Core.Auth.AuthRequirement +Dexpace.Sdk.Core.Auth.AuthRequirement.$() -> Dexpace.Sdk.Core.Auth.AuthRequirement! +Dexpace.Sdk.Core.Auth.AuthRequirement.AuthRequirement(Dexpace.Sdk.Core.Auth.AuthScheme scheme) -> void +Dexpace.Sdk.Core.Auth.AuthRequirement.Equals(Dexpace.Sdk.Core.Auth.AuthRequirement? other) -> bool +Dexpace.Sdk.Core.Auth.AuthRequirement.Parameters.get -> System.Collections.Generic.IReadOnlyDictionary! +Dexpace.Sdk.Core.Auth.AuthRequirement.Parameters.init -> void +Dexpace.Sdk.Core.Auth.AuthRequirement.Scheme.get -> Dexpace.Sdk.Core.Auth.AuthScheme +Dexpace.Sdk.Core.Auth.AuthRequirement.Scopes.get -> System.Collections.Generic.IReadOnlyList! +Dexpace.Sdk.Core.Auth.AuthRequirement.Scopes.init -> void +Dexpace.Sdk.Core.Auth.AuthResolver +Dexpace.Sdk.Core.Auth.AuthScheme +Dexpace.Sdk.Core.Auth.AuthScheme.ApiKey = 1 -> Dexpace.Sdk.Core.Auth.AuthScheme +Dexpace.Sdk.Core.Auth.AuthScheme.Basic = 2 -> Dexpace.Sdk.Core.Auth.AuthScheme +Dexpace.Sdk.Core.Auth.AuthScheme.Digest = 3 -> Dexpace.Sdk.Core.Auth.AuthScheme +Dexpace.Sdk.Core.Auth.AuthScheme.NoAuth = 4 -> Dexpace.Sdk.Core.Auth.AuthScheme +Dexpace.Sdk.Core.Auth.AuthScheme.OAuth2 = 0 -> Dexpace.Sdk.Core.Auth.AuthScheme Dexpace.Sdk.Core.Auth.BasicCredential Dexpace.Sdk.Core.Auth.BasicCredential.BasicCredential(string! username, string! password) -> void Dexpace.Sdk.Core.Auth.BasicCredential.Password.get -> string! @@ -202,6 +224,10 @@ Dexpace.Sdk.Core.Diagnostics.UrlRedactor.Redact(string! url) -> string! Dexpace.Sdk.Core.Diagnostics.UrlRedactor.RedactHeaderValue(string! value) -> string! Dexpace.Sdk.Core.Diagnostics.UrlRedactor.UrlRedactor() -> void Dexpace.Sdk.Core.Diagnostics.UrlRedactor.UrlRedactor(System.Collections.Generic.IEnumerable! queryAllowList) -> void +Dexpace.Sdk.Core.Errors.AuthResolutionException +Dexpace.Sdk.Core.Errors.AuthResolutionException.AuthResolutionException(System.Collections.Generic.IReadOnlyList! required, System.Collections.Generic.IReadOnlyList! available) -> void +Dexpace.Sdk.Core.Errors.AuthResolutionException.Available.get -> System.Collections.Generic.IReadOnlyList! +Dexpace.Sdk.Core.Errors.AuthResolutionException.Required.get -> System.Collections.Generic.IReadOnlyList! Dexpace.Sdk.Core.Errors.BodyTooLargeException Dexpace.Sdk.Core.Errors.BodyTooLargeException.BodyTooLargeException() -> void Dexpace.Sdk.Core.Errors.BodyTooLargeException.BodyTooLargeException(string! message) -> void @@ -557,6 +583,12 @@ Dexpace.Sdk.Core.Serialization.IStringSerde Dexpace.Sdk.Core.Serialization.IStringSerde.SerializeToString(T value) -> string! Dexpace.Sdk.Core.Serialization.ResponseBodySerdeExtensions Dexpace.Sdk.Core.Serialization.SerdeExtensions +override Dexpace.Sdk.Core.Auth.AuthDescriptor.Equals(object? obj) -> bool +override Dexpace.Sdk.Core.Auth.AuthDescriptor.GetHashCode() -> int +override Dexpace.Sdk.Core.Auth.AuthDescriptor.ToString() -> string! +override Dexpace.Sdk.Core.Auth.AuthRequirement.Equals(object? obj) -> bool +override Dexpace.Sdk.Core.Auth.AuthRequirement.GetHashCode() -> int +override Dexpace.Sdk.Core.Auth.AuthRequirement.ToString() -> string! override Dexpace.Sdk.Core.Configuration.HttpLoggingOptions.Equals(object? obj) -> bool override Dexpace.Sdk.Core.Configuration.HttpLoggingOptions.GetHashCode() -> int override Dexpace.Sdk.Core.Configuration.HttpLoggingOptions.ToString() -> string! @@ -633,6 +665,12 @@ override sealed Dexpace.Sdk.Core.Errors.ServiceResponseException.IsRetryable.get override sealed Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy.Stage.get -> Dexpace.Sdk.Core.Pipeline.PipelineStage override sealed Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy.Process(Dexpace.Sdk.Core.Http.Request.Request! request, Dexpace.Sdk.Core.Pipeline.PipelineContext! context, Dexpace.Sdk.Core.Pipeline.PipelineRunner continuation) -> Dexpace.Sdk.Core.Http.Response.Response! override sealed Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy.ProcessAsync(Dexpace.Sdk.Core.Http.Request.Request! request, Dexpace.Sdk.Core.Pipeline.PipelineContext! context, Dexpace.Sdk.Core.Pipeline.PipelineRunner continuation) -> System.Threading.Tasks.ValueTask +static Dexpace.Sdk.Core.Auth.AuthDescriptor.operator !=(Dexpace.Sdk.Core.Auth.AuthDescriptor? left, Dexpace.Sdk.Core.Auth.AuthDescriptor? right) -> bool +static Dexpace.Sdk.Core.Auth.AuthDescriptor.operator ==(Dexpace.Sdk.Core.Auth.AuthDescriptor? left, Dexpace.Sdk.Core.Auth.AuthDescriptor? right) -> bool +static Dexpace.Sdk.Core.Auth.AuthRequirement.NoAuth.get -> Dexpace.Sdk.Core.Auth.AuthRequirement! +static Dexpace.Sdk.Core.Auth.AuthRequirement.operator !=(Dexpace.Sdk.Core.Auth.AuthRequirement? left, Dexpace.Sdk.Core.Auth.AuthRequirement? right) -> bool +static Dexpace.Sdk.Core.Auth.AuthRequirement.operator ==(Dexpace.Sdk.Core.Auth.AuthRequirement? left, Dexpace.Sdk.Core.Auth.AuthRequirement? right) -> bool +static Dexpace.Sdk.Core.Auth.AuthResolver.Resolve(Dexpace.Sdk.Core.Auth.AuthDescriptor? perCall, Dexpace.Sdk.Core.Auth.AuthDescriptor? operation, Dexpace.Sdk.Core.Auth.AuthDescriptor? client, System.Collections.Generic.IReadOnlyCollection! availableSchemes) -> Dexpace.Sdk.Core.Auth.AuthRequirement! static Dexpace.Sdk.Core.Client.DelegateHttpClient.Create(System.Func!>! send) -> Dexpace.Sdk.Core.Client.IAsyncHttpClient! static Dexpace.Sdk.Core.Client.DelegateHttpClient.CreateBlocking(System.Func! send) -> Dexpace.Sdk.Core.Client.IHttpClient! static Dexpace.Sdk.Core.Client.HttpClientExtensions.AsAsync(this Dexpace.Sdk.Core.Client.IHttpClient! client, System.Threading.Tasks.TaskScheduler! scheduler) -> Dexpace.Sdk.Core.Client.IAsyncHttpClient! @@ -924,6 +962,10 @@ Dexpace.Sdk.Core.Errors.OperationTimeoutException.OperationTimeoutException() -> Dexpace.Sdk.Core.Errors.OperationTimeoutException.OperationTimeoutException(string! message) -> void Dexpace.Sdk.Core.Errors.OperationTimeoutException.OperationTimeoutException(string! message, System.Exception? innerException) -> void Dexpace.Sdk.Core.Errors.SdkException.Suppressed.get -> System.Collections.Generic.IReadOnlyList! +Dexpace.Sdk.Core.Http.Request.RequestOptions.Auth.get -> Dexpace.Sdk.Core.Auth.AuthDescriptor? +Dexpace.Sdk.Core.Http.Request.RequestOptions.Auth.init -> void +Dexpace.Sdk.Core.Http.Request.RequestOptions.OperationAuth.get -> Dexpace.Sdk.Core.Auth.AuthDescriptor? +Dexpace.Sdk.Core.Http.Request.RequestOptions.OperationAuth.init -> void override Dexpace.Sdk.Core.Errors.SdkException.ToString() -> string! override Dexpace.Sdk.Core.Pipeline.Policies.RetryAttemptContext.GetHashCode() -> int static Dexpace.Sdk.Core.Errors.ExceptionFacts.EnumerateCauses(System.Exception! exception) -> System.Collections.Generic.IEnumerable! diff --git a/tests/Dexpace.Sdk.Core.Tests/Auth/AuthDescriptorTests.cs b/tests/Dexpace.Sdk.Core.Tests/Auth/AuthDescriptorTests.cs new file mode 100644 index 0000000..a53645c --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Auth/AuthDescriptorTests.cs @@ -0,0 +1,163 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Globalization; +using System.Reflection; +using System.Runtime.CompilerServices; +using Dexpace.Sdk.Core.Auth; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Auth; + +/// AUTH-1 to AUTH-3: the scheme enum, the requirement and the descriptor. +[Trait("Category", "Unit")] +public sealed class AuthDescriptorTests +{ + [Fact] + public void AuthScheme_values_are_explicit_and_exactly_five() + { + Assert.Equal(0, (int)AuthScheme.OAuth2); + Assert.Equal(1, (int)AuthScheme.ApiKey); + Assert.Equal(2, (int)AuthScheme.Basic); + Assert.Equal(3, (int)AuthScheme.Digest); + Assert.Equal(4, (int)AuthScheme.NoAuth); + Assert.Equal(5, Enum.GetValues().Length); + Assert.Equal(AuthScheme.OAuth2, default(AuthScheme)); + Assert.NotEqual(AuthScheme.NoAuth, default(AuthScheme)); + } + + [Fact] + public void A_requirement_rejects_an_undefined_scheme() + { + Assert.Throws(() => new AuthRequirement((AuthScheme)99)); + } + + [Fact] + public void NoAuth_is_the_anonymous_sentinel() + { + Assert.Equal(AuthScheme.NoAuth, AuthRequirement.NoAuth.Scheme); + Assert.Equal(AuthRequirement.NoAuth, AuthRequirement.NoAuth); + } + + [Fact] + public void Scopes_and_parameters_are_copied_at_init() + { + var scopes = new List { "a" }; + var parameters = new Dictionary { ["k"] = "v" }; + var requirement = new AuthRequirement(AuthScheme.OAuth2) { Scopes = scopes, Parameters = parameters }; + + scopes.Add("b"); + parameters["k2"] = "v2"; + + Assert.Equal(["a"], requirement.Scopes); + Assert.Single(requirement.Parameters); + Assert.Empty(new AuthRequirement(AuthScheme.Basic).Scopes); + Assert.Empty(new AuthRequirement(AuthScheme.Basic).Parameters); + Assert.Throws(() => new AuthRequirement(AuthScheme.OAuth2) { Scopes = new string?[] { null }! }); + } + + [Fact] + public void Scopes_and_parameters_are_preserved_and_never_interpreted() + { + var requirement = new AuthRequirement(AuthScheme.OAuth2) + { + Scopes = ["a", "b"], + Parameters = new Dictionary { ["k"] = "v" }, + }; + + Assert.Equal(["a", "b"], requirement.Scopes); + Assert.Equal("v", requirement.Parameters["k"]); + } + + [Fact] + public void Requirements_with_equal_content_are_equal_and_hash_alike() + { + var a = new AuthRequirement(AuthScheme.OAuth2) { Scopes = new List { "x", "y" }, Parameters = new Dictionary { ["k"] = "v" } }; + var b = new AuthRequirement(AuthScheme.OAuth2) { Scopes = new List { "x", "y" }, Parameters = new Dictionary { ["k"] = "v" } }; + + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + Assert.NotEqual(a, a with { Scopes = ["y", "x"] }); + Assert.NotEqual(a, a with { Parameters = new Dictionary { ["k"] = "other" } }); + } + + [Fact] + public void A_requirement_does_not_print_a_parameter_value() + { + var requirement = new AuthRequirement(AuthScheme.ApiKey) { Parameters = new Dictionary { ["tenant"] = "t-Zx9" } }; + + Assert.DoesNotContain("t-Zx9", requirement.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void A_descriptor_copies_its_requirements_and_exposes_them_read_only() + { + var source = new[] { new AuthRequirement(AuthScheme.Basic), new AuthRequirement(AuthScheme.Digest) }; + var descriptor = new AuthDescriptor(source); + + source[0] = AuthRequirement.NoAuth; + + Assert.Equal(AuthScheme.Basic, descriptor.Requirements[0].Scheme); + // ImmutableArray implements IList explicitly; every mutator throws. + Assert.Throws(() => ((IList)descriptor.Requirements).Add(AuthRequirement.NoAuth)); + } + + [Fact] + public void An_empty_descriptor_or_a_null_element_throws_ArgumentException() + { + Assert.Throws(() => new AuthDescriptor(Array.Empty())); + Assert.Throws(() => new AuthDescriptor(new AuthRequirement?[] { null }!)); + } + + [Fact] + public void AllowsAnonymous_is_true_iff_any_requirement_is_NoAuth() + { + Assert.True(new AuthDescriptor(AuthRequirement.NoAuth).AllowsAnonymous); + Assert.True(new AuthDescriptor(new AuthRequirement(AuthScheme.Basic), AuthRequirement.NoAuth).AllowsAnonymous); + Assert.False(new AuthDescriptor(new AuthRequirement(AuthScheme.Basic)).AllowsAnonymous); + } + + [Fact] + public void Descriptors_with_the_same_ordered_requirements_are_equal() + { + var a = new AuthRequirement(AuthScheme.Basic); + var b = new AuthRequirement(AuthScheme.Digest); + + Assert.Equal(new AuthDescriptor(a, b), new AuthDescriptor(a, b)); + Assert.Equal(new AuthDescriptor(a, b).GetHashCode(), new AuthDescriptor(a, b).GetHashCode()); + Assert.NotEqual(new AuthDescriptor(a, b), new AuthDescriptor(b, a)); + } + + [Fact] + public void A_descriptor_has_no_init_member_so_with_cannot_empty_it() + { + foreach (var property in typeof(AuthDescriptor).GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + var setter = property.SetMethod; + if (setter is null) + { + continue; + } + + Assert.DoesNotContain( + setter.ReturnParameter.GetRequiredCustomModifiers(), + modifier => modifier == typeof(IsExternalInit)); + } + } + + [Fact] + public void Scheme_names_print_without_culture_dependence() + { + using var _ = new CultureScope("tr-TR"); + Assert.Contains("Digest", new AuthDescriptor(new AuthRequirement(AuthScheme.Digest)).ToString(), StringComparison.Ordinal); + } + + private sealed class CultureScope : IDisposable + { + private readonly CultureInfo _previous = CultureInfo.CurrentCulture; + + public CultureScope(string name) => CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(name); + + public void Dispose() => CultureInfo.CurrentCulture = _previous; + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Auth/AuthResolverTests.cs b/tests/Dexpace.Sdk.Core.Tests/Auth/AuthResolverTests.cs new file mode 100644 index 0000000..1ad77ba --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Auth/AuthResolverTests.cs @@ -0,0 +1,113 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Reflection; +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Errors; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Auth; + +/// AUTH-4 to AUTH-7: the tiers, the preference order and the statelessness of the resolver. +[Trait("Category", "Unit")] +public sealed class AuthResolverTests +{ + private static AuthDescriptor Of(params AuthScheme[] schemes) => + new(schemes.Select(static s => new AuthRequirement(s))); + + [Fact] + public void Per_call_wins_over_operation_and_client() + { + var chosen = AuthResolver.Resolve(Of(AuthScheme.Basic), Of(AuthScheme.Digest), Of(AuthScheme.OAuth2), [AuthScheme.Basic, AuthScheme.Digest, AuthScheme.OAuth2]); + + Assert.Equal(AuthScheme.Basic, chosen.Scheme); + } + + [Fact] + public void Operation_wins_over_client() + { + var chosen = AuthResolver.Resolve(null, Of(AuthScheme.Digest), Of(AuthScheme.OAuth2), [AuthScheme.Digest, AuthScheme.OAuth2]); + + Assert.Equal(AuthScheme.Digest, chosen.Scheme); + } + + [Fact] + public void Client_is_used_when_the_others_are_absent() + { + var chosen = AuthResolver.Resolve(null, null, Of(AuthScheme.OAuth2), [AuthScheme.OAuth2]); + + Assert.Equal(AuthScheme.OAuth2, chosen.Scheme); + } + + [Fact] + public void A_higher_tier_that_cannot_be_satisfied_does_not_fall_through() + { + Assert.Throws( + () => AuthResolver.Resolve(Of(AuthScheme.Digest), null, Of(AuthScheme.OAuth2), [AuthScheme.OAuth2])); + } + + [Fact] + public void The_first_satisfiable_requirement_in_preference_order_wins() + { + var chosen = AuthResolver.Resolve(null, null, Of(AuthScheme.Digest, AuthScheme.OAuth2, AuthScheme.Basic), [AuthScheme.OAuth2, AuthScheme.Basic]); + + Assert.Equal(AuthScheme.OAuth2, chosen.Scheme); + } + + [Fact] + public void NoAuth_is_always_satisfiable() + { + Assert.Equal(AuthScheme.NoAuth, AuthResolver.Resolve(null, null, Of(AuthScheme.NoAuth), []).Scheme); + Assert.Equal(AuthScheme.NoAuth, AuthResolver.Resolve(null, null, Of(AuthScheme.Digest, AuthScheme.NoAuth), []).Scheme); + } + + [Fact] + public void No_credential_is_inspected() + { + var method = typeof(AuthResolver).GetMethod(nameof(AuthResolver.Resolve))!; + + Assert.All( + method.GetParameters(), + p => Assert.DoesNotContain("Credential", p.ParameterType.Name, StringComparison.Ordinal)); + Assert.Equal(typeof(IReadOnlyCollection), method.GetParameters()[3].ParameterType); + } + + [Fact] + public void All_tiers_absent_throws_ArgumentException() + { + Assert.Throws(() => AuthResolver.Resolve(null, null, null, [AuthScheme.Basic])); + } + + [Fact] + public void Nothing_satisfiable_throws_AuthResolutionException_with_required_and_available() + { + var descriptor = new AuthDescriptor( + new AuthRequirement(AuthScheme.Digest), + new AuthRequirement(AuthScheme.OAuth2) { Scopes = ["secret-scope"] }); + + var ex = Assert.Throws( + () => AuthResolver.Resolve(null, null, descriptor, [AuthScheme.Basic, AuthScheme.ApiKey])); + + Assert.Equal([AuthScheme.Digest, AuthScheme.OAuth2], ex.Required); + Assert.Equal([AuthScheme.ApiKey, AuthScheme.Basic], ex.Available); + Assert.Contains("[Digest, OAuth2]", ex.Message, StringComparison.Ordinal); + Assert.Contains("[ApiKey, Basic]", ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain("secret-scope", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Resolve_is_safe_under_concurrent_calls_on_one_descriptor() + { + var descriptor = Of(AuthScheme.Digest, AuthScheme.Basic); + var tasks = Enumerable.Range(0, 64) + .Select(_ => Task.Run(() => AuthResolver.Resolve(null, null, descriptor, [AuthScheme.Basic]), TestContext.Current.CancellationToken)) + .ToArray(); + + var results = await Task.WhenAll(tasks); + + Assert.All(results, r => Assert.Equal(AuthScheme.Basic, r.Scheme)); + var type = typeof(AuthResolver); + Assert.True(type.IsAbstract && type.IsSealed); + Assert.Empty(type.GetFields(BindingFlags.Static | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Http/Request/RequestOptionsTests.cs b/tests/Dexpace.Sdk.Core.Tests/Http/Request/RequestOptionsTests.cs index 304868c..e5748fb 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Http/Request/RequestOptionsTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Http/Request/RequestOptionsTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. See LICENSE in the repository root for details. using System.Collections.Immutable; +using Dexpace.Sdk.Core.Auth; using Dexpace.Sdk.Core.Http.Request; using Xunit; @@ -158,4 +159,52 @@ public void WithTag_validates_the_key_and_value_are_not_null() Assert.Throws(() => RequestOptions.Empty.WithTag(null!, "v")); Assert.Throws(() => RequestOptions.Empty.WithTag("k", null!)); } + + [Fact] + public void Auth_and_OperationAuth_default_to_null() + { + Assert.Null(RequestOptions.Empty.Auth); + Assert.Null(RequestOptions.Empty.OperationAuth); + } + + [Fact] + public void Two_options_with_equal_descriptors_are_equal() + { + var a = new RequestOptions { Auth = new AuthDescriptor(new AuthRequirement(AuthScheme.Basic)) }; + var b = new RequestOptions { Auth = new AuthDescriptor(new AuthRequirement(AuthScheme.Basic)) }; + + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void Options_differing_only_in_Auth_are_unequal() + { + var a = new RequestOptions { Auth = new AuthDescriptor(new AuthRequirement(AuthScheme.Basic)) }; + + Assert.NotEqual(a, RequestOptions.Empty); + Assert.NotEqual(a, a with { Auth = new AuthDescriptor(new AuthRequirement(AuthScheme.Digest)) }); + } + + [Fact] + public void Options_differing_only_in_OperationAuth_are_unequal() + { + var a = new RequestOptions { OperationAuth = new AuthDescriptor(AuthRequirement.NoAuth) }; + + Assert.NotEqual(a, RequestOptions.Empty); + } + + [Fact] + public void With_sets_each_tier_without_touching_the_other() + { + var call = new AuthDescriptor(new AuthRequirement(AuthScheme.ApiKey)); + var operation = new AuthDescriptor(new AuthRequirement(AuthScheme.OAuth2)); + + var withCall = RequestOptions.Empty with { Auth = call }; + var both = withCall with { OperationAuth = operation }; + + Assert.Same(call, both.Auth); + Assert.Same(operation, both.OperationAuth); + Assert.Null(withCall.OperationAuth); + } } From 55d41adc0cd95ee735c6ad5b15a5f284d12408b6 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 8 Oct 2026 20:14:14 +0300 Subject: [PATCH 02/13] feat!: phase 6c credentials (AUTH-8..AUTH-10) --- CHANGELOG.md | 10 + src/Dexpace.Sdk.Core/Auth/AccessToken.cs | 101 +++++++-- src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs | 2 +- src/Dexpace.Sdk.Core/Auth/ApiKeyCredential.cs | 62 ++--- src/Dexpace.Sdk.Core/Auth/AuthCredentials.cs | 79 +++++++ src/Dexpace.Sdk.Core/Auth/BasicCredential.cs | 58 +++-- src/Dexpace.Sdk.Core/Auth/DigestCredential.cs | 48 ++++ .../Auth/TokenRequestContext.cs | 15 +- src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt | 40 +++- .../Auth/CredentialTypesTests.cs | 212 +++++++++++++++++- .../Pipeline/Policies/BasicAuthPolicyTests.cs | 14 +- .../Security/CredentialRedactionTests.cs | 140 ++++++++++++ 12 files changed, 693 insertions(+), 88 deletions(-) create mode 100644 src/Dexpace.Sdk.Core/Auth/AuthCredentials.cs create mode 100644 src/Dexpace.Sdk.Core/Auth/DigestCredential.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Security/CredentialRedactionTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 276b5cf..170f803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -284,6 +284,14 @@ Roadmap phase 1, defects S1–S9, each pinned by a `[Trait("Category", "Security the deadline. - **Breaking:** `RequestOptions` equality now includes the new `Auth` and `OperationAuth` descriptors (`AUTH-4`); two options differing only in a descriptor were equal before. +- **Breaking:** `AccessToken.ExpiresOn` is `DateTimeOffset?` (a token may never expire); the token must be non-blank; equality + is by value (token, expiry, refresh) with `==`/`!=`; `ToString` redacts the token (`AUTH-8`..`AUTH-10`). +- **Breaking:** `ApiKeyCredential` rejects a whitespace-only key, a blank or whitespace-containing `Scheme`, and a key or + scheme the outbound header grammar refuses, at construction (was: an empty key only); `ToString` redacts the key + (`AUTH-26`, `XCUT-18`). +- **Breaking:** `BasicCredential` rejects an empty username or password and a `:` in the username (was: `null` only); + `ToString` redacts the password (`AUTH-14`). +- **Breaking:** `TokenRequestContext.Scopes` is a copy of the caller's list (was: the list itself). - **Breaking (phase 6b redirect, PR 1):** `RedirectOptions` is reshaped (`REDIR-3`, `REDIR-4`, `REDIR-5`, `REDIR-15`, `REDIR-17`, `REDIR-20`, `REDIR-26`). `StripSensitiveHeadersOnCrossOrigin` is removed (it has had no effect since phase 1; stripping always @@ -495,5 +503,7 @@ Roadmap phase 1, defects S1–S9, each pinned by a `[Trait("Category", "Security - Phase 6c descriptor and resolver: `AuthScheme`, `AuthRequirement`, `AuthDescriptor`, `AuthResolver` (per-call, operation and client tiers, no fall-through), `AuthResolutionException`, and the `RequestOptions.Auth` / `OperationAuth` carriers (`AUTH-1`..`AUTH-7`). +- `DigestCredential` and `AuthCredentials` (with a `TokenRefreshMargin`); `CredentialRedactionTests` (`Security`) pins that no + credential type formats its secret (`XCUT-19`(d)). [Unreleased]: https://github.com/dexpace/dotnet-sdk/commits/main diff --git a/src/Dexpace.Sdk.Core/Auth/AccessToken.cs b/src/Dexpace.Sdk.Core/Auth/AccessToken.cs index 1231a66..e8d2a49 100644 --- a/src/Dexpace.Sdk.Core/Auth/AccessToken.cs +++ b/src/Dexpace.Sdk.Core/Auth/AccessToken.cs @@ -1,53 +1,110 @@ // Copyright (c) 2026 dexpace and Omar Aljarrah. // Licensed under the MIT License. See LICENSE in the repository root for details. +using System.Globalization; + namespace Dexpace.Sdk.Core.Auth; /// -/// An access token returned by a , together with its expiry -/// and an optional proactive-refresh hint. +/// An access token returned by a , together with its optional expiry and an optional +/// proactive-refresh hint. /// -public readonly struct AccessToken +/// +/// +/// Equality is by value over the token, the expiry and the refresh hint (AUTH-8, P6c-21). +/// redacts the token. +/// +/// +/// Breaking: was a non-nullable (a token may now never +/// expire); the token must now be non-blank (was: non-null); the struct had no equality and +/// printed the type name. +/// +/// +public readonly struct AccessToken : IEquatable { - /// - /// Initializes an with an expiry and no proactive-refresh hint. - /// + /// Initializes a token that never expires. /// The raw token string. - /// The time at which this token becomes invalid. + /// is empty or whitespace. /// is . - public AccessToken(string token, DateTimeOffset expiresOn) + public AccessToken(string token) + : this(token, null, null) + { + } + + /// Initializes a token with an optional expiry and no proactive-refresh hint. + /// The raw token string. + /// The time at which this token becomes invalid, or if it never does. + /// is empty or whitespace. + /// is . + public AccessToken(string token, DateTimeOffset? expiresOn) : this(token, expiresOn, null) { } - /// - /// Initializes an with an expiry and an optional proactive-refresh hint. - /// + /// Initializes a token with an optional expiry and an optional proactive-refresh hint. /// The raw token string. - /// The time at which this token becomes invalid. + /// The time at which this token becomes invalid, or if it never does. /// - /// An optional hint: when the clock reaches this value the token cache should proactively - /// refresh, even though has not been reached. + /// An optional hint: when the clock reaches this value the token cache should proactively refresh, even though + /// has not been reached. /// + /// is empty or whitespace. /// is . - public AccessToken(string token, DateTimeOffset expiresOn, DateTimeOffset? refreshOn) + public AccessToken(string token, DateTimeOffset? expiresOn, DateTimeOffset? refreshOn) { - ArgumentNullException.ThrowIfNull(token); + ArgumentException.ThrowIfNullOrWhiteSpace(token); Token = token; ExpiresOn = expiresOn; RefreshOn = refreshOn; } - /// The raw token value. + /// The raw token value. only for default(AccessToken). public string Token { get; } - /// The time at which this token becomes invalid. - public DateTimeOffset ExpiresOn { get; } + /// The time at which this token becomes invalid, or if it never expires (AUTH-10). + public DateTimeOffset? ExpiresOn { get; } /// - /// An optional proactive-refresh hint. When non-, the token cache - /// begins refreshing once the clock passes this value, even though - /// has not yet been reached. + /// An optional proactive-refresh hint. When non-, the token cache begins refreshing once the + /// clock passes this value, even though has not yet been reached. /// public DateTimeOffset? RefreshOn { get; } + + /// Compares two tokens by value. + /// The first token. + /// The second token. + /// when they are equal. + public static bool operator ==(AccessToken left, AccessToken right) => left.Equals(right); + + /// Compares two tokens by value. + /// The first token. + /// The second token. + /// when they differ. + public static bool operator !=(AccessToken left, AccessToken right) => !left.Equals(right); + + /// Whether the token is expired at once is allowed for (AUTH-10). + /// The current time. + /// How early to treat the token as expired. + /// + /// when plus is strictly after + /// ; a token without an expiry is never expired. + /// + public bool IsExpired(DateTimeOffset now, TimeSpan margin) => ExpiresOn is { } expiresOn && now + margin > expiresOn; + + /// + public bool Equals(AccessToken other) => + string.Equals(Token, other.Token, StringComparison.Ordinal) && ExpiresOn == other.ExpiresOn && RefreshOn == other.RefreshOn; + + /// + public override bool Equals(object? obj) => obj is AccessToken other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(Token, ExpiresOn, RefreshOn); + + /// Renders the token with its value redacted (AUTH-8). + /// A description that never contains the token. + public override string ToString() => + string.Create( + CultureInfo.InvariantCulture, + $"AccessToken {{ Token = ***, ExpiresOn = {ExpiresOn?.ToString("O", CultureInfo.InvariantCulture)}, RefreshOn = {RefreshOn?.ToString("O", CultureInfo.InvariantCulture)} }}"); } diff --git a/src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs b/src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs index b2a73af..305840a 100644 --- a/src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs +++ b/src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs @@ -123,7 +123,7 @@ public async ValueTask GetAsync(TokenRequestContext context, Cancel // A token is "valid" while the clock hasn't yet reached ExpiresOn. private static bool IsValid(AccessToken token, DateTimeOffset now) => - now < token.ExpiresOn; + token.ExpiresOn is not { } expiresOn || now < expiresOn; // A token "needs refresh" once RefreshOn has been reached (proactive hint). private static bool NeedsRefresh(AccessToken token, DateTimeOffset now) => diff --git a/src/Dexpace.Sdk.Core/Auth/ApiKeyCredential.cs b/src/Dexpace.Sdk.Core/Auth/ApiKeyCredential.cs index 223d969..1452ed5 100644 --- a/src/Dexpace.Sdk.Core/Auth/ApiKeyCredential.cs +++ b/src/Dexpace.Sdk.Core/Auth/ApiKeyCredential.cs @@ -6,53 +6,59 @@ namespace Dexpace.Sdk.Core.Auth; /// -/// A credential that authenticates requests by stamping a static API key into an HTTP header. +/// An API key sent in a request header, optionally behind a scheme prefix (SharedAccessKey <key>, AUTH-26). /// /// -/// By default the key is sent in the Authorization header with no scheme prefix, i.e. -/// the header value is exactly . Specify a scheme to add a prefix, e.g. -/// "Bearer" produces Authorization: Bearer <key>. Pass a custom header -/// name to use a non-standard header such as X-Api-Key. +/// +/// The stamped value (the key, or "<scheme> <key>") is computed once and checked against the outbound +/// header grammar at construction (XCUT-18). There is no separate named-key credential in this port: a prefixed key is +/// new ApiKeyCredential(key, scheme: "SharedAccessKey") (P6c-19). redacts the key. +/// +/// +/// Breaking: a whitespace-only key, a blank scheme or a scheme containing whitespace, and a key or scheme the +/// outbound header grammar refuses, are now rejected at construction (was: only an empty key, with the rest failing per +/// request or never). +/// /// public sealed class ApiKeyCredential { - /// - /// Initializes an . - /// - /// The API key value. Must not be null or empty. - /// - /// The header to stamp. Defaults to when - /// . Breaking: is now a class, so this parameter is a - /// nullable reference rather than a Nullable<HttpHeaderName>. - /// - /// - /// An optional scheme prefix (e.g. "Bearer"). When the key - /// is used as the entire header value. - /// + /// Initializes an . + /// The API key. Must not be null, empty or whitespace. + /// The header to send the key in. Defaults to Authorization. + /// An optional prefix; non-blank and free of whitespace when set. /// is . - /// is empty. + /// + /// is blank, is blank or contains whitespace, or the resulting header + /// value is not valid outbound header text. The message never contains the key. + /// public ApiKeyCredential(string key, HttpHeaderName? header = null, string? scheme = null) { - ArgumentNullException.ThrowIfNull(key); - if (key.Length == 0) + ArgumentException.ThrowIfNullOrWhiteSpace(key); + if (scheme is not null && (string.IsNullOrWhiteSpace(scheme) || scheme.Any(char.IsWhiteSpace))) { - throw new ArgumentException("API key must not be empty.", nameof(key)); + throw new ArgumentException("The scheme must be non-blank and contain no whitespace.", nameof(scheme)); } Key = key; HeaderName = header ?? HttpHeaderName.WellKnown.Authorization; Scheme = scheme; + HeaderValue = scheme is null ? key : scheme + " " + key; + HeaderSyntax.ValidateOutboundValue(HeaderValue, HeaderName.Original, nameof(key)); } - /// The raw API key value. + /// The API key. public string Key { get; } - /// The HTTP header into which the key is stamped. + /// The header the key is sent in. public HttpHeaderName HeaderName { get; } - /// - /// The optional scheme prefix. When non-, the header value is - /// "<Scheme> <Key>"; otherwise the header value is exactly . - /// + /// The optional scheme prefix, or when the key is sent bare. public string? Scheme { get; } + + /// The value stamped on the wire, computed once. + internal string HeaderValue { get; } + + /// Renders the credential with the key redacted (AUTH-8). + /// A description that never contains the key. + public override string ToString() => $"ApiKeyCredential {{ Key = ***, HeaderName = {HeaderName.Original}, Scheme = {Scheme} }}"; } diff --git a/src/Dexpace.Sdk.Core/Auth/AuthCredentials.cs b/src/Dexpace.Sdk.Core/Auth/AuthCredentials.cs new file mode 100644 index 0000000..bd0298a --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/AuthCredentials.cs @@ -0,0 +1,79 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text; + +namespace Dexpace.Sdk.Core.Auth; + +/// +/// The credentials a multi-scheme auth policy can serve, one optional member per scheme (AUTH-8). +/// +/// +/// lists which schemes are configured and never renders a member (P6c-20). +/// +public sealed record AuthCredentials +{ + private static readonly TimeSpan s_defaultMargin = TimeSpan.FromSeconds(30); + + private TimeSpan _tokenRefreshMargin = s_defaultMargin; + + /// The OAuth 2.0 token credential, or . + public TokenCredential? Token { get; init; } + + /// The API-key credential, or . + public ApiKeyCredential? ApiKey { get; init; } + + /// The Basic credential, or . + public BasicCredential? Basic { get; init; } + + /// The Digest credential, or . + public DigestCredential? Digest { get; init; } + + /// How long before expiry a bearer token is refreshed in the background; 30 seconds by default. + /// The value is negative. + public TimeSpan TokenRefreshMargin + { + get => _tokenRefreshMargin; + init + { + ArgumentOutOfRangeException.ThrowIfLessThan(value, TimeSpan.Zero); + _tokenRefreshMargin = value; + } + } + + /// The schemes that have a credential, in enum order. + internal IReadOnlyList Available + { + get + { + var schemes = new List(4); + if (Token is not null) + { + schemes.Add(AuthScheme.OAuth2); + } + + if (ApiKey is not null) + { + schemes.Add(AuthScheme.ApiKey); + } + + if (Basic is not null) + { + schemes.Add(AuthScheme.Basic); + } + + if (Digest is not null) + { + schemes.Add(AuthScheme.Digest); + } + + return schemes; + } + } + + private bool PrintMembers(StringBuilder builder) + { + builder.Append("Schemes = [").Append(string.Join(", ", Available)).Append(']'); + return true; + } +} diff --git a/src/Dexpace.Sdk.Core/Auth/BasicCredential.cs b/src/Dexpace.Sdk.Core/Auth/BasicCredential.cs index c60060b..a3b2e64 100644 --- a/src/Dexpace.Sdk.Core/Auth/BasicCredential.cs +++ b/src/Dexpace.Sdk.Core/Auth/BasicCredential.cs @@ -6,45 +6,67 @@ namespace Dexpace.Sdk.Core.Auth; /// -/// A credential that authenticates requests using the HTTP Basic scheme (RFC 7617). +/// A username and password for HTTP Basic authentication (RFC 7617). /// /// -/// The credential stores the username and password in plain text. Call -/// to obtain the Base64-encoded username:password token suitable for the -/// Authorization: Basic <token> header value. +/// +/// Both parts must be non-empty; whitespace is allowed (AUTH-14). A : in the username is rejected because RFC 7617 +/// §2 forbids it (P6c-17). redacts the password. +/// +/// +/// Breaking: an empty username or password and a colon in the username are now rejected at construction (was: +/// only ). +/// /// public sealed class BasicCredential { - /// - /// Initializes a with the given username and password. - /// - /// The username. Must not be . - /// The password. Must not be . - /// - /// or is . - /// + /// Initializes a . + /// The user-id. Non-empty, without a colon. + /// The password. Non-empty. + /// An argument is . + /// An argument is empty, or the username contains a colon. public BasicCredential(string username, string password) { ArgumentNullException.ThrowIfNull(username); ArgumentNullException.ThrowIfNull(password); + if (username.Length == 0) + { + throw new ArgumentException("The username must not be empty.", nameof(username)); + } + + if (password.Length == 0) + { + throw new ArgumentException("The password must not be empty.", nameof(password)); + } + + if (username.Contains(':', StringComparison.Ordinal)) + { + throw new ArgumentException("The username must not contain a colon (RFC 7617 section 2).", nameof(username)); + } + Username = username; Password = password; + HeaderValue = "Basic " + ToBase64(); } - /// The username. + /// The user-id. public string Username { get; } /// The password. public string Password { get; } - /// - /// Returns the Base64-encoded UTF-8 username:password token for use in the - /// Authorization: Basic <token> header value. - /// - /// The Base64-encoded credentials. + /// The complete Authorization value, computed once and shared by the policy and the challenge handler. + internal string HeaderValue { get; } + + /// Encodes username:password as UTF-8 Base64. + /// The Base64 text. public string ToBase64() { var bytes = Encoding.UTF8.GetBytes($"{Username}:{Password}"); return Convert.ToBase64String(bytes); } + + /// Renders the credential with the password redacted (AUTH-8). + /// A description that never contains the password. + public override string ToString() => $"BasicCredential {{ Username = {Username}, Password = *** }}"; } diff --git a/src/Dexpace.Sdk.Core/Auth/DigestCredential.cs b/src/Dexpace.Sdk.Core/Auth/DigestCredential.cs new file mode 100644 index 0000000..1f8deaa --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/DigestCredential.cs @@ -0,0 +1,48 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Auth; + +/// +/// A username and password for HTTP Digest authentication (RFC 7616). +/// +/// +/// Both parts must be non-empty. No encoding check happens at construction: whether a password is representable depends +/// on the challenge's charset, so the Digest challenge handler decides (P6c-32). +/// redacts the password. +/// +public sealed class DigestCredential +{ + /// Initializes a . + /// The user name. Non-empty. + /// The password. Non-empty. + /// An argument is . + /// An argument is empty. + public DigestCredential(string username, string password) + { + ArgumentNullException.ThrowIfNull(username); + ArgumentNullException.ThrowIfNull(password); + if (username.Length == 0) + { + throw new ArgumentException("The username must not be empty.", nameof(username)); + } + + if (password.Length == 0) + { + throw new ArgumentException("The password must not be empty.", nameof(password)); + } + + Username = username; + Password = password; + } + + /// The user name. + public string Username { get; } + + /// The password. + public string Password { get; } + + /// Renders the credential with the password redacted (AUTH-8). + /// A description that never contains the password. + public override string ToString() => $"DigestCredential {{ Username = {Username}, Password = *** }}"; +} diff --git a/src/Dexpace.Sdk.Core/Auth/TokenRequestContext.cs b/src/Dexpace.Sdk.Core/Auth/TokenRequestContext.cs index 27901fc..625be32 100644 --- a/src/Dexpace.Sdk.Core/Auth/TokenRequestContext.cs +++ b/src/Dexpace.Sdk.Core/Auth/TokenRequestContext.cs @@ -1,6 +1,7 @@ // Copyright (c) 2026 dexpace and Omar Aljarrah. // Licensed under the MIT License. See LICENSE in the repository root for details. +using System.Collections.Immutable; using System.Text; namespace Dexpace.Sdk.Core.Auth; @@ -11,10 +12,16 @@ namespace Dexpace.Sdk.Core.Auth; /// /// /// The shape intentionally mirrors Azure.Core.TokenRequestContext to ease the -/// Dexpace.Sdk.Auth.AzureIdentity adapter. +/// Dexpace.Sdk.Auth.AzureIdentity adapter. The scopes are copied at construction, so cannot +/// drift from (P6c-22). +/// +/// Breaking: was the caller's list; it is now a copy. +/// /// public readonly struct TokenRequestContext { + private readonly ImmutableArray _scopes; + /// /// Initializes a with the specified scopes and no claims. /// @@ -36,13 +43,13 @@ public TokenRequestContext(IReadOnlyList scopes) public TokenRequestContext(IReadOnlyList scopes, string? claims) { ArgumentNullException.ThrowIfNull(scopes); - Scopes = scopes; + _scopes = [.. scopes]; Claims = claims; - CacheKey = BuildCacheKey(scopes, claims); + CacheKey = BuildCacheKey(_scopes, claims); } /// The OAuth 2.0 scopes for which a token is required. - public IReadOnlyList Scopes { get; } + public IReadOnlyList Scopes => _scopes.IsDefault ? [] : _scopes; /// An optional claims challenge to include in the token request. public string? Claims { get; } diff --git a/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt b/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt index 1300f5b..26b2184 100644 --- a/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt +++ b/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt @@ -59,9 +59,12 @@ const Dexpace.Sdk.Core.Http.Response.Response.MaxBufferedErrorBytes = 1048576 -> const Dexpace.Sdk.Core.Http.Response.ResponseBody.DefaultMaxMaterializedBytes = 67108864 -> long Dexpace.Sdk.Core.Auth.AccessToken Dexpace.Sdk.Core.Auth.AccessToken.AccessToken() -> void -Dexpace.Sdk.Core.Auth.AccessToken.AccessToken(string! token, System.DateTimeOffset expiresOn) -> void -Dexpace.Sdk.Core.Auth.AccessToken.AccessToken(string! token, System.DateTimeOffset expiresOn, System.DateTimeOffset? refreshOn) -> void -Dexpace.Sdk.Core.Auth.AccessToken.ExpiresOn.get -> System.DateTimeOffset +Dexpace.Sdk.Core.Auth.AccessToken.AccessToken(string! token) -> void +Dexpace.Sdk.Core.Auth.AccessToken.AccessToken(string! token, System.DateTimeOffset? expiresOn) -> void +Dexpace.Sdk.Core.Auth.AccessToken.AccessToken(string! token, System.DateTimeOffset? expiresOn, System.DateTimeOffset? refreshOn) -> void +Dexpace.Sdk.Core.Auth.AccessToken.Equals(Dexpace.Sdk.Core.Auth.AccessToken other) -> bool +Dexpace.Sdk.Core.Auth.AccessToken.ExpiresOn.get -> System.DateTimeOffset? +Dexpace.Sdk.Core.Auth.AccessToken.IsExpired(System.DateTimeOffset now, System.TimeSpan margin) -> bool Dexpace.Sdk.Core.Auth.AccessToken.RefreshOn.get -> System.DateTimeOffset? Dexpace.Sdk.Core.Auth.AccessToken.Token.get -> string! Dexpace.Sdk.Core.Auth.AccessTokenCache @@ -72,6 +75,20 @@ Dexpace.Sdk.Core.Auth.ApiKeyCredential.ApiKeyCredential(string! key, Dexpace.Sdk Dexpace.Sdk.Core.Auth.ApiKeyCredential.HeaderName.get -> Dexpace.Sdk.Core.Http.Common.HttpHeaderName! Dexpace.Sdk.Core.Auth.ApiKeyCredential.Key.get -> string! Dexpace.Sdk.Core.Auth.ApiKeyCredential.Scheme.get -> string? +Dexpace.Sdk.Core.Auth.AuthCredentials +Dexpace.Sdk.Core.Auth.AuthCredentials.$() -> Dexpace.Sdk.Core.Auth.AuthCredentials! +Dexpace.Sdk.Core.Auth.AuthCredentials.ApiKey.get -> Dexpace.Sdk.Core.Auth.ApiKeyCredential? +Dexpace.Sdk.Core.Auth.AuthCredentials.ApiKey.init -> void +Dexpace.Sdk.Core.Auth.AuthCredentials.AuthCredentials() -> void +Dexpace.Sdk.Core.Auth.AuthCredentials.Basic.get -> Dexpace.Sdk.Core.Auth.BasicCredential? +Dexpace.Sdk.Core.Auth.AuthCredentials.Basic.init -> void +Dexpace.Sdk.Core.Auth.AuthCredentials.Digest.get -> Dexpace.Sdk.Core.Auth.DigestCredential? +Dexpace.Sdk.Core.Auth.AuthCredentials.Digest.init -> void +Dexpace.Sdk.Core.Auth.AuthCredentials.Equals(Dexpace.Sdk.Core.Auth.AuthCredentials? other) -> bool +Dexpace.Sdk.Core.Auth.AuthCredentials.Token.get -> Dexpace.Sdk.Core.Auth.TokenCredential? +Dexpace.Sdk.Core.Auth.AuthCredentials.Token.init -> void +Dexpace.Sdk.Core.Auth.AuthCredentials.TokenRefreshMargin.get -> System.TimeSpan +Dexpace.Sdk.Core.Auth.AuthCredentials.TokenRefreshMargin.init -> void Dexpace.Sdk.Core.Auth.AuthDescriptor Dexpace.Sdk.Core.Auth.AuthDescriptor.$() -> Dexpace.Sdk.Core.Auth.AuthDescriptor! Dexpace.Sdk.Core.Auth.AuthDescriptor.AllowsAnonymous.get -> bool @@ -99,6 +116,10 @@ Dexpace.Sdk.Core.Auth.BasicCredential.BasicCredential(string! username, string! Dexpace.Sdk.Core.Auth.BasicCredential.Password.get -> string! Dexpace.Sdk.Core.Auth.BasicCredential.ToBase64() -> string! Dexpace.Sdk.Core.Auth.BasicCredential.Username.get -> string! +Dexpace.Sdk.Core.Auth.DigestCredential +Dexpace.Sdk.Core.Auth.DigestCredential.DigestCredential(string! username, string! password) -> void +Dexpace.Sdk.Core.Auth.DigestCredential.Password.get -> string! +Dexpace.Sdk.Core.Auth.DigestCredential.Username.get -> string! Dexpace.Sdk.Core.Auth.TokenCredential Dexpace.Sdk.Core.Auth.TokenCredential.TokenCredential() -> void Dexpace.Sdk.Core.Auth.TokenRequestContext @@ -583,12 +604,21 @@ Dexpace.Sdk.Core.Serialization.IStringSerde Dexpace.Sdk.Core.Serialization.IStringSerde.SerializeToString(T value) -> string! Dexpace.Sdk.Core.Serialization.ResponseBodySerdeExtensions Dexpace.Sdk.Core.Serialization.SerdeExtensions +override Dexpace.Sdk.Core.Auth.AccessToken.Equals(object? obj) -> bool +override Dexpace.Sdk.Core.Auth.AccessToken.GetHashCode() -> int +override Dexpace.Sdk.Core.Auth.AccessToken.ToString() -> string! +override Dexpace.Sdk.Core.Auth.ApiKeyCredential.ToString() -> string! +override Dexpace.Sdk.Core.Auth.AuthCredentials.Equals(object? obj) -> bool +override Dexpace.Sdk.Core.Auth.AuthCredentials.GetHashCode() -> int +override Dexpace.Sdk.Core.Auth.AuthCredentials.ToString() -> string! override Dexpace.Sdk.Core.Auth.AuthDescriptor.Equals(object? obj) -> bool override Dexpace.Sdk.Core.Auth.AuthDescriptor.GetHashCode() -> int override Dexpace.Sdk.Core.Auth.AuthDescriptor.ToString() -> string! override Dexpace.Sdk.Core.Auth.AuthRequirement.Equals(object? obj) -> bool override Dexpace.Sdk.Core.Auth.AuthRequirement.GetHashCode() -> int override Dexpace.Sdk.Core.Auth.AuthRequirement.ToString() -> string! +override Dexpace.Sdk.Core.Auth.BasicCredential.ToString() -> string! +override Dexpace.Sdk.Core.Auth.DigestCredential.ToString() -> string! override Dexpace.Sdk.Core.Configuration.HttpLoggingOptions.Equals(object? obj) -> bool override Dexpace.Sdk.Core.Configuration.HttpLoggingOptions.GetHashCode() -> int override Dexpace.Sdk.Core.Configuration.HttpLoggingOptions.ToString() -> string! @@ -665,6 +695,10 @@ override sealed Dexpace.Sdk.Core.Errors.ServiceResponseException.IsRetryable.get override sealed Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy.Stage.get -> Dexpace.Sdk.Core.Pipeline.PipelineStage override sealed Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy.Process(Dexpace.Sdk.Core.Http.Request.Request! request, Dexpace.Sdk.Core.Pipeline.PipelineContext! context, Dexpace.Sdk.Core.Pipeline.PipelineRunner continuation) -> Dexpace.Sdk.Core.Http.Response.Response! override sealed Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy.ProcessAsync(Dexpace.Sdk.Core.Http.Request.Request! request, Dexpace.Sdk.Core.Pipeline.PipelineContext! context, Dexpace.Sdk.Core.Pipeline.PipelineRunner continuation) -> System.Threading.Tasks.ValueTask +static Dexpace.Sdk.Core.Auth.AccessToken.operator !=(Dexpace.Sdk.Core.Auth.AccessToken left, Dexpace.Sdk.Core.Auth.AccessToken right) -> bool +static Dexpace.Sdk.Core.Auth.AccessToken.operator ==(Dexpace.Sdk.Core.Auth.AccessToken left, Dexpace.Sdk.Core.Auth.AccessToken right) -> bool +static Dexpace.Sdk.Core.Auth.AuthCredentials.operator !=(Dexpace.Sdk.Core.Auth.AuthCredentials? left, Dexpace.Sdk.Core.Auth.AuthCredentials? right) -> bool +static Dexpace.Sdk.Core.Auth.AuthCredentials.operator ==(Dexpace.Sdk.Core.Auth.AuthCredentials? left, Dexpace.Sdk.Core.Auth.AuthCredentials? right) -> bool static Dexpace.Sdk.Core.Auth.AuthDescriptor.operator !=(Dexpace.Sdk.Core.Auth.AuthDescriptor? left, Dexpace.Sdk.Core.Auth.AuthDescriptor? right) -> bool static Dexpace.Sdk.Core.Auth.AuthDescriptor.operator ==(Dexpace.Sdk.Core.Auth.AuthDescriptor? left, Dexpace.Sdk.Core.Auth.AuthDescriptor? right) -> bool static Dexpace.Sdk.Core.Auth.AuthRequirement.NoAuth.get -> Dexpace.Sdk.Core.Auth.AuthRequirement! diff --git a/tests/Dexpace.Sdk.Core.Tests/Auth/CredentialTypesTests.cs b/tests/Dexpace.Sdk.Core.Tests/Auth/CredentialTypesTests.cs index c5ff964..2b39b23 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Auth/CredentialTypesTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Auth/CredentialTypesTests.cs @@ -38,6 +38,64 @@ public void Ctor_ThrowsOnNullToken() { Assert.Throws(() => new AccessToken(null!, DateTimeOffset.UtcNow)); } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void AccessToken_rejects_null_empty_and_whitespace_tokens(string? token) + { + Assert.ThrowsAny(() => new AccessToken(token!)); + Assert.ThrowsAny(() => new AccessToken(token!, DateTimeOffset.UtcNow)); + } + + [Fact] + public void AccessToken_without_expiry_never_expires() + { + var token = new AccessToken("t"); + + Assert.Null(token.ExpiresOn); + Assert.False(token.IsExpired(DateTimeOffset.MaxValue, TimeSpan.Zero)); + } + + [Fact] + public void IsExpired_is_strictly_after() + { + var expiry = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero); + var token = new AccessToken("t", expiry); + + Assert.False(token.IsExpired(expiry, TimeSpan.Zero)); + Assert.True(token.IsExpired(expiry.AddTicks(1), TimeSpan.Zero)); + Assert.True(token.IsExpired(expiry.AddSeconds(-29), TimeSpan.FromSeconds(30))); + Assert.False(token.IsExpired(expiry.AddSeconds(-31), TimeSpan.FromSeconds(30))); + } + + [Fact] + public void AccessToken_equality_is_by_value_over_token_expiry_and_refresh() + { + var expiry = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero); + var a = new AccessToken("t", expiry, expiry.AddMinutes(-5)); + var b = new AccessToken("t", expiry, expiry.AddMinutes(-5)); + var c = new AccessToken("t", expiry, expiry.AddMinutes(-4)); + + Assert.Equal(a, b); + Assert.True(a == b); + Assert.True(a != c); + Assert.NotEqual(a, c); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + Assert.True(default(AccessToken) == default); + } + + [Fact] + public void AccessToken_ToString_redacts_the_token() + { + var text = new AccessToken("s3cr3t-Zx9", new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero)).ToString(); + + Assert.Contains("***", text, StringComparison.Ordinal); + Assert.DoesNotContain("s3cr3t-Zx9", text, StringComparison.Ordinal); + Assert.Contains("2030", text, StringComparison.Ordinal); + Assert.Contains("***", default(AccessToken).ToString(), StringComparison.Ordinal); + } } [Trait("Category", "Unit")] @@ -49,7 +107,7 @@ public void Ctor_SetsScopes() IReadOnlyList scopes = ["https://example.com/.default"]; var ctx = new TokenRequestContext(scopes); - Assert.Same(scopes, ctx.Scopes); + Assert.Equal(scopes, ctx.Scopes); Assert.Null(ctx.Claims); } @@ -106,6 +164,20 @@ public void Ctor_ThrowsOnNullScopes() { Assert.Throws(() => new TokenRequestContext(null!)); } + + [Fact] + public void TokenRequestContext_copies_its_scopes() + { + var scopes = new List { "a" }; + var ctx = new TokenRequestContext(scopes); + var key = ctx.CacheKey; + + scopes.Add("b"); + + Assert.Equal(["a"], ctx.Scopes); + Assert.Equal(key, ctx.CacheKey); + Assert.Empty(default(TokenRequestContext).Scopes); + } } [Trait("Category", "Unit")] @@ -181,6 +253,51 @@ public void Ctor_ThrowsOnEmptyKey() { Assert.Throws(() => new ApiKeyCredential(string.Empty)); } + + [Theory] + [InlineData(" ")] + [InlineData("\t")] + public void ApiKeyCredential_rejects_a_whitespace_only_key(string key) + { + Assert.Throws(() => new ApiKeyCredential(key)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("Shared Key")] + public void ApiKeyCredential_scheme_must_be_null_or_non_blank_without_whitespace(string scheme) + { + Assert.Throws(() => new ApiKeyCredential("k", null, scheme)); + Assert.Equal("SharedAccessKey", new ApiKeyCredential("k", null, "SharedAccessKey").Scheme); + } + + [Theory] + [InlineData("a\r\nb-Zx9")] + [InlineData("caf\u00e9-Zx9")] + public void ApiKeyCredential_rejects_a_value_the_outbound_header_grammar_refuses(string key) + { + var ex = Assert.Throws(() => new ApiKeyCredential(key)); + + Assert.DoesNotContain("Zx9", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void ApiKeyCredential_exposes_the_stamped_value_computed_once() + { + Assert.Equal("SharedAccessKey k", new ApiKeyCredential("k", null, "SharedAccessKey").HeaderValue); + Assert.Equal("k", new ApiKeyCredential("k").HeaderValue); + } + + [Fact] + public void ApiKeyCredential_ToString_redacts_the_key() + { + var text = new ApiKeyCredential("s3cr3t-Zx9", HttpHeaderName.Of("X-Api-Key")).ToString(); + + Assert.Contains("***", text, StringComparison.Ordinal); + Assert.DoesNotContain("s3cr3t-Zx9", text, StringComparison.Ordinal); + Assert.Contains("X-Api-Key", text, StringComparison.Ordinal); + } } [Trait("Category", "Unit")] @@ -224,4 +341,97 @@ public void ToBase64_HandlesSpecialChars() Assert.Equal(expected, cred.ToBase64()); } + + [Fact] + public void BasicCredential_rejects_empty_username_or_password_but_allows_whitespace() + { + Assert.Throws(() => new BasicCredential("u", "")); + Assert.Throws(() => new BasicCredential("", "p")); + Assert.Equal(" ", new BasicCredential("u", " ").Password); + Assert.Equal(" ", new BasicCredential(" ", "p").Username); + } + + [Fact] + public void BasicCredential_rejects_a_colon_in_the_username() + { + Assert.Throws(() => new BasicCredential("a:b", "p")); + Assert.Equal("p:q", new BasicCredential("a", "p:q").Password); + } + + [Fact] + public void BasicCredential_with_an_empty_password_is_rejected_at_construction() + { + Assert.Throws(() => new BasicCredential("user", string.Empty)); + } + + [Fact] + public void BasicCredential_exposes_the_header_value_computed_once() + { + var credential = new BasicCredential("Aladdin", "open sesame"); + + Assert.Equal("Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==", credential.HeaderValue); + } + + [Fact] + public void BasicCredential_ToString_redacts_the_password() + { + var text = new BasicCredential("alice", "s3cr3t-Zx9").ToString(); + + Assert.Contains("***", text, StringComparison.Ordinal); + Assert.Contains("alice", text, StringComparison.Ordinal); + Assert.DoesNotContain("s3cr3t-Zx9", text, StringComparison.Ordinal); + } +} + +[Trait("Category", "Unit")] +public class DigestCredentialTests +{ + [Fact] + public void DigestCredential_rejects_empty_username_or_password_and_redacts() + { + Assert.Throws(() => new DigestCredential(null!, "p")); + Assert.Throws(() => new DigestCredential("u", null!)); + Assert.Throws(() => new DigestCredential("", "p")); + Assert.Throws(() => new DigestCredential("u", "")); + + var text = new DigestCredential("alice", "s3cr3t-Zx9").ToString(); + Assert.Contains("***", text, StringComparison.Ordinal); + Assert.DoesNotContain("s3cr3t-Zx9", text, StringComparison.Ordinal); + } +} + +[Trait("Category", "Unit")] +public class AuthCredentialsTests +{ + [Fact] + public void Defaults_have_no_credentials_and_a_30_second_refresh_margin() + { + var credentials = new AuthCredentials(); + + Assert.Null(credentials.Token); + Assert.Null(credentials.ApiKey); + Assert.Null(credentials.Basic); + Assert.Null(credentials.Digest); + Assert.Equal(TimeSpan.FromSeconds(30), credentials.TokenRefreshMargin); + Assert.Empty(credentials.Available); + } + + [Fact] + public void Margin_must_be_non_negative() + { + Assert.Throws(() => new AuthCredentials { TokenRefreshMargin = TimeSpan.FromTicks(-1) }); + Assert.Equal(TimeSpan.Zero, new AuthCredentials { TokenRefreshMargin = TimeSpan.Zero }.TokenRefreshMargin); + } + + [Fact] + public void Available_lists_configured_schemes_in_enum_order() + { + var credentials = new AuthCredentials + { + Digest = new DigestCredential("u", "p"), + Basic = new BasicCredential("u", "p"), + }; + + Assert.Equal([AuthScheme.Basic, AuthScheme.Digest], credentials.Available); + } } diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BasicAuthPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BasicAuthPolicyTests.cs index dce6227..8860b5f 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BasicAuthPolicyTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BasicAuthPolicyTests.cs @@ -59,18 +59,10 @@ public async Task ProcessAsync_StampsBasicAuthorizationHeader() } [Fact] - public async Task ProcessAsync_EmptyPassword_StampsCorrectly() + public void BasicCredential_with_an_empty_password_is_rejected_at_construction() { - var credential = new BasicCredential("user", string.Empty); - var transport = new RecordingTransport(); - var pipeline = new PipelineBuilder() - .Add(new BasicAuthPolicy(credential)) - .Build(transport); - - await pipeline.SendAsync(MakeRequest(), MakeOptions(), TestContext.Current.CancellationToken); - - var value = transport.LastRequest!.Headers.Get("Authorization"); - Assert.Equal($"Basic {Base64("user", "")}", value); + // AUTH-14 (breaking 3): the empty password the policy once stamped is now unreachable. + Assert.Throws(() => new BasicCredential("user", string.Empty)); } [Fact] diff --git a/tests/Dexpace.Sdk.Core.Tests/Security/CredentialRedactionTests.cs b/tests/Dexpace.Sdk.Core.Tests/Security/CredentialRedactionTests.cs new file mode 100644 index 0000000..349df2f --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Security/CredentialRedactionTests.cs @@ -0,0 +1,140 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Reflection; +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Http.Common; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Security; + +/// +/// Roadmap phase 1, S5 remainder (XCUT-19(d); design §6.3 fact 2, P6c-20): no credential type ever reveals its secret in +/// its string form, however it is formatted. Every credential overrides ToString by hand, so the redaction holds +/// whatever the type becomes. Permanent (roadmap constraint 5): never delete or loosen a case. +/// +[Trait("Category", "Security")] +public sealed class CredentialRedactionTests +{ + private const string Secret = "s3cr3t-Zx9"; + + public static TheoryData Credentials => new() + { + { new AccessToken(Secret, DateTimeOffset.UnixEpoch), Secret }, + { new ApiKeyCredential(Secret, HttpHeaderName.Of("X-Api-Key")), Secret }, + { new BasicCredential("alice", Secret), Secret }, + { new DigestCredential("alice", Secret), Secret }, + }; + + [Theory] + [MemberData(nameof(Credentials))] + public void Every_credential_type_formats_without_its_secret(object value, string secret) + { + var formats = new[] + { + value.ToString()!, + $"{value}", + string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}", value), + ((object)value).ToString()!, + }; + + foreach (var text in formats) + { + Assert.Contains("***", text, StringComparison.Ordinal); + Assert.DoesNotContain(secret, text, StringComparison.Ordinal); + } + } + + [Fact] + public void AuthCredentials_with_every_member_set_lists_schemes_and_leaks_nothing() + { + var credentials = new AuthCredentials + { + Token = new FixedTokenCredential(), + ApiKey = new ApiKeyCredential(Secret), + Basic = new BasicCredential("alice", Secret), + Digest = new DigestCredential("alice", Secret), + }; + + var text = credentials.ToString(); + + Assert.Contains("OAuth2", text, StringComparison.Ordinal); + Assert.Contains("ApiKey", text, StringComparison.Ordinal); + Assert.Contains("Basic", text, StringComparison.Ordinal); + Assert.Contains("Digest", text, StringComparison.Ordinal); + Assert.DoesNotContain(Secret, text, StringComparison.Ordinal); + Assert.DoesNotContain("alice", text, StringComparison.Ordinal); + Assert.DoesNotContain(credentials.Basic.ToString(), text, StringComparison.Ordinal); + } + + [Fact] + public void A_record_that_wraps_a_credential_does_not_reintroduce_the_secret_unsuspected() + { + var holder = new Holder(new BasicCredential("alice", Secret)); + + var text = holder.ToString(); + + Assert.Contains("***", text, StringComparison.Ordinal); + Assert.DoesNotContain(Secret, text, StringComparison.Ordinal); + } + + [Fact] + public void Every_public_type_in_the_Auth_namespace_that_holds_a_secret_overrides_ToString() + { + var types = typeof(AccessToken).Assembly.GetTypes() + .Where(t => t.IsPublic && string.Equals(t.Namespace, "Dexpace.Sdk.Core.Auth", StringComparison.Ordinal)); + + var offenders = Offenders(types); + + Assert.Empty(offenders); + } + + [Fact] + public void The_scanner_can_fail() + { + Assert.Equal([typeof(LeakyFixture).Name], Offenders([typeof(CompliantFixture), typeof(LeakyFixture)])); + } + + private static List Offenders(IEnumerable types) + { + var secretNames = new[] { "Token", "Key", "Password", "Secret" }; + var offenders = new List(); + foreach (var type in types) + { + var holdsSecret = type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Any(p => secretNames.Contains(p.Name, StringComparer.Ordinal) && p.PropertyType == typeof(string)); + if (!holdsSecret) + { + continue; + } + + var toString = type.GetMethod(nameof(ToString), BindingFlags.Public | BindingFlags.Instance, Type.EmptyTypes); + if (toString is null || toString.DeclaringType != type) + { + offenders.Add(type.Name); + } + } + + return offenders; + } + + private sealed record Holder(BasicCredential C); + + private sealed class FixedTokenCredential : TokenCredential + { + public override ValueTask GetTokenAsync(TokenRequestContext context, CancellationToken ct = default) => + new(new AccessToken(Secret)); + } + + private sealed class CompliantFixture + { + public string Password { get; } = "x"; + + public override string ToString() => "***"; + } + + private sealed class LeakyFixture + { + public string Password { get; } = "x"; + } +} From 19655f7a5631a607af1149cb4c6dff8630d28644 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 8 Oct 2026 20:20:43 +0300 Subject: [PATCH 03/13] feat: phase 6c challenge parser and Basic and composite handlers (AUTH-12, AUTH-13, AUTH-23, AUTH-25) --- CHANGELOG.md | 4 + .../Auth/AuthenticationChallenge.cs | 97 +++ .../Auth/BasicChallengeHandler.cs | 44 ++ src/Dexpace.Sdk.Core/Auth/ChallengeParser.cs | 330 ++++++++ .../Auth/CompositeChallengeHandler.cs | 60 ++ .../Auth/IChallengeHandler.cs | 35 + .../Http/Common/HttpHeaderName.cs | 9 + src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt | 20 + .../Auth/AuthenticationChallengeTests.cs | 237 ++++++ .../Auth/BasicChallengeHandlerTests.cs | 265 +++++++ .../Auth/ChallengeFixtures.cs | 33 + .../Http/Common/HttpHeaderNameTests.cs | 9 + tests/vectors/auth/challenges.json | 721 ++++++++++++++++++ 13 files changed, 1864 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Auth/AuthenticationChallenge.cs create mode 100644 src/Dexpace.Sdk.Core/Auth/BasicChallengeHandler.cs create mode 100644 src/Dexpace.Sdk.Core/Auth/ChallengeParser.cs create mode 100644 src/Dexpace.Sdk.Core/Auth/CompositeChallengeHandler.cs create mode 100644 src/Dexpace.Sdk.Core/Auth/IChallengeHandler.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Auth/AuthenticationChallengeTests.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Auth/BasicChallengeHandlerTests.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Auth/ChallengeFixtures.cs create mode 100644 tests/vectors/auth/challenges.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 170f803..77509ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -505,5 +505,9 @@ Roadmap phase 1, defects S1–S9, each pinned by a `[Trait("Category", "Security (`AUTH-1`..`AUTH-7`). - `DigestCredential` and `AuthCredentials` (with a `TokenRefreshMargin`); `CredentialRedactionTests` (`Security`) pins that no credential type formats its secret (`XCUT-19`(d)). +- Phase 6c challenge model: `AuthenticationChallenge` and its lenient, linear RFC 7235 parser (`Parse(string?)`, + `Parse(ReadOnlySpan)`; a duplicate parameter keeps the first value), the `IChallengeHandler` SPI, + `BasicChallengeHandler`, `CompositeChallengeHandler`, and `HttpHeaderName.WellKnown.WwwAuthenticate`, + `ProxyAuthenticate` and `ProxyAuthorization` (`AUTH-12`, `AUTH-13`, `AUTH-14`, `AUTH-23`, `AUTH-25`). [Unreleased]: https://github.com/dexpace/dotnet-sdk/commits/main diff --git a/src/Dexpace.Sdk.Core/Auth/AuthenticationChallenge.cs b/src/Dexpace.Sdk.Core/Auth/AuthenticationChallenge.cs new file mode 100644 index 0000000..10d59fb --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/AuthenticationChallenge.cs @@ -0,0 +1,97 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Collections.Immutable; +using System.Text; + +namespace Dexpace.Sdk.Core.Auth; + +/// +/// One parsed RFC 7235 authentication challenge: a scheme and its parameters (AUTH-12). +/// +/// +/// +/// The scheme and the parameter names are lower-cased with an ASCII-only fold; parameter values are kept verbatim +/// (quotes stripped and escapes resolved). A token68 value sits under the synthetic key +/// . A duplicate parameter name keeps the first value (P6c-15): RFC 7235 says each name +/// occurs once per challenge, and first-wins means a hostile later duplicate cannot overwrite a realm a handler already +/// compared. +/// +/// +/// is lenient (AUTH-13): it never throws, it is linear in the input and it uses no regular +/// expression. A scheme that is not a token, a parameter without =, or a value that is neither a token nor a +/// quoted string abandons the current element and resumes after the next top-level (unquoted) comma, keeping the +/// challenge and the parameters parsed so far. An unterminated quoted string ends at the end of the input with its +/// content kept; a trailing backslash is dropped. Blank or input yields an empty list. +/// +/// +public sealed class AuthenticationChallenge +{ + /// The synthetic parameter key a token68 value is recorded under. + public const string Token68Key = "token68"; + + /// Initializes a challenge, folding the scheme and the parameter names to lower case. + /// The authentication scheme. Must not be blank. + /// The parameters, copied; the first of two names that fold equal is kept. + /// is blank. + public AuthenticationChallenge(string scheme, IReadOnlyDictionary? parameters = null) + : this(RequireScheme(scheme), Fold(parameters)) + { + } + + internal AuthenticationChallenge(string foldedScheme, ImmutableDictionary foldedParameters) + { + Scheme = foldedScheme; + Parameters = foldedParameters; + } + + /// The scheme, in ASCII lower case. + public string Scheme { get; } + + /// The parameters, with lower-case names and verbatim values. + public IReadOnlyDictionary Parameters { get; } + + /// The token68 value, or when the challenge carries none. + public string? Token68 => Parameters.TryGetValue(Token68Key, out var value) ? value : null; + + /// Parses every challenge in one header field value, in wire order. + /// The field value, or . + /// The challenges; empty for blank input. Never throws. + public static IReadOnlyList Parse(string? value) => + value is null ? [] : ChallengeParser.Parse(value.AsSpan()); + + /// Parses every challenge in one header field value, in wire order. + /// The field value. + /// The challenges; empty for blank input. Never throws. + public static IReadOnlyList Parse(ReadOnlySpan value) => ChallengeParser.Parse(value); + + /// Renders the scheme and the parameter names; values are never printed (they may carry nonces). + /// A description without parameter values. + public override string ToString() => + new StringBuilder("AuthenticationChallenge { Scheme = ") + .Append(Scheme) + .Append(", Parameters = [") + .AppendJoin(", ", Parameters.Keys) + .Append("] }") + .ToString(); + + private static string RequireScheme(string scheme) + { + ArgumentException.ThrowIfNullOrWhiteSpace(scheme); + return ChallengeParser.FoldAscii(scheme.Trim()); + } + + private static ImmutableDictionary Fold(IReadOnlyDictionary? parameters) + { + var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + if (parameters is not null) + { + foreach (var (name, value) in parameters) + { + builder.TryAdd(ChallengeParser.FoldAscii(name), value); + } + } + + return builder.ToImmutable(); + } +} diff --git a/src/Dexpace.Sdk.Core/Auth/BasicChallengeHandler.cs b/src/Dexpace.Sdk.Core/Auth/BasicChallengeHandler.cs new file mode 100644 index 0000000..b6a4c80 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/BasicChallengeHandler.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; + +namespace Dexpace.Sdk.Core.Auth; + +/// +/// Answers a Basic challenge (any realm) with the credential (RFC 7617, AUTH-14). +/// +/// +/// The header value is precomputed at construction and is the same string BasicAuthPolicy stamps preemptively. +/// +public sealed class BasicChallengeHandler : IChallengeHandler +{ + private readonly string _headerValue; + + /// Initializes the handler. + /// The credential to answer with. + /// is . + public BasicChallengeHandler(BasicCredential credential) + { + ArgumentNullException.ThrowIfNull(credential); + _headerValue = credential.HeaderValue; + } + + /// + public Request? Authorize(IReadOnlyList challenges, Request request, bool proxy) + { + ArgumentNullException.ThrowIfNull(challenges); + ArgumentNullException.ThrowIfNull(request); + foreach (var challenge in challenges) + { + if (string.Equals(challenge.Scheme, "basic", StringComparison.Ordinal)) + { + var header = proxy ? HttpHeaderName.WellKnown.ProxyAuthorization : HttpHeaderName.WellKnown.Authorization; + return request.WithHeaders(request.Headers.Set(header.Original, _headerValue)); + } + } + + return null; + } +} diff --git a/src/Dexpace.Sdk.Core/Auth/ChallengeParser.cs b/src/Dexpace.Sdk.Core/Auth/ChallengeParser.cs new file mode 100644 index 0000000..3c51485 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/ChallengeParser.cs @@ -0,0 +1,330 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Collections.Immutable; +using System.Text; + +namespace Dexpace.Sdk.Core.Auth; + +/// +/// The RFC 7235 section 2.1 challenge grammar as a single-pass, character-level state machine (AUTH-12, AUTH-13). +/// +/// +/// The one ambiguity of the grammar, whether a token after a comma is a parameter name or a new scheme, is decided by a +/// one-token lookahead: a token followed by optional whitespace, = and then a character that is not = is a +/// parameter; anything else starts a new challenge. Every challenge read consumes at least one character, so the loop +/// terminates, and every character is scanned a bounded number of times, so the parse is linear. +/// +internal static class ChallengeParser +{ + internal static IReadOnlyList Parse(ReadOnlySpan value) + { + var challenges = new List(); + var reader = new Reader(value); + while (true) + { + reader.SkipSeparators(); + if (reader.AtEnd) + { + return challenges; + } + + var before = reader.Position; + ReadChallenge(ref reader, challenges); + if (reader.Position == before) + { + reader.Advance(); + } + } + } + + internal static string FoldAscii(string text) + { + var needsFold = false; + foreach (var c in text) + { + if (c is >= 'A' and <= 'Z') + { + needsFold = true; + break; + } + } + + return needsFold ? string.Create(text.Length, text, static (span, source) => Fold(source, span)) : text; + } + + private static void Fold(ReadOnlySpan source, Span destination) + { + for (var i = 0; i < source.Length; i++) + { + var c = source[i]; + destination[i] = c is >= 'A' and <= 'Z' ? (char)(c + ('a' - 'A')) : c; + } + } + + private static void ReadChallenge(ref Reader reader, List challenges) + { + if (!reader.TryReadToken(out var scheme)) + { + reader.SkipToTopLevelComma(); + return; + } + + var foldedScheme = FoldAscii(scheme.ToString()); + var afterScheme = reader.Position; + reader.SkipBlanks(); + if (reader.Peek == '=') + { + // A parameter with no scheme ahead of it: discard the element. + reader.SkipToTopLevelComma(); + return; + } + + var parameters = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + if (reader.Position == afterScheme && !reader.AtEnd && reader.Peek != ',') + { + // The scheme is not followed by SP: keep the challenge, abandon the rest of the element. + reader.SkipToTopLevelComma(); + } + else if (!reader.AtEnd && reader.Peek != ',' && !reader.TryReadToken68(parameters)) + { + ReadParameters(ref reader, parameters); + } + + challenges.Add(new AuthenticationChallenge(foldedScheme, parameters.ToImmutable())); + } + + private static void ReadParameters(ref Reader reader, ImmutableDictionary.Builder parameters) + { + while (true) + { + if (reader.LooksLikeParameter() && reader.TryReadParameter(out var name, out var value)) + { + parameters.TryAdd(FoldAscii(name), value); + reader.SkipBlanks(); + } + + // A malformed element, or junk after a parameter: abandon it, keep what was parsed. + if (!reader.AtEnd && reader.Peek != ',') + { + reader.SkipToTopLevelComma(); + } + + reader.SkipSeparators(); + if (reader.AtEnd || !(reader.LooksLikeParameter() || reader.StartsMalformedElement)) + { + return; + } + } + } + + private ref struct Reader + { + private readonly ReadOnlySpan _text; + private int _position; + + internal Reader(ReadOnlySpan text) + { + _text = text; + _position = 0; + } + + internal readonly int Position => _position; + + internal readonly bool AtEnd => _position >= _text.Length; + + internal readonly char Peek => _position < _text.Length ? _text[_position] : '\0'; + + internal readonly bool StartsMalformedElement => !AtEnd && !IsTokenChar(Peek); + + internal void Advance() => _position++; + + internal void SkipBlanks() + { + while (_position < _text.Length && _text[_position] is ' ' or '\t') + { + _position++; + } + } + + internal void SkipSeparators() + { + while (_position < _text.Length && _text[_position] is ' ' or '\t' or ',') + { + _position++; + } + } + + internal bool TryReadToken(out ReadOnlySpan token) + { + var start = _position; + while (_position < _text.Length && IsTokenChar(_text[_position])) + { + _position++; + } + + token = _text[start.._position]; + return _position > start; + } + + // token68 = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/" ) *"=", then a comma or the end. + internal bool TryReadToken68(ImmutableDictionary.Builder parameters) + { + var start = _position; + var p = start; + while (p < _text.Length && IsToken68Char(_text[p])) + { + p++; + } + + if (p == start) + { + return false; + } + + while (p < _text.Length && _text[p] == '=') + { + p++; + } + + var end = p; + while (p < _text.Length && _text[p] is ' ' or '\t') + { + p++; + } + + if (p < _text.Length && _text[p] != ',') + { + return false; + } + + parameters.TryAdd(AuthenticationChallenge.Token68Key, _text[start..end].ToString()); + _position = p; + return true; + } + + internal readonly bool LooksLikeParameter() + { + var p = _position; + var start = p; + while (p < _text.Length && IsTokenChar(_text[p])) + { + p++; + } + + if (p == start) + { + return false; + } + + while (p < _text.Length && _text[p] is ' ' or '\t') + { + p++; + } + + return p < _text.Length - 1 && _text[p] == '=' && _text[p + 1] != '='; + } + + internal bool TryReadParameter(out string name, out string value) + { + name = string.Empty; + value = string.Empty; + if (!TryReadToken(out var nameSpan)) + { + return false; + } + + SkipBlanks(); + if (Peek != '=') + { + return false; + } + + _position++; + SkipBlanks(); + name = nameSpan.ToString(); + if (Peek == '"') + { + value = ReadQuoted(); + return true; + } + + if (TryReadToken(out var valueSpan)) + { + value = valueSpan.ToString(); + return true; + } + + return false; + } + + // Positioned at the opening quote. An unterminated string ends at the end of the input; a trailing backslash is dropped. + private string ReadQuoted() + { + _position++; + var builder = new StringBuilder(); + while (_position < _text.Length) + { + var c = _text[_position++]; + if (c == '"') + { + break; + } + + if (c == '\\') + { + if (_position >= _text.Length) + { + break; + } + + c = _text[_position++]; + } + + builder.Append(c); + } + + return builder.ToString(); + } + + // Skips to the next comma outside a quoted string, leaving the position on it (or at the end). + internal void SkipToTopLevelComma() + { + var inQuote = false; + while (_position < _text.Length) + { + var c = _text[_position]; + if (inQuote) + { + if (c == '\\') + { + _position++; + } + else if (c == '"') + { + inQuote = false; + } + } + else if (c == '"') + { + inQuote = true; + } + else if (c == ',') + { + return; + } + + _position++; + } + + _position = _text.Length; + } + + private static bool IsTokenChar(char c) => + c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or (>= '0' and <= '9') + or '!' or '#' or '$' or '%' or '&' or '\'' or '*' or '+' or '-' or '.' or '^' or '_' or '`' or '|' or '~'; + + private static bool IsToken68Char(char c) => + c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or (>= '0' and <= '9') or '-' or '.' or '_' or '~' or '+' or '/'; + } +} diff --git a/src/Dexpace.Sdk.Core/Auth/CompositeChallengeHandler.cs b/src/Dexpace.Sdk.Core/Auth/CompositeChallengeHandler.cs new file mode 100644 index 0000000..64dada5 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/CompositeChallengeHandler.cs @@ -0,0 +1,60 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Collections.Immutable; +using Dexpace.Sdk.Core.Http.Request; + +namespace Dexpace.Sdk.Core.Auth; + +/// +/// Tries several s in order and returns the first answer (AUTH-23). +/// +/// +/// Callers order the stronger scheme first: new CompositeChallengeHandler(digest, basic). The handlers are copied +/// at construction. +/// +public sealed class CompositeChallengeHandler : IChallengeHandler +{ + private readonly ImmutableArray _handlers; + + /// Initializes the composite. + /// The handlers, strongest first. + /// is . + /// There are none, or one is . + public CompositeChallengeHandler(params IEnumerable handlers) + { + ArgumentNullException.ThrowIfNull(handlers); + var builder = ImmutableArray.CreateBuilder(); + foreach (var handler in handlers) + { + if (handler is null) + { + throw new ArgumentException("A handler must not be null.", nameof(handlers)); + } + + builder.Add(handler); + } + + if (builder.Count == 0) + { + throw new ArgumentException("A composite needs at least one handler.", nameof(handlers)); + } + + _handlers = builder.ToImmutable(); + } + + /// + public Request? Authorize(IReadOnlyList challenges, Request request, bool proxy) + { + foreach (var handler in _handlers) + { + var answer = handler.Authorize(challenges, request, proxy); + if (answer is not null) + { + return answer; + } + } + + return null; + } +} diff --git a/src/Dexpace.Sdk.Core/Auth/IChallengeHandler.cs b/src/Dexpace.Sdk.Core/Auth/IChallengeHandler.cs new file mode 100644 index 0000000..6e11e18 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/IChallengeHandler.cs @@ -0,0 +1,35 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Http.Request; + +namespace Dexpace.Sdk.Core.Auth; + +/// +/// Answers authentication challenges: given what a server asked for, produces the request that answers it (AUTH-23, +/// AUTH-25). +/// +/// +/// +/// One method serves as both the "can handle" check and the "build" step (P6c-16): selecting twice would run Digest's +/// selection twice and advance its counter twice. The result is exactly what an auth policy's challenge hook returns +/// (AUTH-30): a replacement for the request, with the answer set under Authorization (or +/// Proxy-Authorization when proxy is ), or when the handler +/// cannot answer. +/// +/// +/// Implementations are stateless apart from bounded counters and safe to call concurrently (AUTH-24). A forward proxy's +/// 407 never reaches the pipeline (P6c-13); the flag exists for callers composing their own proxy handling. +/// +/// +public interface IChallengeHandler +{ + /// Answers for . + /// The parsed challenges, in wire order. + /// The request that drew the challenge. It is never mutated. + /// + /// to set Proxy-Authorization instead of Authorization (AUTH-25). + /// + /// The replacement request, or when the handler cannot answer. + Request? Authorize(IReadOnlyList challenges, Request request, bool proxy); +} diff --git a/src/Dexpace.Sdk.Core/Http/Common/HttpHeaderName.cs b/src/Dexpace.Sdk.Core/Http/Common/HttpHeaderName.cs index 5d37d0d..497aabb 100644 --- a/src/Dexpace.Sdk.Core/Http/Common/HttpHeaderName.cs +++ b/src/Dexpace.Sdk.Core/Http/Common/HttpHeaderName.cs @@ -99,6 +99,12 @@ public static class WellKnown /// The If-Unmodified-Since header. public static HttpHeaderName IfUnmodifiedSince { get; } = Of("If-Unmodified-Since"); + /// The Proxy-Authenticate header. + public static HttpHeaderName ProxyAuthenticate { get; } = Of("Proxy-Authenticate"); + + /// The Proxy-Authorization header. + public static HttpHeaderName ProxyAuthorization { get; } = Of("Proxy-Authorization"); + /// The Range header (see HttpRange). public static HttpHeaderName Range { get; } = Of("Range"); @@ -107,5 +113,8 @@ public static class WellKnown /// The User-Agent header. public static HttpHeaderName UserAgent { get; } = Of("User-Agent"); + + /// The WWW-Authenticate header. + public static HttpHeaderName WwwAuthenticate { get; } = Of("WWW-Authenticate"); } } diff --git a/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt b/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt index 26b2184..999253e 100644 --- a/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt +++ b/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt @@ -10,6 +10,7 @@ abstract Dexpace.Sdk.Core.Pipeline.HttpPipelinePolicy.Stage.get -> Dexpace.Sdk.C abstract Dexpace.Sdk.Core.Pipeline.HttpPipelinePolicy.ProcessAsync(Dexpace.Sdk.Core.Http.Request.Request! request, Dexpace.Sdk.Core.Pipeline.PipelineContext! context, Dexpace.Sdk.Core.Pipeline.PipelineRunner continuation) -> System.Threading.Tasks.ValueTask abstract Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy.GetCredentialAsync(Dexpace.Sdk.Core.Pipeline.PipelineContext! context) -> System.Threading.Tasks.ValueTask<(string! HeaderName, string! HeaderValue)> abstract Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy.WithheldHeaderName.get -> Dexpace.Sdk.Core.Http.Common.HttpHeaderName! +const Dexpace.Sdk.Core.Auth.AuthenticationChallenge.Token68Key = "token68" -> string! const Dexpace.Sdk.Core.Configuration.HttpLoggingOptions.DefaultBodyPreviewSize = 8192 -> int const Dexpace.Sdk.Core.Diagnostics.DexpaceLogEvents.BodyCaptureFailed = "http.instrumentation.body_capture_failed" -> string! const Dexpace.Sdk.Core.Diagnostics.DexpaceLogEvents.BodyCaptureFailedId = 121 -> int @@ -111,15 +112,28 @@ Dexpace.Sdk.Core.Auth.AuthScheme.Basic = 2 -> Dexpace.Sdk.Core.Auth.AuthScheme Dexpace.Sdk.Core.Auth.AuthScheme.Digest = 3 -> Dexpace.Sdk.Core.Auth.AuthScheme Dexpace.Sdk.Core.Auth.AuthScheme.NoAuth = 4 -> Dexpace.Sdk.Core.Auth.AuthScheme Dexpace.Sdk.Core.Auth.AuthScheme.OAuth2 = 0 -> Dexpace.Sdk.Core.Auth.AuthScheme +Dexpace.Sdk.Core.Auth.AuthenticationChallenge +Dexpace.Sdk.Core.Auth.AuthenticationChallenge.AuthenticationChallenge(string! scheme, System.Collections.Generic.IReadOnlyDictionary? parameters = null) -> void +Dexpace.Sdk.Core.Auth.AuthenticationChallenge.Parameters.get -> System.Collections.Generic.IReadOnlyDictionary! +Dexpace.Sdk.Core.Auth.AuthenticationChallenge.Scheme.get -> string! +Dexpace.Sdk.Core.Auth.AuthenticationChallenge.Token68.get -> string? +Dexpace.Sdk.Core.Auth.BasicChallengeHandler +Dexpace.Sdk.Core.Auth.BasicChallengeHandler.Authorize(System.Collections.Generic.IReadOnlyList! challenges, Dexpace.Sdk.Core.Http.Request.Request! request, bool proxy) -> Dexpace.Sdk.Core.Http.Request.Request? +Dexpace.Sdk.Core.Auth.BasicChallengeHandler.BasicChallengeHandler(Dexpace.Sdk.Core.Auth.BasicCredential! credential) -> void Dexpace.Sdk.Core.Auth.BasicCredential Dexpace.Sdk.Core.Auth.BasicCredential.BasicCredential(string! username, string! password) -> void Dexpace.Sdk.Core.Auth.BasicCredential.Password.get -> string! Dexpace.Sdk.Core.Auth.BasicCredential.ToBase64() -> string! Dexpace.Sdk.Core.Auth.BasicCredential.Username.get -> string! +Dexpace.Sdk.Core.Auth.CompositeChallengeHandler +Dexpace.Sdk.Core.Auth.CompositeChallengeHandler.Authorize(System.Collections.Generic.IReadOnlyList! challenges, Dexpace.Sdk.Core.Http.Request.Request! request, bool proxy) -> Dexpace.Sdk.Core.Http.Request.Request? +Dexpace.Sdk.Core.Auth.CompositeChallengeHandler.CompositeChallengeHandler(params System.Collections.Generic.IEnumerable! handlers) -> void Dexpace.Sdk.Core.Auth.DigestCredential Dexpace.Sdk.Core.Auth.DigestCredential.DigestCredential(string! username, string! password) -> void Dexpace.Sdk.Core.Auth.DigestCredential.Password.get -> string! Dexpace.Sdk.Core.Auth.DigestCredential.Username.get -> string! +Dexpace.Sdk.Core.Auth.IChallengeHandler +Dexpace.Sdk.Core.Auth.IChallengeHandler.Authorize(System.Collections.Generic.IReadOnlyList! challenges, Dexpace.Sdk.Core.Http.Request.Request! request, bool proxy) -> Dexpace.Sdk.Core.Http.Request.Request? Dexpace.Sdk.Core.Auth.TokenCredential Dexpace.Sdk.Core.Auth.TokenCredential.TokenCredential() -> void Dexpace.Sdk.Core.Auth.TokenRequestContext @@ -617,6 +631,7 @@ override Dexpace.Sdk.Core.Auth.AuthDescriptor.ToString() -> string! override Dexpace.Sdk.Core.Auth.AuthRequirement.Equals(object? obj) -> bool override Dexpace.Sdk.Core.Auth.AuthRequirement.GetHashCode() -> int override Dexpace.Sdk.Core.Auth.AuthRequirement.ToString() -> string! +override Dexpace.Sdk.Core.Auth.AuthenticationChallenge.ToString() -> string! override Dexpace.Sdk.Core.Auth.BasicCredential.ToString() -> string! override Dexpace.Sdk.Core.Auth.DigestCredential.ToString() -> string! override Dexpace.Sdk.Core.Configuration.HttpLoggingOptions.Equals(object? obj) -> bool @@ -705,6 +720,8 @@ static Dexpace.Sdk.Core.Auth.AuthRequirement.NoAuth.get -> Dexpace.Sdk.Core.Auth static Dexpace.Sdk.Core.Auth.AuthRequirement.operator !=(Dexpace.Sdk.Core.Auth.AuthRequirement? left, Dexpace.Sdk.Core.Auth.AuthRequirement? right) -> bool static Dexpace.Sdk.Core.Auth.AuthRequirement.operator ==(Dexpace.Sdk.Core.Auth.AuthRequirement? left, Dexpace.Sdk.Core.Auth.AuthRequirement? right) -> bool static Dexpace.Sdk.Core.Auth.AuthResolver.Resolve(Dexpace.Sdk.Core.Auth.AuthDescriptor? perCall, Dexpace.Sdk.Core.Auth.AuthDescriptor? operation, Dexpace.Sdk.Core.Auth.AuthDescriptor? client, System.Collections.Generic.IReadOnlyCollection! availableSchemes) -> Dexpace.Sdk.Core.Auth.AuthRequirement! +static Dexpace.Sdk.Core.Auth.AuthenticationChallenge.Parse(System.ReadOnlySpan value) -> System.Collections.Generic.IReadOnlyList! +static Dexpace.Sdk.Core.Auth.AuthenticationChallenge.Parse(string? value) -> System.Collections.Generic.IReadOnlyList! static Dexpace.Sdk.Core.Client.DelegateHttpClient.Create(System.Func!>! send) -> Dexpace.Sdk.Core.Client.IAsyncHttpClient! static Dexpace.Sdk.Core.Client.DelegateHttpClient.CreateBlocking(System.Func! send) -> Dexpace.Sdk.Core.Client.IHttpClient! static Dexpace.Sdk.Core.Client.HttpClientExtensions.AsAsync(this Dexpace.Sdk.Core.Client.IHttpClient! client, System.Threading.Tasks.TaskScheduler! scheduler) -> Dexpace.Sdk.Core.Client.IAsyncHttpClient! @@ -1006,6 +1023,9 @@ static Dexpace.Sdk.Core.Errors.ExceptionFacts.EnumerateCauses(System.Exception! static Dexpace.Sdk.Core.Errors.ExceptionFacts.IsFatal(System.Exception! exception) -> bool static Dexpace.Sdk.Core.Errors.ExceptionTrail.AddSuppressed(System.Exception! primary, System.Exception! secondary) -> void static Dexpace.Sdk.Core.Errors.ExceptionTrail.GetSuppressed(System.Exception! exception) -> System.Collections.Generic.IReadOnlyList! +static Dexpace.Sdk.Core.Http.Common.HttpHeaderName.WellKnown.ProxyAuthenticate.get -> Dexpace.Sdk.Core.Http.Common.HttpHeaderName! +static Dexpace.Sdk.Core.Http.Common.HttpHeaderName.WellKnown.ProxyAuthorization.get -> Dexpace.Sdk.Core.Http.Common.HttpHeaderName! +static Dexpace.Sdk.Core.Http.Common.HttpHeaderName.WellKnown.WwwAuthenticate.get -> Dexpace.Sdk.Core.Http.Common.HttpHeaderName! static Dexpace.Sdk.Core.Pipeline.Policies.RetryAttemptContext.operator !=(Dexpace.Sdk.Core.Pipeline.Policies.RetryAttemptContext left, Dexpace.Sdk.Core.Pipeline.Policies.RetryAttemptContext right) -> bool static Dexpace.Sdk.Core.Pipeline.Policies.RetryAttemptContext.operator ==(Dexpace.Sdk.Core.Pipeline.Policies.RetryAttemptContext left, Dexpace.Sdk.Core.Pipeline.Policies.RetryAttemptContext right) -> bool Dexpace.Sdk.Core.Recovery.Outcome diff --git a/tests/Dexpace.Sdk.Core.Tests/Auth/AuthenticationChallengeTests.cs b/tests/Dexpace.Sdk.Core.Tests/Auth/AuthenticationChallengeTests.cs new file mode 100644 index 0000000..0c26042 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Auth/AuthenticationChallengeTests.cs @@ -0,0 +1,237 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Globalization; +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.TestSupport.Vectors; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Auth; + +/// AUTH-12 and AUTH-13: the challenge model and its lenient, linear parser. +[Trait("Category", "Unit")] +public sealed class AuthenticationChallengeTests +{ + public static TheoryData VectorNames + { + get + { + var data = new TheoryData(); + foreach (var vector in VectorFile.Load("auth/challenges.json")) + { + data.Add(vector.Name); + } + + return data; + } + } + + [Theory] + [MemberData(nameof(VectorNames))] + public void Parse_matches_every_vector(string name) + { + var vector = VectorFile.Load("auth/challenges.json").Single(v => v.Name == name); + + AssertMatches(vector, AuthenticationChallenge.Parse(vector.Input)); + AssertMatches(vector, AuthenticationChallenge.Parse((vector.Input ?? string.Empty).AsSpan())); + } + + [Fact] + public void Every_vector_has_a_source() + { + Assert.All(VectorFile.Load("auth/challenges.json"), v => Assert.False(string.IsNullOrWhiteSpace(v.Source), v.Name)); + } + + [Theory] + [InlineData("en-US")] + [InlineData("tr-TR")] + public void Scheme_and_parameter_names_are_lower_cased_with_ASCII_folding_only(string culture) + { + var previous = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture); + try + { + var challenge = AuthenticationChallenge.Parse("BEARER REALM=\"X\"").Single(); + Assert.Equal("bearer", challenge.Scheme); + Assert.Equal("X", challenge.Parameters["realm"]); + + var dotted = AuthenticationChallenge.Parse("Bearer \u0130x=\"v\""); + Assert.DoesNotContain(dotted, c => c.Parameters.ContainsKey("i\u0307x")); + Assert.Equal("Z", new AuthenticationChallenge("B", new Dictionary { ["\u0130Z"] = "Z" }).Parameters["\u0130z"]); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } + + [Fact] + public void Values_are_never_folded() + { + var challenge = AuthenticationChallenge.Parse("Digest realm=\"MiXeD\", algorithm=SHA-256").Single(); + + Assert.Equal("MiXeD", challenge.Parameters["realm"]); + Assert.Equal("SHA-256", challenge.Parameters["algorithm"]); + } + + [Fact] + public void The_constructor_folds_parameter_names_and_copies_them() + { + var source = new Dictionary { ["Realm"] = "x" }; + var challenge = new AuthenticationChallenge("Bearer", source); + source["nonce"] = "n"; + + Assert.Equal("bearer", challenge.Scheme); + Assert.Equal("x", challenge.Parameters["realm"]); + Assert.Single(challenge.Parameters); + Assert.Throws(() => new AuthenticationChallenge(" ")); + Assert.Throws(() => new AuthenticationChallenge(null!)); + Assert.Empty(new AuthenticationChallenge("basic").Parameters); + } + + [Fact] + public void The_constructor_keeps_the_first_of_two_names_that_fold_equal() + { + var challenge = new AuthenticationChallenge( + "digest", + new List> { new("Realm", "first"), new("REALM", "second") }.ToDictionary(p => p.Key, p => p.Value)); + + Assert.Single(challenge.Parameters); + } + + [Fact] + public void Token68_is_Parameters_token68() + { + Assert.Equal("token68", AuthenticationChallenge.Token68Key); + var challenge = AuthenticationChallenge.Parse(ChallengeFixtures.BareToken68).Single(); + Assert.Equal("dGhlIHNlY3JldCB0b2tlbg==", challenge.Token68); + Assert.Equal(challenge.Token68, challenge.Parameters[AuthenticationChallenge.Token68Key]); + Assert.Null(AuthenticationChallenge.Parse(ChallengeFixtures.Basic).Single().Token68); + } + + [Fact] + public void Parse_never_throws_for_arbitrary_text() + { + const string Alphabet = "Bearer Basic realm=\"\\,=;\t é\u0001\u007f@/-."; + var random = new Random(6); + var buffer = new char[40]; + for (var i = 0; i < 5000; i++) + { + var length = random.Next(buffer.Length); + for (var j = 0; j < length; j++) + { + buffer[j] = Alphabet[random.Next(Alphabet.Length)]; + } + + var challenges = AuthenticationChallenge.Parse(new string(buffer, 0, length)); + + Assert.All(challenges, c => + { + Assert.NotEmpty(c.Scheme); + Assert.Equal(c.Scheme.ToLowerInvariant(), c.Scheme); + Assert.All(c.Parameters.Keys, k => Assert.NotEmpty(k)); + }); + } + } + + [Fact] + public void Parse_never_throws_over_the_metacharacter_alphabet() + { + char[] alphabet = [' ', ',', '=', '"', '\\', '/', '!', '@', 'a', '\t']; + for (var seed = 0; seed < 100_000; seed++) + { + var chars = new char[5]; + var n = seed; + for (var i = 0; i < 5; i++) + { + chars[i] = alphabet[n % alphabet.Length]; + n /= alphabet.Length; + } + + _ = AuthenticationChallenge.Parse(new string(chars)); + } + } + + [Fact] + public void Parse_is_linear_in_the_input() + { + var small = string.Concat(Enumerable.Repeat("a=b,", 20_000)); + var large = string.Concat(Enumerable.Repeat("a=b,", 80_000)); + _ = AuthenticationChallenge.Parse(small); // warm up + + var smallBytes = Allocated(() => AuthenticationChallenge.Parse(small)); + var largeBytes = Allocated(() => AuthenticationChallenge.Parse(large)); + + Assert.True(largeBytes <= smallBytes * 5, $"{largeBytes} bytes for 4x the input versus {smallBytes}."); + + var quoted = "Basic realm=\"" + new string('x', 1_000_000) + "\""; + Assert.Single(AuthenticationChallenge.Parse(quoted)); + Assert.Empty(AuthenticationChallenge.Parse(new string(',', 1_000_000))); + Assert.Single(AuthenticationChallenge.Parse("Basic " + new string('a', 1_000_000))); + } + + [Fact] + public void A_quoted_comma_is_not_a_challenge_separator() + { + Assert.Single(AuthenticationChallenge.Parse("Digest realm=\"a, Basic realm=b\", nonce=\"n\"")); + } + + [Fact] + public void An_escaped_quote_does_not_end_the_value() + { + var challenge = AuthenticationChallenge.Parse("Digest realm=\"a\\\"b, c\", nonce=\"n\"").Single(); + + Assert.Equal("a\"b, c", challenge.Parameters["realm"]); + Assert.Equal("n", challenge.Parameters["nonce"]); + } + + [Fact] + public void ToString_prints_names_and_never_values() + { + var text = AuthenticationChallenge.Parse("Digest nonce=\"n0nce-Zx9\"").Single().ToString(); + + Assert.Contains("nonce", text, StringComparison.Ordinal); + Assert.DoesNotContain("n0nce-Zx9", text, StringComparison.Ordinal); + } + + private static long Allocated(Action action) + { + var before = GC.GetAllocatedBytesForCurrentThread(); + action(); + return GC.GetAllocatedBytesForCurrentThread() - before; + } + + private static void AssertMatches(ChallengeCase vector, IReadOnlyList actual) + { + Assert.Equal(vector.Expected.Count, actual.Count); + for (var i = 0; i < actual.Count; i++) + { + var expected = vector.Expected[i]; + Assert.Equal(expected.Scheme, actual[i].Scheme); + Assert.Equal(expected.Token68, actual[i].Token68); + var parameters = actual[i].Parameters.Where(p => p.Key != AuthenticationChallenge.Token68Key) + .ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal); + Assert.Equal(expected.Parameters.OrderBy(p => p.Key, StringComparer.Ordinal), parameters.OrderBy(p => p.Key, StringComparer.Ordinal)); + } + } + + public sealed class ChallengeCase + { + public string Name { get; set; } = string.Empty; + + public string? Input { get; set; } + + public string Source { get; set; } = string.Empty; + + public List Expected { get; set; } = []; + } + + public sealed class ExpectedChallenge + { + public string Scheme { get; set; } = string.Empty; + + public Dictionary Parameters { get; set; } = []; + + public string? Token68 { get; set; } + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Auth/BasicChallengeHandlerTests.cs b/tests/Dexpace.Sdk.Core.Tests/Auth/BasicChallengeHandlerTests.cs new file mode 100644 index 0000000..de83b2f --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Auth/BasicChallengeHandlerTests.cs @@ -0,0 +1,265 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text; +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Auth; + +/// AUTH-14 and AUTH-25: the Basic challenge handler. +[Trait("Category", "Unit")] +public sealed class BasicChallengeHandlerTests +{ + private static readonly Request s_request = Request.Get("https://api.example.com/v1/items"); + + private static IReadOnlyList Challenges(string header) => AuthenticationChallenge.Parse(header); + + [Theory] + [InlineData("Basic realm=\"a\"")] + [InlineData("Basic realm=\"other\"")] + [InlineData("BASIC")] + public void Answers_a_basic_challenge_with_any_realm_by_setting_Authorization(string header) + { + var handler = new BasicChallengeHandler(new BasicCredential("u", "p")); + + var answer = handler.Authorize(Challenges(header), s_request, proxy: false); + + Assert.NotNull(answer); + Assert.StartsWith("Basic ", answer.Headers.Get("Authorization"), StringComparison.Ordinal); + } + + [Fact] + public void Value_is_Basic_plus_base64_of_UTF8_user_colon_password() + { + var aladdin = new BasicChallengeHandler(new BasicCredential("Aladdin", "open sesame")) + .Authorize(Challenges(ChallengeFixtures.Basic), s_request, proxy: false); + Assert.Equal("Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==", aladdin!.Headers.Get("Authorization")); + + var unicode = new BasicChallengeHandler(new BasicCredential("u", "päss€")) + .Authorize(Challenges(ChallengeFixtures.Basic), s_request, proxy: false); + var encoded = unicode!.Headers.Get("Authorization")!["Basic ".Length..]; + Assert.Equal("u:päss€", Encoding.UTF8.GetString(Convert.FromBase64String(encoded))); + + var blank = new BasicChallengeHandler(new BasicCredential("u", " ")) + .Authorize(Challenges(ChallengeFixtures.Basic), s_request, proxy: false); + Assert.NotNull(blank); + } + + [Theory] + [InlineData("Bearer realm=\"a\"")] + [InlineData("Digest realm=\"a\", nonce=\"n\"")] + [InlineData("")] + public void Returns_null_when_no_basic_challenge_is_present(string header) + { + var handler = new BasicChallengeHandler(new BasicCredential("u", "p")); + + Assert.Null(handler.Authorize(Challenges(header), s_request, proxy: false)); + } + + [Fact] + public void Sets_Proxy_Authorization_when_proxy_is_true() + { + var handler = new BasicChallengeHandler(new BasicCredential("u", "p")); + + var answer = handler.Authorize(Challenges(ChallengeFixtures.Basic), s_request, proxy: true)!; + + Assert.StartsWith("Basic ", answer.Headers.Get("Proxy-Authorization"), StringComparison.Ordinal); + Assert.Null(answer.Headers.Get("Authorization")); + } + + [Fact] + public void Replaces_an_existing_header_rather_than_appending() + { + var request = s_request.WithHeaders(s_request.Headers.Set("Authorization", "Basic old")); + var handler = new BasicChallengeHandler(new BasicCredential("u", "p")); + + var answer = handler.Authorize(Challenges(ChallengeFixtures.Basic), request, proxy: false)!; + + Assert.Single(answer.Headers.GetAll("Authorization")); + Assert.NotEqual("Basic old", answer.Headers.Get("Authorization")); + } + + [Fact] + public void Returns_a_new_request_and_leaves_the_input_untouched() + { + var handler = new BasicChallengeHandler(new BasicCredential("u", "p")); + + var answer = handler.Authorize(Challenges(ChallengeFixtures.Basic), s_request, proxy: false)!; + + Assert.NotSame(s_request, answer); + Assert.Null(s_request.Headers.Get("Authorization")); + } + + [Fact] + public void The_value_equals_the_one_BasicAuthPolicy_stamps_preemptively() + { + var credential = new BasicCredential("alice", "s3cr3t"); + var handler = new BasicChallengeHandler(credential); + + var answer = handler.Authorize(Challenges(ChallengeFixtures.Basic), s_request, proxy: false)!; + + Assert.Equal("Basic " + credential.ToBase64(), answer.Headers.Get("Authorization")); + Assert.Equal(credential.HeaderValue, answer.Headers.Get("Authorization")); + } + + [Fact] + public void Rejects_null_arguments() + { + var handler = new BasicChallengeHandler(new BasicCredential("u", "p")); + + Assert.Throws(() => new BasicChallengeHandler(null!)); + Assert.Throws(() => handler.Authorize(null!, s_request, proxy: false)); + Assert.Throws(() => handler.Authorize([], null!, proxy: false)); + } +} + +/// AUTH-23 and AUTH-24: the composite handler. +[Trait("Category", "Unit")] +public sealed class CompositeChallengeHandlerTests +{ + private static readonly Request s_request = Request.Get("https://api.example.com/v1/items"); + + private static IReadOnlyList Challenges => AuthenticationChallenge.Parse(ChallengeFixtures.Basic); + + [Fact] + public void The_first_non_null_answer_wins() + { + var composite = new CompositeChallengeHandler(new Fixed(null), new Fixed("first"), new Fixed("second")); + + Assert.Equal("first", composite.Authorize(Challenges, s_request, proxy: false)!.Headers.Get("X-Answer")); + } + + [Fact] + public void Order_is_the_constructor_order() + { + var a = new CompositeChallengeHandler(new Fixed("strong"), new Fixed("weak")); + var b = new CompositeChallengeHandler(new Fixed("weak"), new Fixed("strong")); + + Assert.Equal("strong", a.Authorize(Challenges, s_request, proxy: false)!.Headers.Get("X-Answer")); + Assert.Equal("weak", b.Authorize(Challenges, s_request, proxy: false)!.Headers.Get("X-Answer")); + } + + [Fact] + public void Returns_null_when_every_handler_declines() + { + Assert.Null(new CompositeChallengeHandler(new Fixed(null), new Fixed(null)).Authorize(Challenges, s_request, proxy: false)); + } + + [Fact] + public void Copies_its_handlers() + { + var handlers = new IChallengeHandler[] { new Fixed("a") }; + var composite = new CompositeChallengeHandler(handlers); + + handlers[0] = new Fixed("b"); + + Assert.Equal("a", composite.Authorize(Challenges, s_request, proxy: false)!.Headers.Get("X-Answer")); + } + + [Fact] + public void An_empty_list_or_a_null_element_throws_ArgumentException() + { + Assert.Throws(() => new CompositeChallengeHandler(Array.Empty())); + Assert.Throws(() => new CompositeChallengeHandler(new IChallengeHandler?[] { null }!)); + Assert.Throws(() => new CompositeChallengeHandler(null!)); + } + + [Fact] + public void Passes_the_proxy_flag_through() + { + var recorder = new Recording(); + + _ = new CompositeChallengeHandler(recorder).Authorize(Challenges, s_request, proxy: true); + + Assert.True(recorder.LastProxy); + } + + [Fact] + public void A_handler_after_the_winner_is_not_called() + { + var after = new Recording(); + + _ = new CompositeChallengeHandler(new Fixed("win"), after).Authorize(Challenges, s_request, proxy: false); + + Assert.Equal(0, after.Calls); + } + + [Fact] + public async Task Is_stateless_and_safe_under_parallel_use() + { + var composite = new CompositeChallengeHandler( + new Fixed(null), + new BasicChallengeHandler(new BasicCredential("u", "p"))); + + var results = await Task.WhenAll(Enumerable.Range(0, 32).Select( + _ => Task.Run(() => composite.Authorize(Challenges, s_request, proxy: false), TestContext.Current.CancellationToken))); + + Assert.All(results, r => Assert.StartsWith("Basic ", r!.Headers.Get("Authorization"), StringComparison.Ordinal)); + } + + private sealed class Fixed(string? answer) : IChallengeHandler + { + public Request? Authorize(IReadOnlyList challenges, Request request, bool proxy) => + answer is null ? null : request.WithHeaders(request.Headers.Set("X-Answer", answer)); + } + + private sealed class Recording : IChallengeHandler + { + private int _calls; + + public int Calls => Volatile.Read(ref _calls); + + public bool LastProxy { get; private set; } + + public Request? Authorize(IReadOnlyList challenges, Request request, bool proxy) + { + Interlocked.Increment(ref _calls); + LastProxy = proxy; + return null; + } + } +} + +/// AUTH-25: every shipped handler changes only the header the proxy flag selects. +[Trait("Category", "Unit")] +public sealed class ChallengeHandlerContractTests +{ + private static readonly Request s_request = Request.Get("https://api.example.com/v1/items") + .WithHeaders(Headers.Empty.Set("X-Keep", "1")); + + public static TheoryData Handlers => new() + { + { "basic", ChallengeFixtures.Basic, new BasicChallengeHandler(new BasicCredential("u", "p")) }, + { + "composite", + ChallengeFixtures.Basic, + new CompositeChallengeHandler(new BasicChallengeHandler(new BasicCredential("u", "p"))) + }, + }; + + [Theory] + [MemberData(nameof(Handlers))] + public void The_return_is_null_or_a_request_whose_only_changed_header_is_the_one_the_flag_selects( + string name, + string header, + IChallengeHandler handler) + { + Assert.NotEmpty(name); + var challenges = AuthenticationChallenge.Parse(header); + + foreach (var (proxy, expected, other) in new[] { (false, "Authorization", "Proxy-Authorization"), (true, "Proxy-Authorization", "Authorization") }) + { + var answer = handler.Authorize(challenges, s_request, proxy); + + Assert.NotNull(answer); + Assert.NotNull(answer.Headers.Get(expected)); + Assert.Null(answer.Headers.Get(other)); + Assert.Equal("1", answer.Headers.Get("X-Keep")); + Assert.Equal(s_request.Url, answer.Url); + Assert.Equal(s_request.Method, answer.Method); + } + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Auth/ChallengeFixtures.cs b/tests/Dexpace.Sdk.Core.Tests/Auth/ChallengeFixtures.cs new file mode 100644 index 0000000..fff1f25 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Auth/ChallengeFixtures.cs @@ -0,0 +1,33 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Tests.Auth; + +/// +/// Reusable WWW-Authenticate header values (the Ruby port's eight strings): RFC 2617 section 3.5's Basic and MD5 +/// challenges, RFC 7616 section 3.9.1's SHA-256-sess challenge, one Digest challenge a handler must decline, and one +/// deliberately malformed string per AUTH-13 recovery clause. +/// +internal static class ChallengeFixtures +{ + internal const string Basic = "Basic realm=\"example\""; + + internal const string DigestMd5 = + "Digest realm=\"testrealm@host.com\", qop=\"auth,auth-int\", nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", " + + "opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""; + + internal const string DigestSha256Sess = + "Digest realm=\"http-auth@example.org\", qop=\"auth\", algorithm=SHA-256-sess, " + + "nonce=\"7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v\", " + + "opaque=\"FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS\", charset=UTF-8, userhash=false"; + + internal const string DigestUnsupportedQop = "Digest realm=\"r\", qop=\"auth-int\", nonce=\"n\""; + + internal const string MalformedUnterminatedQuote = "Digest realm=\"unterminated"; + + internal const string MalformedStrayComma = "Digest realm=r,,nonce=n"; + + internal const string MalformedValue = "Digest realm=@@, nonce=\"n\""; + + internal const string BareToken68 = "Bearer dGhlIHNlY3JldCB0b2tlbg=="; +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Http/Common/HttpHeaderNameTests.cs b/tests/Dexpace.Sdk.Core.Tests/Http/Common/HttpHeaderNameTests.cs index 361f1de..ff22701 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Http/Common/HttpHeaderNameTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Http/Common/HttpHeaderNameTests.cs @@ -91,6 +91,15 @@ public void WellKnown_includes_IdempotencyKey_IfMatch_IfNoneMatch_IfModifiedSinc Assert.Equal(("range", "Range"), Pair(HttpHeaderName.WellKnown.Range)); } + [Fact] + public void WellKnown_has_WwwAuthenticate_ProxyAuthenticate_and_ProxyAuthorization() + { + Assert.Equal(("www-authenticate", "WWW-Authenticate"), Pair(HttpHeaderName.WellKnown.WwwAuthenticate)); + Assert.Equal(("proxy-authenticate", "Proxy-Authenticate"), Pair(HttpHeaderName.WellKnown.ProxyAuthenticate)); + Assert.Equal(("proxy-authorization", "Proxy-Authorization"), Pair(HttpHeaderName.WellKnown.ProxyAuthorization)); + Assert.Equal(HttpHeaderName.Of("www-authenticate"), HttpHeaderName.WellKnown.WwwAuthenticate); + } + [Fact] public void HttpHeaderName_is_a_reference_type() { diff --git a/tests/vectors/auth/challenges.json b/tests/vectors/auth/challenges.json new file mode 100644 index 0000000..cad324c --- /dev/null +++ b/tests/vectors/auth/challenges.json @@ -0,0 +1,721 @@ +{ + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/{challenge,challenges}_test.rb + support/challenge_fixtures.rb; nodejs-sdk@54aeed4 packages/core/src/auth/challenge.test.ts; RFC 7235 s2.1", + "note": "Each case parses `input` with AuthenticationChallenge.Parse and compares the challenges in order. `parameters` excludes the synthetic token68 key, which is carried in `token68` (null when absent). A null input is the null string. Cases where a sibling differs from a P6c ruling record the .NET expectation and say so.", + "cases": [ + { + "name": "single_bearer_realm", + "input": "Bearer realm=\"example\"", + "expected": [ + { + "scheme": "bearer", + "parameters": { + "realm": "example" + }, + "token68": null + } + ], + "source": "nodejs-sdk@54aeed4 packages/core/src/auth/challenge.test.ts 'scheme and param names are lower-cased'" + }, + { + "name": "multiple_challenges_one_field", + "input": "Basic realm=\"example\", Digest realm=\"testrealm@host.com\", qop=\"auth,auth-int\", nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"", + "expected": [ + { + "scheme": "basic", + "parameters": { + "realm": "example" + }, + "token68": null + }, + { + "scheme": "digest", + "parameters": { + "realm": "testrealm@host.com", + "qop": "auth,auth-int", + "nonce": "dcd98b7102dd2f0e8b11d0f600bfb0c093", + "opaque": "5ccc069c403ebaf9f0171e9517f40e41" + }, + "token68": null + } + ], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb 'multiple comma-separated challenges' (RFC 2617 s3.5 fixtures: ruby-sdk gems/dexpace-core/test/support/challenge_fixtures.rb)" + }, + { + "name": "basic_charset_utf8", + "input": "Basic realm=\"foo\", charset=\"UTF-8\"", + "expected": [ + { + "scheme": "basic", + "parameters": { + "realm": "foo", + "charset": "UTF-8" + }, + "token68": null + } + ], + "source": "RFC 7617 s2.1" + }, + { + "name": "quoted_comma_in_value", + "input": "Digest realm=\"a, b = c\", nonce=\"x,y\"", + "expected": [ + { + "scheme": "digest", + "parameters": { + "realm": "a, b = c", + "nonce": "x,y" + }, + "token68": null + } + ], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb 'a quoted-string may contain commas and equals signs'; nodejs-sdk@54aeed4 packages/core/src/auth/challenge.test.ts 'a comma INSIDE a quoted value'" + }, + { + "name": "quoted_equals_in_value", + "input": "Digest realm=\"a=b\", nonce=\"n\"", + "expected": [ + { + "scheme": "digest", + "parameters": { + "realm": "a=b", + "nonce": "n" + }, + "token68": null + } + ], + "source": "nodejs-sdk@54aeed4 packages/core/src/auth/challenge.test.ts 'an equals sign inside a quoted value'" + }, + { + "name": "escaped_quote_and_backslash", + "input": "Digest realm=\"a\\\"b\", nonce=\"a\\\\b\"", + "expected": [ + { + "scheme": "digest", + "parameters": { + "realm": "a\"b", + "nonce": "a\\b" + }, + "token68": null + } + ], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb 'backslash escapes are unescaped'; nodejs-sdk@54aeed4 packages/core/src/auth/challenge.test.ts 'a backslash escape is unquoted'" + }, + { + "name": "scheme_and_param_names_fold_to_lower", + "input": "BASIC REALM=\"MiXeD\"", + "expected": [ + { + "scheme": "basic", + "parameters": { + "realm": "MiXeD" + }, + "token68": null + } + ], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb 'scheme and parameter names are lower-cased'" + }, + { + "name": "param_names_fold_digest_algorithm", + "input": "Digest Algorithm=SHA-256", + "expected": [ + { + "scheme": "digest", + "parameters": { + "algorithm": "SHA-256" + }, + "token68": null + } + ], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb 'scheme and parameter names are lower-cased'" + }, + { + "name": "values_are_verbatim_case", + "input": "Digest algorithm=SHA-256, QOP=\"Auth\"", + "expected": [ + { + "scheme": "digest", + "parameters": { + "algorithm": "SHA-256", + "qop": "Auth" + }, + "token68": null + } + ], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb 'values kept verbatim'" + }, + { + "name": "bare_scheme", + "input": "NTLM", + "expected": [ + { + "scheme": "ntlm", + "parameters": {}, + "token68": null + } + ], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb 'a bare scheme with no params'; nodejs-sdk@54aeed4 packages/core/src/auth/challenge.test.ts 'a bare scheme with no params gets an empty parameter map'" + }, + { + "name": "two_bare_schemes", + "input": "Negotiate, NTLM", + "expected": [ + { + "scheme": "negotiate", + "parameters": {}, + "token68": null + }, + { + "scheme": "ntlm", + "parameters": {}, + "token68": null + } + ], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb 'a bare scheme with no params'" + }, + { + "name": "token68_after_scheme", + "input": "Negotiate a87421000492aa874209af8bc028", + "expected": [ + { + "scheme": "negotiate", + "parameters": {}, + "token68": "a87421000492aa874209af8bc028" + } + ], + "source": "nodejs-sdk@54aeed4 packages/core/src/auth/challenge.test.ts 'a token68 value is recorded under the synthetic key'" + }, + { + "name": "token68_with_trailing_equals", + "input": "Bearer dGhlIHNlY3JldCB0b2tlbg==", + "expected": [ + { + "scheme": "bearer", + "parameters": {}, + "token68": "dGhlIHNlY3JldCB0b2tlbg==" + } + ], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb 'a token68 value is recorded whole'; ruby-sdk gems/dexpace-core/test/support/challenge_fixtures.rb BARE_TOKEN68; nodejs-sdk@54aeed4 packages/core/src/auth/challenge.test.ts \"token68's own '=' padding\"" + }, + { + "name": "token68_then_second_challenge", + "input": "Bearer abc, Basic realm=r", + "expected": [ + { + "scheme": "bearer", + "parameters": {}, + "token68": "abc" + }, + { + "scheme": "basic", + "parameters": { + "realm": "r" + }, + "token68": null + } + ], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb 'a token68 value is recorded whole'" + }, + { + "name": "digest_full_challenge", + "input": "Digest realm=\"http-auth@example.org\", qop=\"auth\", algorithm=SHA-256-sess, nonce=\"7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v\", opaque=\"FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS\", charset=UTF-8, userhash=false", + "expected": [ + { + "scheme": "digest", + "parameters": { + "realm": "http-auth@example.org", + "qop": "auth", + "algorithm": "SHA-256-sess", + "nonce": "7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", + "opaque": "FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS", + "charset": "UTF-8", + "userhash": "false" + }, + "token68": null + } + ], + "source": "ruby-sdk gems/dexpace-core/test/support/challenge_fixtures.rb DIGEST_SHA256_SESS (RFC 7616 s3.9.1)" + }, + { + "name": "bearer_error_params", + "input": "Bearer realm=\"example\", error=\"invalid_token\", error_description=\"The access token expired\"", + "expected": [ + { + "scheme": "bearer", + "parameters": { + "realm": "example", + "error": "invalid_token", + "error_description": "The access token expired" + }, + "token68": null + } + ], + "source": "RFC 6750 s3" + }, + { + "name": "duplicate_parameter_first_wins", + "input": "Digest realm=\"first\", realm=\"second\"", + "expected": [ + { + "scheme": "digest", + "parameters": { + "realm": "first" + }, + "token68": null + } + ], + "source": "P6c-15", + "note": "Node keeps the last duplicate; the .NET rule (P6c-15) is first-wins so a hostile later duplicate cannot overwrite a compared realm." + }, + { + "name": "blank_input", + "input": "", + "expected": [], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb 'nil, empty and blank input yield an empty list'" + }, + { + "name": "null_input", + "input": null, + "expected": [], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb 'nil, empty and blank input yield an empty list'" + }, + { + "name": "whitespace_only_input", + "input": " ", + "expected": [], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb 'nil, empty and blank input yield an empty list'" + }, + { + "name": "malformed_parameter_skips_to_next_comma_keeps_earlier_params", + "input": "Digest realm=\"a\", =bad, Basic realm=\"b\"", + "expected": [ + { + "scheme": "digest", + "parameters": { + "realm": "a" + }, + "token68": null + }, + { + "scheme": "basic", + "parameters": { + "realm": "b" + }, + "token68": null + } + ], + "source": "nodejs-sdk@54aeed4 packages/core/src/auth/challenge.test.ts 'a malformed segment recovers at the next top-level comma, keeping prior params'" + }, + { + "name": "malformed_scheme_skips_to_next_challenge", + "input": "@@@, Basic realm=\"b\"", + "expected": [ + { + "scheme": "basic", + "parameters": { + "realm": "b" + }, + "token68": null + } + ], + "source": "AUTH-13" + }, + { + "name": "unterminated_quote_ends_at_end_of_input", + "input": "Digest realm=\"unterminated", + "expected": [ + { + "scheme": "digest", + "parameters": { + "realm": "unterminated" + }, + "token68": null + } + ], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb 'an unterminated quoted-string terminates at end-of-input'; ruby-sdk gems/dexpace-core/test/support/challenge_fixtures.rb MALFORMED_UNTERMINATED_QUOTE; nodejs-sdk@54aeed4 packages/core/src/auth/challenge.test.ts" + }, + { + "name": "unterminated_quote_keeps_earlier_params", + "input": "Digest realm=\"r\", nonce=\"n", + "expected": [ + { + "scheme": "digest", + "parameters": { + "realm": "r", + "nonce": "n" + }, + "token68": null + } + ], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb 'an unterminated quoted-string terminates at end-of-input'" + }, + { + "name": "trailing_backslash_dropped", + "input": "Digest realm=\"abc\\", + "expected": [ + { + "scheme": "digest", + "parameters": { + "realm": "abc" + }, + "token68": null + } + ], + "source": "AUTH-13 (trailing backslash dropped)" + }, + { + "name": "parameter_without_equals_abandons_element", + "input": "Digest realm=\"r\" nonce=\"n\", Basic realm=\"b\"", + "expected": [ + { + "scheme": "digest", + "parameters": { + "realm": "r" + }, + "token68": null + }, + { + "scheme": "basic", + "parameters": { + "realm": "b" + }, + "token68": null + } + ], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb 'a parameter before any scheme, and a bare token after one, are skipped'" + }, + { + "name": "bare_token_after_scheme_is_skipped", + "input": "Bearer abc realm=x, Basic realm=r", + "expected": [ + { + "scheme": "bearer", + "parameters": {}, + "token68": null + }, + { + "scheme": "basic", + "parameters": { + "realm": "r" + }, + "token68": null + } + ], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb 'a parameter before any scheme, and a bare token after one, are skipped'" + }, + { + "name": "param_before_any_scheme_is_discarded", + "input": "realm=x, Basic realm=r", + "expected": [ + { + "scheme": "basic", + "parameters": { + "realm": "r" + }, + "token68": null + } + ], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb same test; nodejs-sdk@54aeed4 packages/core/src/auth/challenge.test.ts 'a leading auth-param with no scheme ahead of it is discarded'" + }, + { + "name": "value_neither_token_nor_quoted", + "input": "Digest realm=@@, nonce=\"n\"", + "expected": [ + { + "scheme": "digest", + "parameters": { + "nonce": "n" + }, + "token68": null + } + ], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb 'a malformed value recovers to the next top-level comma'; ruby-sdk gems/dexpace-core/test/support/challenge_fixtures.rb MALFORMED_VALUE" + }, + { + "name": "recovery_walks_a_quoted_string", + "input": "Digest realm=@@ nonce=\"a,b\", Basic realm=x", + "expected": [ + { + "scheme": "digest", + "parameters": {}, + "token68": null + }, + { + "scheme": "basic", + "parameters": { + "realm": "x" + }, + "token68": null + } + ], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb 'recovery walks a quoted string'" + }, + { + "name": "commas_only", + "input": ",,,", + "expected": [], + "source": "nodejs-sdk@54aeed4 packages/core/src/auth/challenge.test.ts 'stray commas collapse'" + }, + { + "name": "stray_commas_between_challenges", + "input": "Basic,,Digest realm=\"b\"", + "expected": [ + { + "scheme": "basic", + "parameters": {}, + "token68": null + }, + { + "scheme": "digest", + "parameters": { + "realm": "b" + }, + "token68": null + } + ], + "source": "nodejs-sdk@54aeed4 packages/core/src/auth/challenge.test.ts 'stray commas collapse rather than emitting empty challenges'" + }, + { + "name": "stray_comma_inside_a_challenge", + "input": "Digest realm=r,,nonce=n", + "expected": [ + { + "scheme": "digest", + "parameters": { + "realm": "r", + "nonce": "n" + }, + "token68": null + } + ], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb 'empty list elements are skipped'; ruby-sdk gems/dexpace-core/test/support/challenge_fixtures.rb MALFORMED_STRAY_COMMA" + }, + { + "name": "lookahead_scheme_vs_param_two_challenges", + "input": "Basic realm=\"a\", Digest realm=\"b\"", + "expected": [ + { + "scheme": "basic", + "parameters": { + "realm": "a" + }, + "token68": null + }, + { + "scheme": "digest", + "parameters": { + "realm": "b" + }, + "token68": null + } + ], + "source": "nodejs-sdk@54aeed4 packages/core/src/auth/challenge.test.ts 'two distinct challenges are both recovered'" + }, + { + "name": "lookahead_scheme_vs_param_one_challenge", + "input": "Digest realm=\"b\", nonce=\"n\"", + "expected": [ + { + "scheme": "digest", + "parameters": { + "realm": "b", + "nonce": "n" + }, + "token68": null + } + ], + "source": "nodejs-sdk@54aeed4 packages/core/src/auth/challenge.test.ts 'a top-level comma between two DIFFERENT auth-params of the SAME challenge'" + }, + { + "name": "lookahead_param_then_scheme", + "input": "Digest realm=\"r\", nonce=\"n\", Basic realm=\"r\"", + "expected": [ + { + "scheme": "digest", + "parameters": { + "realm": "r", + "nonce": "n" + }, + "token68": null + }, + { + "scheme": "basic", + "parameters": { + "realm": "r" + }, + "token68": null + } + ], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb 'a second challenge after a parameterised first is not swallowed'" + }, + { + "name": "realm_equals_is_not_token68", + "input": "Digest realm=r", + "expected": [ + { + "scheme": "digest", + "parameters": { + "realm": "r" + }, + "token68": null + } + ], + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/challenges_test.rb '`realm=` is not read as a token68'" + }, + { + "name": "unquoted_values", + "input": "Digest realm=simple, qop=auth", + "expected": [ + { + "scheme": "digest", + "parameters": { + "realm": "simple", + "qop": "auth" + }, + "token68": null + } + ], + "source": "nodejs-sdk@54aeed4 packages/core/src/auth/challenge.test.ts 'an unquoted token value is accepted'" + }, + { + "name": "tab_separates_scheme_and_params", + "input": "Basic\trealm=\"x\"", + "expected": [ + { + "scheme": "basic", + "parameters": { + "realm": "x" + }, + "token68": null + } + ], + "source": "RFC 7235 s2.1 (SP/HTAB)" + }, + { + "name": "quoted_comma_in_malformed_element", + "input": "Basic realm=\"a\", =\"x,y\", Digest realm=\"b\"", + "expected": [ + { + "scheme": "basic", + "parameters": { + "realm": "a" + }, + "token68": null + }, + { + "scheme": "digest", + "parameters": { + "realm": "b" + }, + "token68": null + } + ], + "source": "nodejs-sdk@54aeed4 packages/core/src/auth/challenge.test.ts 'a comma inside the malformed segment quoted value is not a recovery point'" + }, + { + "name": "escaped_quote_in_malformed_element", + "input": "Basic realm=\"a\", =\"x\\\",y\", Digest realm=\"b\"", + "expected": [ + { + "scheme": "basic", + "parameters": { + "realm": "a" + }, + "token68": null + }, + { + "scheme": "digest", + "parameters": { + "realm": "b" + }, + "token68": null + } + ], + "source": "nodejs-sdk@54aeed4 packages/core/src/auth/challenge.test.ts 'an escaped quote inside a malformed segment'" + }, + { + "name": "non_ascii_in_quoted_value", + "input": "Basic realm=\"café\"", + "expected": [ + { + "scheme": "basic", + "parameters": { + "realm": "café" + }, + "token68": null + } + ], + "source": "RFC 7230 obs-text in quoted-string" + }, + { + "name": "empty_quoted_value", + "input": "Basic realm=\"\"", + "expected": [ + { + "scheme": "basic", + "parameters": { + "realm": "" + }, + "token68": null + } + ], + "source": "RFC 7235 s2.1" + }, + { + "name": "whitespace_around_comma", + "input": "Digest realm=\"r\" , nonce=\"n\"", + "expected": [ + { + "scheme": "digest", + "parameters": { + "realm": "r", + "nonce": "n" + }, + "token68": null + } + ], + "source": "RFC 7230 s7 (OWS around the list comma)" + }, + { + "name": "trailing_comma", + "input": "Digest realm=\"r\", ", + "expected": [ + { + "scheme": "digest", + "parameters": { + "realm": "r" + }, + "token68": null + } + ], + "source": "RFC 7230 s7" + }, + { + "name": "unknown_scheme_after_comma_starts_a_new_challenge", + "input": "Digest realm=\"r\", bogus nonce=\"n\", Basic realm=\"b\"", + "expected": [ + { + "scheme": "digest", + "parameters": { + "realm": "r" + }, + "token68": null + }, + { + "scheme": "bogus", + "parameters": { + "nonce": "n" + }, + "token68": null + }, + { + "scheme": "basic", + "parameters": { + "realm": "b" + }, + "token68": null + } + ], + "source": "P6c-15 (one-token lookahead)" + } + ] +} \ No newline at end of file From 52aea50f9f1afc843d29909dcd4e2ed907757e56 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 8 Oct 2026 20:29:35 +0300 Subject: [PATCH 04/13] feat!: phase 6c 401 lifecycle in the authorization base (AUTH-27..AUTH-33) --- CHANGELOG.md | 11 + .../Errors/HttpsRequiredException.cs | 42 ++ src/Dexpace.Sdk.Core/Internal/AuthOrigin.cs | 22 + .../Pipeline/Policies/ApiKeyAuthPolicy.cs | 29 +- .../Pipeline/Policies/AuthChallengeContext.cs | 47 ++ .../Pipeline/Policies/AuthorizationPolicy.cs | 273 +++++--- .../Pipeline/Policies/BasicAuthPolicy.cs | 20 +- .../Policies/BearerTokenAuthPolicy.cs | 40 +- .../Pipeline/Policies/ChallengeAuthPolicy.cs | 67 ++ src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt | 22 +- .../Errors/HttpsRequiredExceptionTests.cs | 59 ++ .../Internal/AuthOriginTests.cs | 40 ++ .../Policies/ApiKeyAuthPolicyTests.cs | 43 ++ .../AuthorizationPolicyChallengeTests.cs | 650 ++++++++++++++++++ .../Pipeline/Policies/BasicAuthPolicyTests.cs | 14 + .../Policies/BearerTokenAuthPolicyTests.cs | 45 ++ .../Policies/ChallengeAuthPolicyTests.cs | 121 ++++ .../Security/AuthHttpsGuardTests.cs | 31 +- .../Transports/TestResponses.cs | 21 + 19 files changed, 1477 insertions(+), 120 deletions(-) create mode 100644 src/Dexpace.Sdk.Core/Errors/HttpsRequiredException.cs create mode 100644 src/Dexpace.Sdk.Core/Internal/AuthOrigin.cs create mode 100644 src/Dexpace.Sdk.Core/Pipeline/Policies/AuthChallengeContext.cs create mode 100644 src/Dexpace.Sdk.Core/Pipeline/Policies/ChallengeAuthPolicy.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Errors/HttpsRequiredExceptionTests.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Internal/AuthOriginTests.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/AuthorizationPolicyChallengeTests.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ChallengeAuthPolicyTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 77509ed..8f792a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -292,6 +292,15 @@ Roadmap phase 1, defects S1–S9, each pinned by a `[Trait("Category", "Security - **Breaking:** `BasicCredential` rejects an empty username or password and a `:` in the username (was: `null` only); `ToString` redacts the password (`AUTH-14`). - **Breaking:** `TokenRequestContext.Scopes` is a copy of the caller's list (was: the list itself). +- **Breaking:** `AuthorizationPolicy`'s protected surface: a constructor taking the client `AuthDescriptor` and the available + `AuthScheme`s; `WithheldHeaderNames` replaces `WithheldHeaderName`; `GetCredentialAsync` / `GetCredential` take the + resolved `AuthRequirement` and the request, may return `null` ("nothing to attach on this pass") and are both abstract + (the sync-over-async default is gone); new `OnChallengeAsync` / `OnChallenge` hooks (`AUTH-27`..`AUTH-33`). +- **Breaking:** the HTTPS refusal is `HttpsRequiredException` (was a plain `SdkException`, which it still derives from); + it now also covers the outbound pass of a reactive scheme (`AUTH-28`). +- **Breaking:** every auth policy honours `RequestOptions.Auth` / `OperationAuth` (a per-call `NoAuth` sends the call + anonymously, an unservable scheme throws `AuthResolutionException` before anything is sent) and answers a `401` + carrying `WWW-Authenticate` through its hook, replaying once when the replacement is same-origin and replayable. - **Breaking (phase 6b redirect, PR 1):** `RedirectOptions` is reshaped (`REDIR-3`, `REDIR-4`, `REDIR-5`, `REDIR-15`, `REDIR-17`, `REDIR-20`, `REDIR-26`). `StripSensitiveHeadersOnCrossOrigin` is removed (it has had no effect since phase 1; stripping always @@ -509,5 +518,7 @@ Roadmap phase 1, defects S1–S9, each pinned by a `[Trait("Category", "Security `Parse(ReadOnlySpan)`; a duplicate parameter keeps the first value), the `IChallengeHandler` SPI, `BasicChallengeHandler`, `CompositeChallengeHandler`, and `HttpHeaderName.WellKnown.WwwAuthenticate`, `ProxyAuthenticate` and `ProxyAuthorization` (`AUTH-12`, `AUTH-13`, `AUTH-14`, `AUTH-23`, `AUTH-25`). +- `ChallengeAuthPolicy` (Basic or Digest answered on a `401`, never preemptive), `AuthChallengeContext` and + `HttpsRequiredException` (`AUTH-26`..`AUTH-33`, `AUTH-38`). [Unreleased]: https://github.com/dexpace/dotnet-sdk/commits/main diff --git a/src/Dexpace.Sdk.Core/Errors/HttpsRequiredException.cs b/src/Dexpace.Sdk.Core/Errors/HttpsRequiredException.cs new file mode 100644 index 0000000..50efa69 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Errors/HttpsRequiredException.cs @@ -0,0 +1,42 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Errors; + +/// +/// An auth policy refused to attach a credential to a request that is not going over https (AUTH-28, XCUT-16). +/// +/// +/// Raised before any credential is resolved and before anything is sent. It is not a +/// and is never retryable, so no retry policy re-drives it (P6c-41). There is no +/// loopback exemption. +/// +/// Breaking: the refusal was a plain ; this type is assignable to it. +/// +/// +public sealed class HttpsRequiredException : SdkException +{ + /// Initializes the exception. + /// The name of the policy that refused. + /// The URL scheme that was refused. + public HttpsRequiredException(string policyName, string scheme) + : base(BuildMessage(policyName, scheme)) + { + PolicyName = policyName; + Scheme = scheme; + } + + /// The name of the policy that refused. + public string PolicyName { get; } + + /// The URL scheme that was refused. + public string Scheme { get; } + + private static string BuildMessage(string policyName, string scheme) + { + ArgumentNullException.ThrowIfNull(policyName); + ArgumentNullException.ThrowIfNull(scheme); + return $"{policyName} refused to attach a credential to a request over the '{scheme}' scheme; " + + "credentials are only sent over https."; + } +} diff --git a/src/Dexpace.Sdk.Core/Internal/AuthOrigin.cs b/src/Dexpace.Sdk.Core/Internal/AuthOrigin.cs new file mode 100644 index 0000000..2156296 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Internal/AuthOrigin.cs @@ -0,0 +1,22 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Internal; + +/// +/// The origin comparison the authorization step uses for both the cross-origin withholding rule (AUTH-29) and the check +/// on a challenge replacement. +/// +internal static class AuthOrigin +{ + // Derives a canonical origin string: "://:". Port is always included. For a URL + // written without a port, Uri.Port returns the scheme's default (443 for https, 80 for http), not -1, so + // "https://a/" and "https://a:443/" yield the same origin whether or not the caller supplied the port explicitly. + internal static string Of(Uri uri) + { + ArgumentNullException.ThrowIfNull(uri); + return $"{uri.Scheme.ToLowerInvariant()}://{uri.Host.ToLowerInvariant()}:{uri.Port}"; + } + + internal static bool Same(Uri a, Uri b) => string.Equals(Of(a), Of(b), StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/ApiKeyAuthPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/ApiKeyAuthPolicy.cs index 0491e36..6135a9d 100644 --- a/src/Dexpace.Sdk.Core/Pipeline/Policies/ApiKeyAuthPolicy.cs +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/ApiKeyAuthPolicy.cs @@ -3,6 +3,7 @@ using Dexpace.Sdk.Core.Auth; using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; namespace Dexpace.Sdk.Core.Pipeline.Policies; @@ -26,13 +27,15 @@ namespace Dexpace.Sdk.Core.Pipeline.Policies; /// /// /// -/// Credentials are withheld when the request has been redirected to a different origin; see +/// The value is computed once by the credential (AUTH-26). Per-call RequestOptions.Auth tiers are honoured: a +/// descriptor sends the call anonymously. Credentials are withheld when the request has been redirected to a different origin; see /// for the cross-origin withholding contract. /// /// public sealed class ApiKeyAuthPolicy : AuthorizationPolicy { private readonly ApiKeyCredential _credential; + private readonly HttpHeaderName[] _withheld; /// /// Initializes an with the given credential. @@ -40,27 +43,27 @@ public sealed class ApiKeyAuthPolicy : AuthorizationPolicy /// The API-key credential to stamp on every same-origin request. /// is . public ApiKeyAuthPolicy(ApiKeyCredential credential) + : base(new AuthDescriptor(new AuthRequirement(AuthScheme.ApiKey)), [AuthScheme.ApiKey]) { ArgumentNullException.ThrowIfNull(credential); _credential = credential; + _withheld = [credential.HeaderName]; } /// - protected override HttpHeaderName WithheldHeaderName => _credential.HeaderName; + protected override IReadOnlyList WithheldHeaderNames => _withheld; /// - protected override ValueTask<(string HeaderName, string HeaderValue)> GetCredentialAsync( + protected override ValueTask<(string HeaderName, string HeaderValue)?> GetCredentialAsync( + AuthRequirement requirement, + Request request, PipelineContext context) => - new(GetCredential(context)); + new(GetCredential(requirement, request, context)); /// - protected override (string HeaderName, string HeaderValue) GetCredential(PipelineContext context) - { - var headerName = _credential.HeaderName.Original; - var headerValue = _credential.Scheme is null - ? _credential.Key - : $"{_credential.Scheme} {_credential.Key}"; - - return (headerName, headerValue); - } + protected override (string HeaderName, string HeaderValue)? GetCredential( + AuthRequirement requirement, + Request request, + PipelineContext context) => + (_credential.HeaderName.Original, _credential.HeaderValue); } diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/AuthChallengeContext.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/AuthChallengeContext.cs new file mode 100644 index 0000000..bbe7b23 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/AuthChallengeContext.cs @@ -0,0 +1,47 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; + +namespace Dexpace.Sdk.Core.Pipeline.Policies; + +/// +/// What an auth policy's challenge hook sees when a server answers 401 with WWW-Authenticate (AUTH-30). +/// +/// +/// Constructed only by . It is a class, not a record: it holds a disposable +/// , and structural equality over one would be a trap. +/// +public sealed class AuthChallengeContext +{ + internal AuthChallengeContext( + AuthRequirement requirement, + Request request, + Response response, + IReadOnlyList challenges, + PipelineContext context) + { + Requirement = requirement; + Request = request; + Response = response; + Challenges = challenges; + Context = context; + } + + /// The requirement the call was resolved to. + public AuthRequirement Requirement { get; } + + /// The request that drew the 401, as stamped. + public Request Request { get; } + + /// The 401 response. The hook must not dispose it. + public Response Response { get; } + + /// Every challenge in every WWW-Authenticate field, in wire order. + public IReadOnlyList Challenges { get; } + + /// The call-scoped pipeline context. + public PipelineContext Context { get; } +} diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/AuthorizationPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/AuthorizationPolicy.cs index ba97ada..7c59e04 100644 --- a/src/Dexpace.Sdk.Core/Pipeline/Policies/AuthorizationPolicy.cs +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/AuthorizationPolicy.cs @@ -1,6 +1,8 @@ // Copyright (c) 2026 dexpace and Omar Aljarrah. // Licensed under the MIT License. See LICENSE in the repository root for details. +using System.Collections.Immutable; +using Dexpace.Sdk.Core.Auth; using Dexpace.Sdk.Core.Errors; using Dexpace.Sdk.Core.Http.Common; using Dexpace.Sdk.Core.Http.Request; @@ -10,53 +12,66 @@ namespace Dexpace.Sdk.Core.Pipeline.Policies; /// -/// Abstract base class for all auth policies placed at . +/// Abstract base class for all auth policies placed at : it owns the whole +/// authorization lifecycle, and a subclass supplies the credential and, optionally, the answer to a challenge. /// /// /// -/// This base class implements the cross-origin withholding contract: credentials are stamped only -/// when the current request's origin (scheme + host + port) matches the origin of the -/// . If a redirect has moved the request to a different origin, the credential -/// header is actively removed from the request before the continuation policy is called — providing defense-in-depth -/// independent of . A consumer who composes an auth policy without -/// , or with a custom redirect policy, cannot accidentally forward a -/// stale credential to a foreign origin. +/// For each drive the base, in this order: (1) on a cross-origin hop (a redirect moved the request off the origin of +/// ) removes every header, drives once and +/// returns the response without any challenge handling (AUTH-29, P6c-12); (2) resolves the requirement from +/// , and the client descriptor +/// (AUTH-4 to AUTH-6); (3) sends a call anonymously; (4) refuses a non-https URL +/// with before any credential is resolved (AUTH-28); (5) stamps the credential, if +/// the subclass supplies one; (6) drives; (7) on a 401 with WWW-Authenticate parses every field and calls +/// / (AUTH-30, AUTH-33); (8) checks the replacement is +/// same-origin and replayable (AUTH-31); (9) disposes the 401 and drives the replacement once, returning whatever +/// comes back with no second hook call. /// /// -/// The origin is the one of the request handed to Send/SendAsync, fixed on the call-scoped context at -/// entry (REDIR-24, AUTH-29, design §6.2); no policy can overwrite it. +/// A hook that throws, synchronously or asynchronously, has the 401 disposed before the exception propagates +/// (AUTH-32). A replacement with a non-replayable body is not sent and the original 401 is returned, undisposed +/// (AUTH-31). Both entry points are sealed, so a subclass cannot skip the guard on either path (AUTH-28), and +/// reports every failure through the returned task, never by throwing (AUTH-38). /// /// -/// HTTPS only. Whenever a credential would be attached, a request URL whose scheme is not https -/// (compared case-insensitively) is rejected with an naming the policy and the scheme, -/// before or runs — so no token is fetched and no header is -/// written. There is no loopback exemption. A cross-origin hop, which carries no credential, is not checked. Both entry -/// points are sealed, so a subclass cannot skip the guard on either path (AUTH-28). +/// HTTPS only. There is no loopback exemption, and the guard also covers the outbound pass of a reactive scheme +/// (Digest, Basic on challenge), which attaches a credential on the first 401 (P6c-8). /// /// -/// Derived classes must implement to supply the header name -/// and value to stamp, and to identify the header to remove on a -/// cross-origin hop. The base class performs the Headers.Set / Headers.Without -/// writes and the continuation call. is -/// accessed only on the cross-origin branch; it must not trigger credential resolution (e.g. a -/// token-cache lookup). A subclass with a synchronous credential overrides too. -/// -/// -/// Breaking (behaviour): the origin compared against was the first origin the policy saw on the context, stored -/// under the public string key "dexpace.auth.origin" that any policy could overwrite; it is now the seed -/// request's. Breaking: the signature moved to ProcessAsync(Request, PipelineContext, PipelineRunner), and -/// is sealed here as well. +/// Breaking: the protected surface is a constructor taking the client descriptor and available schemes, +/// (was WithheldHeaderName), and abstract and +/// that take the resolved requirement and the request and may return +/// (the sync-over-async default is gone). The HTTPS refusal is an (was +/// ). Every auth policy now honours the per-call and operation tiers and answers a +/// 401 per its hook. /// /// public abstract class AuthorizationPolicy : HttpPipelinePolicy { + private readonly AuthDescriptor _clientDescriptor; + private readonly ImmutableArray _availableSchemes; + + /// Initializes the base. + /// The client tier: what this policy authenticates with by default. + /// The schemes this policy can serve. + /// An argument is . + protected AuthorizationPolicy(AuthDescriptor clientDescriptor, IEnumerable availableSchemes) + { + ArgumentNullException.ThrowIfNull(clientDescriptor); + ArgumentNullException.ThrowIfNull(availableSchemes); + _clientDescriptor = clientDescriptor; + _availableSchemes = [.. availableSchemes]; + } + /// public sealed override PipelineStage Stage => PipelineStage.Auth; /// - /// The name of the credential header to remove when a request leaves the seed origin. + /// The names of the credential headers to remove when a request leaves the seed origin. /// - protected abstract HttpHeaderName WithheldHeaderName { get; } + /// Read only on the cross-origin branch; it must not trigger credential resolution. + protected abstract IReadOnlyList WithheldHeaderNames { get; } /// public sealed override ValueTask ProcessAsync(Request request, PipelineContext context, PipelineRunner continuation) => @@ -66,31 +81,69 @@ public sealed override ValueTask ProcessAsync(Request request, Pipelin public sealed override Response Process(Request request, PipelineContext context, PipelineRunner continuation) => SyncPath.GetCompletedResult(ProcessCoreAsync(request, context, continuation, async: false), nameof(AuthorizationPolicy)); - /// - /// Resolves the credential header to stamp, asynchronously. - /// + /// Resolves the credential header to stamp, asynchronously. + /// The requirement the call resolved to. + /// The request about to be sent. /// The call-scoped context. - /// The header name and value. - protected abstract ValueTask<(string HeaderName, string HeaderValue)> GetCredentialAsync( + /// The header name and value, or when there is nothing to attach on this pass. + protected abstract ValueTask<(string HeaderName, string HeaderValue)?> GetCredentialAsync( + AuthRequirement requirement, + Request request, PipelineContext context); - /// - /// Resolves the credential header to stamp, synchronously. - /// + /// Resolves the credential header to stamp, synchronously. + /// The requirement the call resolved to. + /// The request about to be sent. /// The call-scoped context. - /// The header name and value. - /// - /// The default is the documented blocking bridge over (design §5.3), because the - /// token cache has no synchronous path until phase 6c; a credential that is available without I/O overrides it. - /// - protected virtual (string HeaderName, string HeaderValue) GetCredential(PipelineContext context) + /// The header name and value, or when there is nothing to attach on this pass. + protected abstract (string HeaderName, string HeaderValue)? GetCredential( + AuthRequirement requirement, + Request request, + PipelineContext context); + + /// Answers a 401 challenge, asynchronously. + /// What the server asked for. + /// + /// The replacement request to send once, or to return the 401 unchanged. The default is + /// . + /// + protected virtual ValueTask OnChallengeAsync(AuthChallengeContext challenge) => new((Request?)null); + + /// Answers a 401 challenge, synchronously. + /// What the server asked for. + /// + /// The replacement request to send once, or to return the 401 unchanged. The default is + /// . + /// + protected virtual Request? OnChallenge(AuthChallengeContext challenge) => null; + + private static async ValueTask DriveAsync(Request request, PipelineContext context, PipelineRunner continuation, bool async) => + async + ? await continuation.RunAsync(request, context).ConfigureAwait(false) + : continuation.Run(request, context); + + private static async ValueTask ReleaseAsync(Response response, PipelineContext context, bool async, Exception? primary = null) { - ArgumentNullException.ThrowIfNull(context); + if (async) + { + await Disposal.DisposeQuietlyAsync(response, primary, context.State.Logger).ConfigureAwait(false); + } + else + { + Disposal.DisposeQuietly(response, primary, context.State.Logger); + } + } + + private static List ReadChallenges(IReadOnlyList fields) + { + // P6c-14: each field is parsed on its own, so an unterminated quote in one cannot swallow the next. + var challenges = new List(); + foreach (var field in fields) + { + challenges.AddRange(AuthenticationChallenge.Parse(field)); + } - // The documented sync bridge until 6c gives AccessTokenCache a synchronous path (design §5.3, P4c-13). -#pragma warning disable RS0030 - return GetCredentialAsync(context).AsTask().GetAwaiter().GetResult(); -#pragma warning restore RS0030 + return challenges; } private async ValueTask ProcessCoreAsync(Request request, PipelineContext context, PipelineRunner continuation, bool async) @@ -98,53 +151,109 @@ private async ValueTask ProcessCoreAsync(Request request, PipelineCont ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(context); - // REDIR-24 / AUTH-29: the origin is the seed request's, fixed at call entry and out of any policy's reach. - if (!string.Equals(GetOrigin(context.SeedRequest.Url), GetOrigin(request.Url), StringComparison.OrdinalIgnoreCase)) + // AUTH-29 / REDIR-24: the origin is the seed request's, fixed at call entry and out of any policy's reach. A + // foreign-origin hop carries no credential and is never challenge-handled (P6c-12). + if (!AuthOrigin.Same(context.SeedRequest.Url, request.Url)) { - // The request has been redirected to a different origin: strip the credential header (defense-in-depth, - // removing any stale value carried over) and forward the request without credential. - var stripped = request.WithHeaders(request.Headers.Without(WithheldHeaderName)); - return async - ? await continuation.RunAsync(stripped, context).ConfigureAwait(false) - : continuation.Run(stripped, context); + var stripped = request; + foreach (var name in WithheldHeaderNames) + { + stripped = stripped.WithHeaders(stripped.Headers.Without(name)); + } + + return await DriveAsync(stripped, context, continuation, async).ConfigureAwait(false); + } + + var requirement = AuthResolver.Resolve( + context.RequestOptions.Auth, + context.RequestOptions.OperationAuth, + _clientDescriptor, + _availableSchemes); + if (requirement.Scheme == AuthScheme.NoAuth) + { + return await DriveAsync(request, context, continuation, async).ConfigureAwait(false); } - // AUTH-28 / XCUT-16: a credential is about to be attached, so refuse plaintext before any credential is - // resolved. The cross-origin branch above attaches none and returns first (AUTH-29). No loopback exemption - // (design §11 item 25). EnsureHttps(request.Url); + var stamped = await StampAsync(requirement, request, context, async).ConfigureAwait(false); + var response = await DriveAsync(stamped, context, continuation, async).ConfigureAwait(false); + return await HandleChallengeAsync(requirement, stamped, response, context, continuation, async).ConfigureAwait(false); + } + + private async ValueTask StampAsync(AuthRequirement requirement, Request request, PipelineContext context, bool async) + { + var credential = async + ? await GetCredentialAsync(requirement, request, context).ConfigureAwait(false) + : GetCredential(requirement, request, context); + return credential is { } header ? request.WithHeaders(request.Headers.Set(header.HeaderName, header.HeaderValue)) : request; + } - var (headerName, headerValue) = async - ? await GetCredentialAsync(context).ConfigureAwait(false) - : GetCredential(context); + private async ValueTask HandleChallengeAsync( + AuthRequirement requirement, + Request sent, + Response response, + PipelineContext context, + PipelineRunner continuation, + bool async) + { + // AUTH-33: only a 401 that carries a WWW-Authenticate field is challenge-handled. + if (response.Status.Code != 401) + { + return response; + } - var stamped = request.WithHeaders(request.Headers.Set(headerName, headerValue)); - return async - ? await continuation.RunAsync(stamped, context).ConfigureAwait(false) - : continuation.Run(stamped, context); + var fields = response.Headers.GetAll(HttpHeaderName.WellKnown.WwwAuthenticate); + if (fields.Count == 0) + { + return response; + } + + var challenge = new AuthChallengeContext(requirement, sent, response, ReadChallenges(fields), context); + var replacement = await RunHookAsync(challenge, response, context, async).ConfigureAwait(false); + if (replacement is null) + { + return response; + } + + if (!AuthOrigin.Same(context.SeedRequest.Url, replacement.Url)) + { + await ReleaseAsync(response, context, async).ConfigureAwait(false); + throw new InvalidOperationException( + "An authentication challenge hook returned a replacement for a different origin; a credential must not be redirected."); + } + + // AUTH-31, P6c-11: a replacement whose body cannot be written again is not sent; the original 401 stays with the caller. + if (replacement.Body is { IsReplayable: false }) + { + return response; + } + + await ReleaseAsync(response, context, async).ConfigureAwait(false); + return await DriveAsync(replacement, context, continuation, async).ConfigureAwait(false); } - // Not ServiceRequestException, so no retry policy re-drives it: the request was refused, not failed. - private void EnsureHttps(Uri url) + private async ValueTask RunHookAsync(AuthChallengeContext challenge, Response response, PipelineContext context, bool async) { - if (!string.Equals(url.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + try { - throw new SdkException( - $"{GetType().Name} refused to attach a credential to a request over the '{url.Scheme}' scheme; " + - "credentials are only sent over https."); + return async + ? await OnChallengeAsync(challenge).ConfigureAwait(false) + : OnChallenge(challenge); + } + catch (Exception ex) when (!ExceptionFacts.IsFatal(ex)) + { + // AUTH-32: the 401 is released before the failure propagates; a failing dispose is attached to it. + await ReleaseAsync(response, context, async, primary: ex).ConfigureAwait(false); + throw; } } - // Derives a canonical origin string: "://:". - // Port is always included. For a URL written without a port, Uri.Port returns the scheme's - // default (443 for https, 80 for http), not -1, so "https://a/" and "https://a:443/" yield - // the same origin whether or not the caller supplied the port explicitly. Uri.Port is -1 - // only for a scheme with no known default port. - private static string GetOrigin(Uri uri) + // Not a ServiceRequestException, so no retry policy re-drives it: the request was refused, not failed. + private void EnsureHttps(Uri url) { - var scheme = uri.Scheme.ToLowerInvariant(); - var host = uri.Host.ToLowerInvariant(); - var port = uri.Port; // the scheme default (443, 80) when the URL omits the port - return $"{scheme}://{host}:{port}"; + if (!string.Equals(url.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + throw new HttpsRequiredException(GetType().Name, url.Scheme); + } } } diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/BasicAuthPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/BasicAuthPolicy.cs index d08e0af..f2099b7 100644 --- a/src/Dexpace.Sdk.Core/Pipeline/Policies/BasicAuthPolicy.cs +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/BasicAuthPolicy.cs @@ -3,6 +3,7 @@ using Dexpace.Sdk.Core.Auth; using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; namespace Dexpace.Sdk.Core.Pipeline.Policies; @@ -22,8 +23,7 @@ namespace Dexpace.Sdk.Core.Pipeline.Policies; /// public sealed class BasicAuthPolicy : AuthorizationPolicy { - // Pre-compute the header value once — BasicCredential is immutable, so the Base64 token - // never changes during the lifetime of this policy instance. + // The header value is computed once by the credential and shared with BasicChallengeHandler (AUTH-14). private readonly string _headerValue; /// @@ -32,20 +32,26 @@ public sealed class BasicAuthPolicy : AuthorizationPolicy /// The Basic credential to stamp on every same-origin request. /// is . public BasicAuthPolicy(BasicCredential credential) + : base(new AuthDescriptor(new AuthRequirement(AuthScheme.Basic)), [AuthScheme.Basic]) { ArgumentNullException.ThrowIfNull(credential); - _headerValue = $"Basic {credential.ToBase64()}"; + _headerValue = credential.HeaderValue; } /// - protected override HttpHeaderName WithheldHeaderName => HttpHeaderName.WellKnown.Authorization; + protected override IReadOnlyList WithheldHeaderNames { get; } = [HttpHeaderName.WellKnown.Authorization]; /// - protected override ValueTask<(string HeaderName, string HeaderValue)> GetCredentialAsync( + protected override ValueTask<(string HeaderName, string HeaderValue)?> GetCredentialAsync( + AuthRequirement requirement, + Request request, PipelineContext context) => - new(GetCredential(context)); + new(GetCredential(requirement, request, context)); /// - protected override (string HeaderName, string HeaderValue) GetCredential(PipelineContext context) => + protected override (string HeaderName, string HeaderValue)? GetCredential( + AuthRequirement requirement, + Request request, + PipelineContext context) => (HttpHeaderName.WellKnown.Authorization.Original, _headerValue); } diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/BearerTokenAuthPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/BearerTokenAuthPolicy.cs index 5a69674..2232e4c 100644 --- a/src/Dexpace.Sdk.Core/Pipeline/Policies/BearerTokenAuthPolicy.cs +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/BearerTokenAuthPolicy.cs @@ -3,6 +3,7 @@ using Dexpace.Sdk.Core.Auth; using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; namespace Dexpace.Sdk.Core.Pipeline.Policies; @@ -37,7 +38,7 @@ namespace Dexpace.Sdk.Core.Pipeline.Policies; public sealed class BearerTokenAuthPolicy : AuthorizationPolicy { private readonly AccessTokenCache _cache; - private readonly TokenRequestContext _tokenRequestContext; + private readonly string[] _scopes; /// /// Initializes a with the given credential and scopes. @@ -55,25 +56,52 @@ public sealed class BearerTokenAuthPolicy : AuthorizationPolicy /// or is . /// public BearerTokenAuthPolicy(TokenCredential credential, params string[] scopes) + : base(ClientDescriptor(scopes), [AuthScheme.OAuth2]) { ArgumentNullException.ThrowIfNull(credential); - ArgumentNullException.ThrowIfNull(scopes); _cache = new AccessTokenCache(credential); - _tokenRequestContext = new TokenRequestContext(scopes); + _scopes = [.. scopes]; } /// - protected override HttpHeaderName WithheldHeaderName => HttpHeaderName.WellKnown.Authorization; + protected override IReadOnlyList WithheldHeaderNames { get; } = [HttpHeaderName.WellKnown.Authorization]; /// - protected override async ValueTask<(string HeaderName, string HeaderValue)> GetCredentialAsync( + protected override async ValueTask<(string HeaderName, string HeaderValue)?> GetCredentialAsync( + AuthRequirement requirement, + Request request, PipelineContext context) { var token = await _cache - .GetAsync(_tokenRequestContext, context.CancellationToken) + .GetAsync(TokenContextFor(requirement), context.CancellationToken) .ConfigureAwait(false); return (HttpHeaderName.WellKnown.Authorization.Original, $"Bearer {token.Token}"); } + + /// + protected override (string HeaderName, string HeaderValue)? GetCredential( + AuthRequirement requirement, + Request request, + PipelineContext context) + { + // Removed by task 5.5 (P6c-26): the cache gains a real synchronous path there. +#pragma warning disable RS0030 + var token = _cache + .GetAsync(TokenContextFor(requirement), context.CancellationToken) + .AsTask().GetAwaiter().GetResult(); +#pragma warning restore RS0030 + return (HttpHeaderName.WellKnown.Authorization.Original, $"Bearer {token.Token}"); + } + + private static AuthDescriptor ClientDescriptor(string[] scopes) + { + ArgumentNullException.ThrowIfNull(scopes); + return new AuthDescriptor(new AuthRequirement(AuthScheme.OAuth2) { Scopes = scopes }); + } + + // The resolved requirement's scopes win (a per-call tier may ask for others); the policy's own are the fallback. + private TokenRequestContext TokenContextFor(AuthRequirement requirement) => + new(requirement.Scopes.Count > 0 ? requirement.Scopes : _scopes); } diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/ChallengeAuthPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/ChallengeAuthPolicy.cs new file mode 100644 index 0000000..13db5bb --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/ChallengeAuthPolicy.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; + +namespace Dexpace.Sdk.Core.Pipeline.Policies; + +/// +/// An auth-stage policy that sends nothing preemptively and answers a 401 challenge through an +/// (Digest, or Basic on challenge). +/// +/// +/// +/// new ChallengeAuthPolicy(new DigestChallengeHandler(credential)) is the shape issue #2 asks for; +/// new CompositeChallengeHandler(digest, basic) is its fallback. The handler is called with +/// proxy: false. The client descriptor is [Digest, Basic]. +/// +/// +/// The lifecycle (HTTPS guard, cross-origin suppression, the single replay and its replayability gate) is +/// 's. +/// +/// +public sealed class ChallengeAuthPolicy : AuthorizationPolicy +{ + private readonly IChallengeHandler _handler; + + /// Initializes the policy. + /// The handler that answers a challenge. + /// is . + public ChallengeAuthPolicy(IChallengeHandler handler) + : base( + new AuthDescriptor(new AuthRequirement(AuthScheme.Digest), new AuthRequirement(AuthScheme.Basic)), + [AuthScheme.Digest, AuthScheme.Basic]) + { + ArgumentNullException.ThrowIfNull(handler); + _handler = handler; + } + + /// + protected override IReadOnlyList WithheldHeaderNames { get; } = [HttpHeaderName.WellKnown.Authorization]; + + /// + protected override ValueTask<(string HeaderName, string HeaderValue)?> GetCredentialAsync( + AuthRequirement requirement, + Request request, + PipelineContext context) => + new((ValueTuple?)null); + + /// + protected override (string HeaderName, string HeaderValue)? GetCredential( + AuthRequirement requirement, + Request request, + PipelineContext context) => + null; + + /// + protected override ValueTask OnChallengeAsync(AuthChallengeContext challenge) => new(OnChallenge(challenge)); + + /// + protected override Request? OnChallenge(AuthChallengeContext challenge) + { + ArgumentNullException.ThrowIfNull(challenge); + return _handler.Authorize(challenge.Challenges, challenge.Request, proxy: false); + } +} diff --git a/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt b/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt index 999253e..93cfd6b 100644 --- a/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt +++ b/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt @@ -8,8 +8,6 @@ abstract Dexpace.Sdk.Core.Pagination.AsyncPageable.AsPages(int? pageSizeHint abstract Dexpace.Sdk.Core.Pagination.AsyncPageable.GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerator! abstract Dexpace.Sdk.Core.Pipeline.HttpPipelinePolicy.Stage.get -> Dexpace.Sdk.Core.Pipeline.PipelineStage abstract Dexpace.Sdk.Core.Pipeline.HttpPipelinePolicy.ProcessAsync(Dexpace.Sdk.Core.Http.Request.Request! request, Dexpace.Sdk.Core.Pipeline.PipelineContext! context, Dexpace.Sdk.Core.Pipeline.PipelineRunner continuation) -> System.Threading.Tasks.ValueTask -abstract Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy.GetCredentialAsync(Dexpace.Sdk.Core.Pipeline.PipelineContext! context) -> System.Threading.Tasks.ValueTask<(string! HeaderName, string! HeaderValue)> -abstract Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy.WithheldHeaderName.get -> Dexpace.Sdk.Core.Http.Common.HttpHeaderName! const Dexpace.Sdk.Core.Auth.AuthenticationChallenge.Token68Key = "token68" -> string! const Dexpace.Sdk.Core.Configuration.HttpLoggingOptions.DefaultBodyPreviewSize = 8192 -> int const Dexpace.Sdk.Core.Diagnostics.DexpaceLogEvents.BodyCaptureFailed = "http.instrumentation.body_capture_failed" -> string! @@ -586,7 +584,6 @@ Dexpace.Sdk.Core.Pipeline.PipelinePropertyKey.PipelinePropertyKey(string! nam Dexpace.Sdk.Core.Pipeline.Policies.ApiKeyAuthPolicy Dexpace.Sdk.Core.Pipeline.Policies.ApiKeyAuthPolicy.ApiKeyAuthPolicy(Dexpace.Sdk.Core.Auth.ApiKeyCredential! credential) -> void Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy -Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy.AuthorizationPolicy() -> void Dexpace.Sdk.Core.Pipeline.Policies.BasicAuthPolicy Dexpace.Sdk.Core.Pipeline.Policies.BasicAuthPolicy.BasicAuthPolicy(Dexpace.Sdk.Core.Auth.BasicCredential! credential) -> void Dexpace.Sdk.Core.Pipeline.Policies.BearerTokenAuthPolicy @@ -970,6 +967,9 @@ Dexpace.Sdk.Core.Execution.RequestContext.PromoteToExchange(Dexpace.Sdk.Core.Htt Dexpace.Sdk.Core.Execution.RequestContext.Request.get -> Dexpace.Sdk.Core.Http.Request.Request! Dexpace.Sdk.Core.Execution.RequestContext.RequestContext(Dexpace.Sdk.Core.Http.Request.Request! request, string? operationName = null, Dexpace.Sdk.Core.Execution.InstrumentationContext? instrumentation = null, Dexpace.Sdk.Core.Execution.CallKey? key = null) -> void abstract Dexpace.Sdk.Core.Execution.CallContext.$() -> Dexpace.Sdk.Core.Execution.CallContext! +abstract Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy.GetCredential(Dexpace.Sdk.Core.Auth.AuthRequirement! requirement, Dexpace.Sdk.Core.Http.Request.Request! request, Dexpace.Sdk.Core.Pipeline.PipelineContext! context) -> (string! HeaderName, string! HeaderValue)? +abstract Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy.GetCredentialAsync(Dexpace.Sdk.Core.Auth.AuthRequirement! requirement, Dexpace.Sdk.Core.Http.Request.Request! request, Dexpace.Sdk.Core.Pipeline.PipelineContext! context) -> System.Threading.Tasks.ValueTask<(string! HeaderName, string! HeaderValue)?> +abstract Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy.WithheldHeaderNames.get -> System.Collections.Generic.IReadOnlyList! override Dexpace.Sdk.Core.Execution.CallContext.Equals(object? obj) -> bool override Dexpace.Sdk.Core.Execution.CallContext.GetHashCode() -> int override Dexpace.Sdk.Core.Execution.DispatchContext.$() -> Dexpace.Sdk.Core.Execution.DispatchContext! @@ -1001,11 +1001,16 @@ virtual Dexpace.Sdk.Core.Execution.CallContext.EqualityContract.get -> System.Ty virtual Dexpace.Sdk.Core.Execution.CallContext.Equals(Dexpace.Sdk.Core.Execution.CallContext? other) -> bool virtual Dexpace.Sdk.Core.Execution.CallContext.PrintMembers(System.Text.StringBuilder! builder) -> bool virtual Dexpace.Sdk.Core.Pipeline.HttpPipelinePolicy.Process(Dexpace.Sdk.Core.Http.Request.Request! request, Dexpace.Sdk.Core.Pipeline.PipelineContext! context, Dexpace.Sdk.Core.Pipeline.PipelineRunner continuation) -> Dexpace.Sdk.Core.Http.Response.Response! -virtual Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy.GetCredential(Dexpace.Sdk.Core.Pipeline.PipelineContext! context) -> (string! HeaderName, string! HeaderValue) +virtual Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy.OnChallenge(Dexpace.Sdk.Core.Pipeline.Policies.AuthChallengeContext! challenge) -> Dexpace.Sdk.Core.Http.Request.Request? +virtual Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy.OnChallengeAsync(Dexpace.Sdk.Core.Pipeline.Policies.AuthChallengeContext! challenge) -> System.Threading.Tasks.ValueTask virtual Dexpace.Sdk.Core.Pipeline.Policies.RetryPolicy.GetDelayOverride(Dexpace.Sdk.Core.Pipeline.Policies.RetryAttemptContext attempt) -> System.TimeSpan? virtual Dexpace.Sdk.Core.Pipeline.Policies.RetryPolicy.ShouldRetry(Dexpace.Sdk.Core.Pipeline.Policies.RetryAttemptContext attempt) -> bool? Dexpace.Sdk.Core.Errors.ExceptionFacts Dexpace.Sdk.Core.Errors.ExceptionTrail +Dexpace.Sdk.Core.Errors.HttpsRequiredException +Dexpace.Sdk.Core.Errors.HttpsRequiredException.HttpsRequiredException(string! policyName, string! scheme) -> void +Dexpace.Sdk.Core.Errors.HttpsRequiredException.PolicyName.get -> string! +Dexpace.Sdk.Core.Errors.HttpsRequiredException.Scheme.get -> string! Dexpace.Sdk.Core.Errors.IRetryableError Dexpace.Sdk.Core.Errors.IRetryableError.IsRetryable.get -> bool Dexpace.Sdk.Core.Errors.OperationTimeoutException @@ -1017,6 +1022,15 @@ Dexpace.Sdk.Core.Http.Request.RequestOptions.Auth.get -> Dexpace.Sdk.Core.Auth.A Dexpace.Sdk.Core.Http.Request.RequestOptions.Auth.init -> void Dexpace.Sdk.Core.Http.Request.RequestOptions.OperationAuth.get -> Dexpace.Sdk.Core.Auth.AuthDescriptor? Dexpace.Sdk.Core.Http.Request.RequestOptions.OperationAuth.init -> void +Dexpace.Sdk.Core.Pipeline.Policies.AuthChallengeContext +Dexpace.Sdk.Core.Pipeline.Policies.AuthChallengeContext.Challenges.get -> System.Collections.Generic.IReadOnlyList! +Dexpace.Sdk.Core.Pipeline.Policies.AuthChallengeContext.Context.get -> Dexpace.Sdk.Core.Pipeline.PipelineContext! +Dexpace.Sdk.Core.Pipeline.Policies.AuthChallengeContext.Request.get -> Dexpace.Sdk.Core.Http.Request.Request! +Dexpace.Sdk.Core.Pipeline.Policies.AuthChallengeContext.Requirement.get -> Dexpace.Sdk.Core.Auth.AuthRequirement! +Dexpace.Sdk.Core.Pipeline.Policies.AuthChallengeContext.Response.get -> Dexpace.Sdk.Core.Http.Response.Response! +Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy.AuthorizationPolicy(Dexpace.Sdk.Core.Auth.AuthDescriptor! clientDescriptor, System.Collections.Generic.IEnumerable! availableSchemes) -> void +Dexpace.Sdk.Core.Pipeline.Policies.ChallengeAuthPolicy +Dexpace.Sdk.Core.Pipeline.Policies.ChallengeAuthPolicy.ChallengeAuthPolicy(Dexpace.Sdk.Core.Auth.IChallengeHandler! handler) -> void override Dexpace.Sdk.Core.Errors.SdkException.ToString() -> string! override Dexpace.Sdk.Core.Pipeline.Policies.RetryAttemptContext.GetHashCode() -> int static Dexpace.Sdk.Core.Errors.ExceptionFacts.EnumerateCauses(System.Exception! exception) -> System.Collections.Generic.IEnumerable! diff --git a/tests/Dexpace.Sdk.Core.Tests/Errors/HttpsRequiredExceptionTests.cs b/tests/Dexpace.Sdk.Core.Tests/Errors/HttpsRequiredExceptionTests.cs new file mode 100644 index 0000000..789ab45 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Errors/HttpsRequiredExceptionTests.cs @@ -0,0 +1,59 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Errors; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Exceptions; + +/// AUTH-28, P6c-8, P6c-41: the typed HTTPS refusal. +[Trait("Category", "Unit")] +public sealed class HttpsRequiredExceptionTests +{ + [Fact] + public void Carries_policy_name_and_scheme() + { + var ex = new HttpsRequiredException("BasicAuthPolicy", "http"); + + Assert.Equal("BasicAuthPolicy", ex.PolicyName); + Assert.Equal("http", ex.Scheme); + } + + [Fact] + public void Is_an_SdkException_but_not_a_ServiceRequestException() + { + var ex = new HttpsRequiredException("P", "http"); + + Assert.IsAssignableFrom(ex); + Assert.IsNotAssignableFrom(ex); + Assert.False(ex.IsRetryable); + } + + [Fact] + public void The_message_is_the_as_built_text() + { + Assert.Equal( + "BasicAuthPolicy refused to attach a credential to a request over the 'http' scheme; credentials are only sent over https.", + new HttpsRequiredException("BasicAuthPolicy", "http").Message); + } +} + +/// P6c-41: the auth failures are never retried. +[Trait("Category", "Unit")] +public sealed class AuthExceptionsAreNotRetryableTests +{ + public static TheoryData Exceptions => + [ + new HttpsRequiredException("P", "http"), + new AuthResolutionException([AuthScheme.Digest], [AuthScheme.Basic]), + ]; + + [Theory] + [MemberData(nameof(Exceptions))] + public void None_is_retryable_or_a_ServiceRequestException(SdkException exception) + { + Assert.False(exception.IsRetryable); + Assert.IsNotAssignableFrom(exception); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Internal/AuthOriginTests.cs b/tests/Dexpace.Sdk.Core.Tests/Internal/AuthOriginTests.cs new file mode 100644 index 0000000..c6b08c8 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Internal/AuthOriginTests.cs @@ -0,0 +1,40 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Internal; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Internal; + +/// AUTH-29: the origin comparison the authorization step uses. +[Trait("Category", "Unit")] +public sealed class AuthOriginTests +{ + [Fact] + public void Same_scheme_host_and_port_are_the_same_origin() + { + Assert.True(AuthOrigin.Same(new Uri("https://a/x"), new Uri("https://a/y?q=1"))); + } + + [Fact] + public void Default_ports_are_equal_to_explicit_ones() + { + Assert.True(AuthOrigin.Same(new Uri("https://a/"), new Uri("https://a:443/"))); + Assert.True(AuthOrigin.Same(new Uri("http://a/"), new Uri("http://a:80/"))); + Assert.Equal("https://a:443", AuthOrigin.Of(new Uri("https://a/"))); + } + + [Fact] + public void Host_and_scheme_compare_case_insensitively() + { + Assert.True(AuthOrigin.Same(new Uri("HTTPS://Example.COM/"), new Uri("https://example.com/"))); + } + + [Fact] + public void A_different_port_is_a_different_origin() + { + Assert.False(AuthOrigin.Same(new Uri("https://a/"), new Uri("https://a:8443/"))); + Assert.False(AuthOrigin.Same(new Uri("https://a/"), new Uri("http://a/"))); + Assert.False(AuthOrigin.Same(new Uri("https://a/"), new Uri("https://b/"))); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ApiKeyAuthPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ApiKeyAuthPolicyTests.cs index 6b8206c..de64430 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ApiKeyAuthPolicyTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ApiKeyAuthPolicyTests.cs @@ -225,4 +225,47 @@ public void Process_sync_stamps_like_ProcessAsync() Assert.Equal("Bearer sk-secret", sent.Headers.Get("Authorization")); } + + [Fact] + public void The_header_value_is_stamped_from_the_credential_computed_once() + { + var credential = new ApiKeyCredential("k-1", HttpHeaderName.Of("X-Api-Key"), "SharedAccessKey"); + var policy = new ApiKeyAuthPolicy(credential); + var context = TestContexts.For(); + + var first = TestContexts.Sent(policy, context.SeedRequest, context); + var second = TestContexts.Sent(policy, context.SeedRequest, context); + + Assert.Equal("SharedAccessKey k-1", first.Headers.Get("X-Api-Key")); + Assert.Equal(credential.HeaderValue, second.Headers.Get("X-Api-Key")); + } + + [Fact] + public async Task A_custom_header_is_withheld_cross_origin() + { + var policy = new ApiKeyAuthPolicy(new ApiKeyCredential("my-key", HttpHeaderName.Of("X-Api-Key"))); + var context = TestContexts.For(MakeRequest("https://api.example.com/")); + + var foreign = await TestContexts.SentAsync( + policy, + MakeRequest("https://other.example.org/").WithHeaders(Headers.Empty.Set("X-Api-Key", "stale").Set("Authorization", "keep")), + context); + + Assert.Null(foreign.Headers.Get("X-Api-Key")); + Assert.Equal("keep", foreign.Headers.Get("Authorization")); + } + + [Fact] + public async Task A_per_call_NoAuth_sends_anonymously() + { + var transport = new RecordingTransport(); + var pipeline = new PipelineBuilder().Add(new ApiKeyAuthPolicy(new ApiKeyCredential("k"))).Build(transport); + + using var response = await pipeline.SendAsync( + MakeRequest(), + new RequestOptions { Auth = new AuthDescriptor(AuthRequirement.NoAuth) }, + TestContext.Current.CancellationToken); + + Assert.Null(transport.LastRequest!.Headers.Get("Authorization")); + } } diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/AuthorizationPolicyChallengeTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/AuthorizationPolicyChallengeTests.cs new file mode 100644 index 0000000..278248b --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/AuthorizationPolicyChallengeTests.cs @@ -0,0 +1,650 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Reflection; +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Pipeline.Policies; +using Dexpace.Sdk.TestSupport.Streams; +using Dexpace.Sdk.TestSupport.Transports; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline.Policies; + +/// +/// AUTH-4 to AUTH-6, AUTH-27 to AUTH-33 and AUTH-38: the authorization base's lifecycle, driven through a probe policy +/// over a scripted transport. Each sync/async pair is one theory over async (the Ruby port's pairs). +/// +[Trait("Category", "Unit")] +public sealed class AuthorizationPolicyChallengeTests +{ + private const string Url = "https://api.example.com/v1/items"; + + private static readonly DexpaceClientOptions s_options = new(); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static Request Get(string url = Url) => Request.Get(url); + + private static async Task SendAsync( + HttpPipeline pipeline, + Request request, + bool async, + RequestOptions? options = null) => + async + ? await pipeline.SendAsync(request, options ?? RequestOptions.Empty, Ct) + : pipeline.Send(request, options ?? RequestOptions.Empty, Ct); + + private static HttpPipeline Pipeline(ProbePolicy policy, ScriptedTransport transport) => + new PipelineBuilder().Add(policy).Build(transport, s_options); + + // --------------------------------------------------------------------------------------------------------------- + // Stage and order (AUTH-27) + // --------------------------------------------------------------------------------------------------------------- + + [Fact] + public void Stage_is_Auth_and_sealed() + { + var stage = typeof(AuthorizationPolicy).GetProperty(nameof(HttpPipelinePolicy.Stage))!.GetMethod!; + + Assert.True(stage.IsFinal); + Assert.Equal(PipelineStage.Auth, new ProbePolicy().Stage); + Assert.True(typeof(AuthorizationPolicy).GetMethod(nameof(HttpPipelinePolicy.ProcessAsync))!.IsFinal); + Assert.True(typeof(AuthorizationPolicy).GetMethod(nameof(HttpPipelinePolicy.Process))!.IsFinal); + } + + [Fact] + public void The_stage_order_is_Redirect_then_Retry_then_Auth() + { + Assert.True(PipelineStage.Redirect < PipelineStage.Retry); + Assert.True(PipelineStage.Retry < PipelineStage.Auth); + Assert.Equal(200, (int)PipelineStage.Redirect); + Assert.Equal(300, (int)PipelineStage.Retry); + Assert.Equal(500, (int)PipelineStage.Auth); + } + + [Fact] + public void A_second_auth_policy_in_one_pipeline_is_rejected_by_the_builder() + { + var builder = new PipelineBuilder().Add(new ProbePolicy()); + + Assert.Throws(() => builder.Add(new BasicAuthPolicy(new BasicCredential("u", "p")))); + } + + // --------------------------------------------------------------------------------------------------------------- + // Resolution wiring (AUTH-4 to AUTH-6) + // --------------------------------------------------------------------------------------------------------------- + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task A_per_call_NoAuth_sends_anonymously_and_returns_a_401_unchanged(bool async) + { + var probe = new ProbePolicy { Hook = _ => throw new InvalidOperationException("must not be called") }; + using var transport = new ScriptedTransport(TestResponses.Unauthorized("Basic realm=\"r\"")); + var options = new RequestOptions { Auth = new AuthDescriptor(AuthRequirement.NoAuth) }; + + // An http URL: the guard applies only where a credential will be attached. + using var response = await SendAsync(Pipeline(probe, transport), Get("http://api.example.com/"), async, options); + + Assert.Equal(401, response.Status.Code); + Assert.Equal(1, transport.CallCount); + Assert.Null(transport.LastRequest!.Headers.Get("Authorization")); + Assert.Equal(0, probe.CredentialCalls); + Assert.Equal(0, probe.ChallengeCalls); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task A_per_call_scheme_the_policy_cannot_serve_throws_AuthResolutionException_before_sending(bool async) + { + using var transport = new ScriptedTransport(TestResponses.Create(Status.Ok)); + var options = new RequestOptions { Auth = new AuthDescriptor(new AuthRequirement(AuthScheme.Digest)) }; + + var ex = await Assert.ThrowsAsync( + () => SendAsync(Pipeline(new ProbePolicy(), transport), Get(), async, options)); + + Assert.Equal([AuthScheme.Digest], ex.Required); + Assert.Equal([AuthScheme.ApiKey], ex.Available); + Assert.Equal(0, transport.CallCount); + } + + [Fact] + public async Task The_operation_tier_is_used_when_no_per_call_tier_is_set() + { + var probe = new ProbePolicy(); + using var transport = new ScriptedTransport(TestResponses.Create(Status.Ok)); + var operation = new RequestOptions { OperationAuth = new AuthDescriptor(AuthRequirement.NoAuth) }; + + using var response = await SendAsync(Pipeline(probe, transport), Get(), async: true, operation); + + Assert.Null(transport.LastRequest!.Headers.Get("Authorization")); + Assert.Equal(0, probe.CredentialCalls); + + // The per-call tier beats the operation tier. + var both = operation with { Auth = new AuthDescriptor(new AuthRequirement(AuthScheme.ApiKey)) }; + using var second = new ScriptedTransport(TestResponses.Create(Status.Ok)); + using var stamped = await SendAsync(Pipeline(probe, second), Get(), async: true, both); + Assert.Equal("Probe-1", second.LastRequest!.Headers.Get("Authorization")); + } + + [Fact] + public async Task The_requirement_handed_to_the_credential_is_the_resolved_one() + { + AuthRequirement? seen = null; + var probe = new ProbePolicy { Credential = (requirement, _) => { seen = requirement; return ("Authorization", "x"); } }; + var wanted = new AuthRequirement(AuthScheme.ApiKey) { Scopes = ["a"] }; + var options = new RequestOptions { Auth = new AuthDescriptor(wanted) }; + + using var response = await SendAsync(Pipeline(probe, new ScriptedTransport(TestResponses.Create(Status.Ok))), Get(), async: true, options); + + Assert.Equal(wanted, seen); + } + + // --------------------------------------------------------------------------------------------------------------- + // Cross-origin (AUTH-29) + // --------------------------------------------------------------------------------------------------------------- + + [Fact] + public async Task A_cross_origin_hop_has_its_credential_header_removed_and_is_not_guarded() + { + var probe = new ProbePolicy(); + var context = TestContexts.For(Get("https://a.example.com/")); + using var transport = new ScriptedTransport(TestResponses.Create(Status.Ok)); + var foreign = Get("http://b.example.com/") + .WithHeaders(Headers.Empty.Set("Authorization", "stale").Set("X-Keep", "1")); + + using var response = await probe.ProcessAsync(foreign, context, TestContexts.RunnerOver(transport)); + + Assert.Null(transport.LastRequest!.Headers.Get("Authorization")); + Assert.Equal("1", transport.LastRequest.Headers.Get("X-Keep")); + Assert.Equal(0, probe.CredentialCalls); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task A_cross_origin_401_is_returned_without_challenge_handling(bool async) + { + var probe = new ProbePolicy { Hook = _ => throw new InvalidOperationException("must not be called") }; + var context = TestContexts.For(Get("https://a.example.com/")); + using var transport = new ScriptedTransport(TestResponses.Unauthorized("Basic realm=\"r\"", "Bearer realm=\"r\"")); + var runner = TestContexts.RunnerOver(transport); + var foreign = Get("https://b.example.com/"); + + using var response = async + ? await probe.ProcessAsync(foreign, context, runner) + : probe.Process(foreign, context, runner); + + Assert.Equal(401, response.Status.Code); + Assert.Equal(1, transport.CallCount); + Assert.Equal(0, probe.ChallengeCalls); + } + + [Fact] + public async Task No_marker_property_is_set_on_the_context() + { + var probe = new ProbePolicy(); + var context = TestContexts.For(Get("https://a.example.com/")); + using var transport = new ScriptedTransport(TestResponses.Create(Status.Ok), TestResponses.Create(Status.Ok)); + + using var same = await probe.ProcessAsync(Get("https://a.example.com/"), context, TestContexts.RunnerOver(transport)); + using var foreign = await probe.ProcessAsync(Get("https://b.example.com/"), context, TestContexts.RunnerOver(transport)); + + var properties = typeof(CallState).GetField("_properties", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(context.State); + Assert.Null(properties); + } + + // --------------------------------------------------------------------------------------------------------------- + // Guard (AUTH-28) + // --------------------------------------------------------------------------------------------------------------- + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task A_non_https_url_throws_HttpsRequiredException_before_any_credential_is_resolved(bool async) + { + var probe = new ProbePolicy(); + using var transport = new ScriptedTransport(TestResponses.Create(Status.Ok)); + + var ex = await Assert.ThrowsAsync( + () => SendAsync(Pipeline(probe, transport), Get("http://api.example.com/"), async)); + + Assert.Equal("http", ex.Scheme); + Assert.Equal(nameof(ProbePolicy), ex.PolicyName); + Assert.Equal(0, probe.CredentialCalls); + Assert.Equal(0, transport.CallCount); + } + + [Fact] + public async Task The_guard_also_covers_a_reactive_schemes_outbound_pass() + { + var probe = new ProbePolicy { Credential = (_, _) => null }; + using var transport = new ScriptedTransport(TestResponses.Create(Status.Ok)); + + await Assert.ThrowsAsync( + () => SendAsync(Pipeline(probe, transport), Get("http://api.example.com/"), async: true)); + + Assert.Equal(0, transport.CallCount); + Assert.Equal(0, probe.CredentialCalls); + } + + [Fact] + public async Task Case_insensitive_scheme_compare() + { + using var transport = new ScriptedTransport(TestResponses.Create(Status.Ok)); + + using var response = await SendAsync(Pipeline(new ProbePolicy(), transport), Get("HTTPS://api.example.com/"), async: true); + + Assert.Equal(1, transport.CallCount); + } + + // --------------------------------------------------------------------------------------------------------------- + // Stamp + // --------------------------------------------------------------------------------------------------------------- + + [Fact] + public async Task A_non_null_credential_replaces_an_existing_header_value() + { + using var transport = new ScriptedTransport(TestResponses.Create(Status.Ok)); + var request = Get().WithHeaders(Headers.Empty.Set("Authorization", "old")); + + using var response = await SendAsync(Pipeline(new ProbePolicy(), transport), request, async: true); + + Assert.Equal(["Probe-1"], transport.LastRequest!.Headers.GetAll("Authorization")); + } + + [Fact] + public async Task A_null_credential_stamps_nothing() + { + using var transport = new ScriptedTransport(TestResponses.Create(Status.Ok)); + var probe = new ProbePolicy { Credential = (_, _) => null }; + + using var response = await SendAsync(Pipeline(probe, transport), Get(), async: true); + + Assert.Null(transport.LastRequest!.Headers.Get("Authorization")); + Assert.Equal(1, probe.CredentialCalls); + } + + // --------------------------------------------------------------------------------------------------------------- + // The 401 hook (AUTH-30) + // --------------------------------------------------------------------------------------------------------------- + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task A_401_with_WWW_Authenticate_calls_the_hook_with_the_parsed_challenges(bool async) + { + AuthChallengeContext? seen = null; + var probe = new ProbePolicy { Hook = c => { seen = c; return null; } }; + using var transport = new ScriptedTransport(TestResponses.Unauthorized("Basic realm=\"r\", Digest realm=\"d\", nonce=\"n\"")); + + using var response = await SendAsync(Pipeline(probe, transport), Get(), async); + + Assert.NotNull(seen); + Assert.Equal(["basic", "digest"], seen.Challenges.Select(c => c.Scheme)); + Assert.Equal(AuthScheme.ApiKey, seen.Requirement.Scheme); + Assert.Equal("Probe-1", seen.Request.Headers.Get("Authorization")); + Assert.Same(response, seen.Response); + Assert.NotNull(seen.Context); + Assert.Equal(1, probe.ChallengeCalls); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task A_non_null_replacement_is_driven_once_through_the_continuation(bool async) + { + var probe = new ProbePolicy { Hook = c => c.Request.WithHeaders(c.Request.Headers.Set("Authorization", "Retry-2")) }; + using var transport = new ScriptedTransport(TestResponses.Unauthorized("Basic realm=\"r\""), TestResponses.Create(Status.Ok)); + + using var response = await SendAsync(Pipeline(probe, transport), Get(), async); + + Assert.Equal(200, response.Status.Code); + Assert.Equal(2, transport.CallCount); + Assert.Equal("Probe-1", transport.Requests[0].Headers.Get("Authorization")); + Assert.Equal("Retry-2", transport.Requests[1].Headers.Get("Authorization")); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task No_further_challenge_handling_after_the_replay(bool async) + { + var probe = new ProbePolicy { Hook = c => c.Request.WithHeaders(c.Request.Headers.Set("Authorization", "Retry-2")) }; + using var transport = new ScriptedTransport( + TestResponses.Unauthorized("Basic realm=\"r\""), + TestResponses.Unauthorized("Basic realm=\"r\"")); + + using var response = await SendAsync(Pipeline(probe, transport), Get(), async); + + Assert.Equal(401, response.Status.Code); + Assert.Equal(2, transport.CallCount); + Assert.Equal(1, probe.ChallengeCalls); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task The_default_hook_yields_null_and_the_401_is_returned(bool async) + { + var probe = new ProbePolicy(); + using var transport = new ScriptedTransport(TestResponses.Unauthorized("Basic realm=\"r\"")); + + using var response = await SendAsync(Pipeline(probe, transport), Get(), async); + + Assert.Equal(401, response.Status.Code); + Assert.Equal(1, transport.CallCount); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task The_401_is_disposed_before_the_replay_and_the_returned_response_is_the_replays(bool async) + { + var log = new List(); + using var first = new TrackingResponseBody(log, "first"); + using var second = new TrackingResponseBody(log, "second"); + var probe = new ProbePolicy { Hook = c => c.Request.WithHeaders(c.Request.Headers.Set("Authorization", "Retry-2")) }; + using var transport = new ScriptedTransport( + TestResponses.Unauthorized(null, first, "Basic realm=\"r\""), + (Func)(r => + { + lock (log) + { + log.Add("transport:second"); + } + + return TestResponses.Create(Status.Ok, r, body: second); + })); + + using var response = await SendAsync(Pipeline(probe, transport), Get(), async); + + Assert.Equal(1, first.DisposeCount); + Assert.Equal(0, second.DisposeCount); + Assert.Equal(200, response.Status.Code); + Assert.True(log.IndexOf("first:dispose") < log.IndexOf("transport:second"), string.Join(",", log)); + } + + [Fact] + public async Task Each_WWW_Authenticate_field_is_parsed_on_its_own() + { + AuthChallengeContext? seen = null; + var probe = new ProbePolicy { Hook = c => { seen = c; return null; } }; + using var transport = new ScriptedTransport(TestResponses.Unauthorized("Digest realm=\"unterminated", "Bearer realm=\"r\"")); + + using var response = await SendAsync(Pipeline(probe, transport), Get(), async: true); + + Assert.Equal(["digest", "bearer"], seen!.Challenges.Select(c => c.Scheme)); + Assert.Equal("r", seen.Challenges[1].Parameters["realm"]); + } + + // --------------------------------------------------------------------------------------------------------------- + // AUTH-33 + // --------------------------------------------------------------------------------------------------------------- + + [Fact] + public async Task A_401_without_WWW_Authenticate_is_returned_unchanged_and_the_hook_is_not_called() + { + var probe = new ProbePolicy { Hook = _ => throw new InvalidOperationException("must not be called") }; + using var transport = new ScriptedTransport(TestResponses.Create(Status.Unauthorized)); + + using var response = await SendAsync(Pipeline(probe, transport), Get(), async: true); + + Assert.Equal(401, response.Status.Code); + Assert.Equal(0, probe.ChallengeCalls); + } + + [Theory] + [InlineData(403)] + [InlineData(200)] + public async Task A_non_401_with_the_header_is_returned_unchanged(int code) + { + var probe = new ProbePolicy { Hook = _ => throw new InvalidOperationException("must not be called") }; + var headers = new Headers.Builder().Add("WWW-Authenticate", "Basic realm=\"r\"").Build(); + using var transport = new ScriptedTransport(TestResponses.Create(Status.FromCode(code), headers: headers)); + + using var response = await SendAsync(Pipeline(probe, transport), Get(), async: true); + + Assert.Equal(code, response.Status.Code); + Assert.Equal(0, probe.ChallengeCalls); + } + + // --------------------------------------------------------------------------------------------------------------- + // AUTH-32 + // --------------------------------------------------------------------------------------------------------------- + + public static TheoryData ThrowingArms => ["sync-hook", "async-hook", "synchronous-throw-from-async-override"]; + + [Theory] + [MemberData(nameof(ThrowingArms))] + public async Task A_hook_that_throws_disposes_the_401_and_propagates_the_exception(string arm) + { + var log = new List(); + using var body = new TrackingResponseBody(log, "401"); + var failure = new SdkException("hook failed"); + var probe = arm switch + { + "sync-hook" => new ProbePolicy { Hook = _ => throw failure }, + "async-hook" => new ProbePolicy { AsyncHook = async _ => { await Task.Yield(); throw failure; } }, + _ => new ProbePolicy { AsyncHook = _ => throw failure }, + }; + using var transport = new ScriptedTransport(TestResponses.Unauthorized(null, body, "Basic realm=\"r\"")); + + var ex = await Assert.ThrowsAsync( + () => SendAsync(Pipeline(probe, transport), Get(), async: arm != "sync-hook")); + + Assert.Same(failure, ex); + Assert.Equal(1, body.DisposeCount); + } + + [Fact] + public async Task A_failing_dispose_is_suppressed_onto_the_hook_exception() + { + var disposeFailure = new InvalidOperationException("dispose failed"); + using var body = new TrackingResponseBody([], "401", disposeFailure); + var hookFailure = new SdkException("hook failed"); + var probe = new ProbePolicy { Hook = _ => throw hookFailure }; + using var transport = new ScriptedTransport(TestResponses.Unauthorized(null, body, "Basic realm=\"r\"")); + + var ex = await Assert.ThrowsAsync(() => SendAsync(Pipeline(probe, transport), Get(), async: true)); + + Assert.Same(hookFailure, ex); + Assert.Contains(disposeFailure, ex.Suppressed); + } + + // --------------------------------------------------------------------------------------------------------------- + // AUTH-31 + // --------------------------------------------------------------------------------------------------------------- + + private static Request WithStreamBody(Request request) => + request.WithBody(RequestBody.FromStream(new ChunkedReadStream([1, 2, 3], 2))); + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task A_replacement_with_a_non_replayable_body_is_not_sent_and_the_original_401_is_returned_undisposed(bool async) + { + using var body = new TrackingResponseBody([], "401"); + var probe = new ProbePolicy { Hook = c => WithStreamBody(c.Request.WithMethod(Method.Post)) }; + using var transport = new ScriptedTransport(TestResponses.Unauthorized(null, body, "Basic realm=\"r\""), TestResponses.Create(Status.Ok)); + + using var response = await SendAsync(Pipeline(probe, transport), Get().WithMethod(Method.Post).WithBody(RequestBody.FromString("x")), async); + + Assert.Equal(401, response.Status.Code); + Assert.Equal(1, transport.CallCount); + Assert.Equal(0, body.DisposeCount); + } + + [Theory] + [InlineData("replayable")] + [InlineData("none")] + public async Task A_replacement_with_a_replayable_or_no_body_is_sent(string kind) + { + var probe = new ProbePolicy + { + Hook = c => kind == "none" + ? c.Request + : c.Request.WithMethod(Method.Post).WithBody(RequestBody.FromString("again")), + }; + using var transport = new ScriptedTransport(TestResponses.Unauthorized("Basic realm=\"r\""), TestResponses.Create(Status.Ok)); + + using var response = await SendAsync(Pipeline(probe, transport), Get(), async: true); + + Assert.Equal(200, response.Status.Code); + Assert.Equal(2, transport.CallCount); + } + + [Fact] + public async Task The_gate_applies_to_every_replacement_regardless_of_which_hook_built_it() + { + var probe = new ProbePolicy { AsyncHook = c => new ValueTask(WithStreamBody(c.Request.WithMethod(Method.Put))) }; + using var transport = new ScriptedTransport(TestResponses.Unauthorized("Basic realm=\"r\""), TestResponses.Create(Status.Ok)); + + using var response = await SendAsync(Pipeline(probe, transport), Get(), async: true); + + Assert.Equal(401, response.Status.Code); + Assert.Equal(1, transport.CallCount); + } + + [Fact] + public async Task A_cross_origin_replacement_throws_InvalidOperationException_after_disposing_the_401() + { + using var body = new TrackingResponseBody([], "401"); + var probe = new ProbePolicy { Hook = c => c.Request.WithUrl(new Uri("https://evil.example.org/")) }; + using var transport = new ScriptedTransport(TestResponses.Unauthorized(null, body, "Basic realm=\"r\""), TestResponses.Create(Status.Ok)); + + await Assert.ThrowsAsync(() => SendAsync(Pipeline(probe, transport), Get(), async: true)); + + Assert.Equal(1, body.DisposeCount); + Assert.Equal(1, transport.CallCount); + } + + // --------------------------------------------------------------------------------------------------------------- + // AUTH-38 + // --------------------------------------------------------------------------------------------------------------- + + [Fact] + public async Task ProcessAsync_returns_a_faulted_task_instead_of_throwing_for_an_http_url() + { + var probe = new ProbePolicy(); + var context = TestContexts.For(Get("http://api.example.com/")); + var runner = TestContexts.RunnerOver(new ScriptedTransport(TestResponses.Create(Status.Ok))); + + var task = probe.ProcessAsync(Get("http://api.example.com/"), context, runner); + + await Assert.ThrowsAsync(async () => await task); + } + + [Fact] + public async Task ProcessAsync_faults_for_a_resolver_failure_and_a_credential_that_throws_synchronously() + { + var options = new RequestOptions { Auth = new AuthDescriptor(new AuthRequirement(AuthScheme.Digest)) }; + var resolver = new ProbePolicy(); + var task = resolver.ProcessAsync( + Get(), + TestContexts.For(Get(), requestOptions: options), + TestContexts.RunnerOver(new ScriptedTransport(TestResponses.Create(Status.Ok)))); + await Assert.ThrowsAsync(async () => await task); + + var throwing = new ProbePolicy { AsyncCredential = (_, _) => throw new SdkException("provider failed") }; + var second = throwing.ProcessAsync( + Get(), + TestContexts.For(Get()), + TestContexts.RunnerOver(new ScriptedTransport(TestResponses.Create(Status.Ok)))); + await Assert.ThrowsAsync(async () => await second); + } + + [Fact] + public void Process_throws_the_original_exception_on_the_sync_path() + { + var probe = new ProbePolicy(); + + Assert.Throws(() => probe.Process( + Get("http://api.example.com/"), + TestContexts.For(Get("http://api.example.com/")), + TestContexts.RunnerOver(new ScriptedTransport(TestResponses.Create(Status.Ok))))); + } + + [Fact] + public async Task The_sync_and_async_paths_produce_identical_requests() + { + async Task> Run(bool async) + { + var probe = new ProbePolicy { Hook = c => c.Request.WithHeaders(c.Request.Headers.Set("Authorization", "Retry-2")) }; + using var transport = new ScriptedTransport(TestResponses.Unauthorized("Basic realm=\"r\""), TestResponses.Create(Status.Ok)); + using var response = await SendAsync(Pipeline(probe, transport), Get(), async); + return [.. transport.Requests.Select(r => r.Headers.Get("Authorization"))]; + } + + Assert.Equal(await Run(async: true), await Run(async: false)); + } + + // --------------------------------------------------------------------------------------------------------------- + // The probe + // --------------------------------------------------------------------------------------------------------------- + + private sealed class ProbePolicy() + : AuthorizationPolicy(new AuthDescriptor(new AuthRequirement(AuthScheme.ApiKey)), [AuthScheme.ApiKey]) + { + private int _credentialCalls; + private int _challengeCalls; + + public int CredentialCalls => Volatile.Read(ref _credentialCalls); + + public int ChallengeCalls => Volatile.Read(ref _challengeCalls); + + public Func Credential { get; init; } = (_, _) => ("Authorization", "Probe-1"); + + public Func>? AsyncCredential { get; init; } + + public Func? Hook { get; init; } + + public Func>? AsyncHook { get; init; } + + protected override IReadOnlyList WithheldHeaderNames { get; } = [HttpHeaderName.WellKnown.Authorization]; + + protected override async ValueTask<(string HeaderName, string HeaderValue)?> GetCredentialAsync( + AuthRequirement requirement, + Request request, + PipelineContext context) + { + Interlocked.Increment(ref _credentialCalls); + return AsyncCredential is { } asyncCredential + ? await asyncCredential(requirement, request) + : Credential(requirement, request); + } + + protected override (string HeaderName, string HeaderValue)? GetCredential( + AuthRequirement requirement, + Request request, + PipelineContext context) + { + Interlocked.Increment(ref _credentialCalls); + return Credential(requirement, request); + } + + protected override ValueTask OnChallengeAsync(AuthChallengeContext challenge) + { + Interlocked.Increment(ref _challengeCalls); + if (AsyncHook is { } asyncHook) + { + return asyncHook(challenge); + } + + return Hook is { } hook ? new ValueTask(hook(challenge)) : base.OnChallengeAsync(challenge); + } + + protected override Request? OnChallenge(AuthChallengeContext challenge) + { + Interlocked.Increment(ref _challengeCalls); + return Hook is { } hook ? hook(challenge) : base.OnChallenge(challenge); + } + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BasicAuthPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BasicAuthPolicyTests.cs index 8860b5f..9e2f96b 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BasicAuthPolicyTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BasicAuthPolicyTests.cs @@ -144,4 +144,18 @@ public void Process_sync_stamps_like_ProcessAsync() Assert.Equal($"Basic {Base64("alice", "s3cr3t")}", sent.Headers.Get("Authorization")); } + + [Fact] + public async Task Preemptive_Basic_is_stamped_on_the_first_request() + { + var transport = new RecordingTransport(); + var pipeline = new PipelineBuilder() + .Add(new BasicAuthPolicy(new BasicCredential("alice", "s3cr3t"))) + .Build(transport); + + using var response = await pipeline.SendAsync(MakeRequest(), MakeOptions(), TestContext.Current.CancellationToken); + + Assert.Equal(1, transport.CallCount); + Assert.Equal($"Basic {Base64("alice", "s3cr3t")}", transport.LastRequest!.Headers.Get("Authorization")); + } } diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BearerTokenAuthPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BearerTokenAuthPolicyTests.cs index a9152ea..1ee54e6 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BearerTokenAuthPolicyTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BearerTokenAuthPolicyTests.cs @@ -232,4 +232,49 @@ public override ValueTask GetTokenAsync( return new ValueTask(new AccessToken(token, s_farFuture)); } } + + [Fact] + public async Task Per_call_OAuth2_scopes_fetch_a_token_for_those_scopes() + { + var recorder = new ScopeRecordingCredential(); + var transport = new RecordingTransport(); + var pipeline = new PipelineBuilder() + .Add(new BearerTokenAuthPolicy(recorder, "policy-scope")) + .Build(transport); + var options = new RequestOptions + { + Auth = new AuthDescriptor(new AuthRequirement(AuthScheme.OAuth2) { Scopes = ["call-scope"] }), + }; + + using var perCall = await pipeline.SendAsync(MakeRequest(), options, TestContext.Current.CancellationToken); + using var byDefault = await pipeline.SendAsync(MakeRequest(), MakeOptions(), TestContext.Current.CancellationToken); + + Assert.Equal(["call-scope", "policy-scope"], recorder.Scopes); + } + + private sealed class ScopeRecordingCredential : TokenCredential + { + private readonly List _scopes = []; + + public IReadOnlyList Scopes + { + get + { + lock (_scopes) + { + return [.. _scopes]; + } + } + } + + public override ValueTask GetTokenAsync(TokenRequestContext context, CancellationToken ct = default) + { + lock (_scopes) + { + _scopes.AddRange(context.Scopes); + } + + return new ValueTask(new AccessToken("tok", s_farFuture)); + } + } } diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ChallengeAuthPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ChallengeAuthPolicyTests.cs new file mode 100644 index 0000000..dea6944 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ChallengeAuthPolicyTests.cs @@ -0,0 +1,121 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Pipeline.Policies; +using Dexpace.Sdk.TestSupport.Transports; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline.Policies; + +/// AUTH-14, AUTH-26 and AUTH-30: the reactive, handler-driven policy. +[Trait("Category", "Unit")] +public sealed class ChallengeAuthPolicyTests +{ + private const string Url = "https://api.example.com/v1/items"; + + private static readonly DexpaceClientOptions s_options = new(); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static BasicChallengeHandler BasicHandler() => new(new BasicCredential("alice", "s3cr3t")); + + private static HttpPipeline Pipeline(IChallengeHandler handler, ScriptedTransport transport) => + new PipelineBuilder().Add(new ChallengeAuthPolicy(handler)).Build(transport, s_options); + + private static async Task SendAsync(HttpPipeline pipeline, string url, bool async) => + async + ? await pipeline.SendAsync(Request.Get(url), RequestOptions.Empty, Ct) + : pipeline.Send(Request.Get(url), RequestOptions.Empty, Ct); + + [Fact] + public async Task Nothing_is_stamped_on_the_first_request() + { + using var transport = new ScriptedTransport(TestResponses.Create(Status.Ok)); + + using var response = await SendAsync(Pipeline(BasicHandler(), transport), Url, async: true); + + Assert.Null(transport.LastRequest!.Headers.Get("Authorization")); + Assert.Equal(1, transport.CallCount); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task A_basic_401_is_answered_once_via_the_handler(bool async) + { + using var transport = new ScriptedTransport(TestResponses.Unauthorized("Basic realm=\"r\""), TestResponses.Create(Status.Ok)); + + using var response = await SendAsync(Pipeline(BasicHandler(), transport), Url, async); + + Assert.Equal(200, response.Status.Code); + Assert.Equal(2, transport.CallCount); + Assert.Null(transport.Requests[0].Headers.Get("Authorization")); + Assert.Equal("Basic YWxpY2U6czNjcjN0", transport.Requests[1].Headers.Get("Authorization")); + } + + [Fact] + public async Task A_401_the_handler_cannot_answer_is_returned() + { + using var transport = new ScriptedTransport(TestResponses.Unauthorized("Bearer realm=\"r\"")); + + using var response = await SendAsync(Pipeline(BasicHandler(), transport), Url, async: true); + + Assert.Equal(401, response.Status.Code); + Assert.Equal(1, transport.CallCount); + } + + [Fact] + public async Task A_still_401_after_the_replay_is_returned_without_a_third_call() + { + using var transport = new ScriptedTransport( + TestResponses.Unauthorized("Basic realm=\"r\""), + TestResponses.Unauthorized("Basic realm=\"r\"")); + + using var response = await SendAsync(Pipeline(BasicHandler(), transport), Url, async: true); + + Assert.Equal(401, response.Status.Code); + Assert.Equal(2, transport.CallCount); + } + + [Fact] + public async Task A_composite_falls_back_from_a_declined_handler() + { + var composite = new CompositeChallengeHandler(new DecliningHandler(), BasicHandler()); + using var transport = new ScriptedTransport(TestResponses.Unauthorized("Basic realm=\"r\""), TestResponses.Create(Status.Ok)); + + using var response = await SendAsync(Pipeline(composite, transport), Url, async: true); + + Assert.Equal(200, response.Status.Code); + Assert.NotNull(transport.Requests[1].Headers.Get("Authorization")); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task The_guard_refuses_http_before_the_first_request(bool async) + { + using var transport = new ScriptedTransport(TestResponses.Create(Status.Ok)); + + await Assert.ThrowsAsync( + () => SendAsync(Pipeline(BasicHandler(), transport), "http://api.example.com/", async)); + + Assert.Equal(0, transport.CallCount); + } + + [Fact] + public void The_client_descriptor_serves_Digest_and_Basic_only() + { + Assert.Throws(() => new ChallengeAuthPolicy(null!)); + } + + private sealed class DecliningHandler : IChallengeHandler + { + public Request? Authorize(IReadOnlyList challenges, Request request, bool proxy) => null; + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Security/AuthHttpsGuardTests.cs b/tests/Dexpace.Sdk.Core.Tests/Security/AuthHttpsGuardTests.cs index a7451fd..6207771 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Security/AuthHttpsGuardTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Security/AuthHttpsGuardTests.cs @@ -43,10 +43,11 @@ public async Task Basic_auth_over_plain_http_is_rejected_before_anything_is_sent .Add(new BasicAuthPolicy(new BasicCredential("u", "p"))) .Build(transport); - var ex = await Assert.ThrowsAsync( + var ex = await Assert.ThrowsAsync( () => pipeline.SendAsync(Request.Get(url), s_options, TestContext.Current.CancellationToken).AsTask()); - Assert.Equal(typeof(SdkException), ex.GetType()); + Assert.Equal(typeof(HttpsRequiredException), ex.GetType()); + Assert.Equal("http", ex.Scheme); Assert.Contains(nameof(BasicAuthPolicy), ex.Message, StringComparison.Ordinal); Assert.Contains("'http'", ex.Message, StringComparison.Ordinal); Assert.Equal(0, transport.CallCount); @@ -58,7 +59,7 @@ public async Task The_guard_runs_before_the_credential_is_resolved() var policy = new CountingAuthPolicy(); var pipeline = new PipelineBuilder().Add(policy).Build(new RecordingTransport()); - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => pipeline.SendAsync(Request.Get("http://api.example.com/"), s_options, TestContext.Current.CancellationToken).AsTask()); Assert.Equal(0, policy.CredentialCalls); @@ -72,7 +73,7 @@ public async Task A_bearer_token_is_never_fetched_for_a_plain_http_url() .Add(new BearerTokenAuthPolicy(credential, "https://api.example.com/.default")) .Build(new RecordingTransport()); - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => pipeline.SendAsync(Request.Get("http://api.example.com/"), s_options, TestContext.Current.CancellationToken).AsTask()); Assert.Equal(0, credential.Calls); @@ -89,7 +90,7 @@ public async Task The_rejection_is_not_retried() .Add(new BasicAuthPolicy(new BasicCredential("u", "p"))) .Build(transport); - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => pipeline.SendAsync(scenarioRequest, s_options, TestContext.Current.CancellationToken).AsTask()); Assert.Equal(0, transport.CallCount); @@ -131,17 +132,31 @@ public async Task A_credential_free_cross_origin_downgrade_hop_skips_the_guard() Assert.Null(transport.Requests[1].Headers.Get("Authorization")); } - private sealed class CountingAuthPolicy : AuthorizationPolicy + private sealed class CountingAuthPolicy() : AuthorizationPolicy( + new AuthDescriptor(new AuthRequirement(AuthScheme.ApiKey)), + [AuthScheme.ApiKey]) { public int CredentialCalls { get; private set; } - protected override HttpHeaderName WithheldHeaderName => HttpHeaderName.WellKnown.Authorization; + protected override IReadOnlyList WithheldHeaderNames { get; } = [HttpHeaderName.WellKnown.Authorization]; - protected override ValueTask<(string HeaderName, string HeaderValue)> GetCredentialAsync(PipelineContext context) + protected override ValueTask<(string HeaderName, string HeaderValue)?> GetCredentialAsync( + AuthRequirement requirement, + Request request, + PipelineContext context) { CredentialCalls++; return new(("Authorization", "Secret")); } + + protected override (string HeaderName, string HeaderValue)? GetCredential( + AuthRequirement requirement, + Request request, + PipelineContext context) + { + CredentialCalls++; + return ("Authorization", "Secret"); + } } private sealed class CountingTokenCredential : TokenCredential diff --git a/tests/Dexpace.Sdk.TestSupport/Transports/TestResponses.cs b/tests/Dexpace.Sdk.TestSupport/Transports/TestResponses.cs index 0dc7153..ba378b5 100644 --- a/tests/Dexpace.Sdk.TestSupport/Transports/TestResponses.cs +++ b/tests/Dexpace.Sdk.TestSupport/Transports/TestResponses.cs @@ -35,4 +35,25 @@ public static Response Create( /// The request that produced the redirect, or for the default. public static Response Redirect(int statusCode, string location, Request? request = null) => Create(Status.FromCode(statusCode), request, new Headers.Builder().Set("Location", location).Build()); + + /// A 401 carrying one WWW-Authenticate field line per value. + /// The field values, in order. + /// The response. + public static Response Unauthorized(params string[] wwwAuthenticate) => Unauthorized(null, null, wwwAuthenticate); + + /// A 401 carrying one WWW-Authenticate field line per value. + /// The request the response answers, or a default. + /// The body, or none. + /// The field values, in order. + /// The response. + public static Response Unauthorized(Request? request, ResponseBody? body, params string[] wwwAuthenticate) + { + var headers = new Headers.Builder(); + foreach (var value in wwwAuthenticate) + { + headers.Add("WWW-Authenticate", value); + } + + return Create(Status.Unauthorized, request, headers.Build(), body); + } } From e07a1373137077dfb265d6f359174ad2afd5f935 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 8 Oct 2026 20:52:06 +0300 Subject: [PATCH 05/13] feat!: phase 6c token cache and bearer challenge (AUTH-11, AUTH-34..AUTH-37) --- BannedSymbols.txt | 2 +- CHANGELOG.md | 11 + src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs | 417 +++++++--- .../Diagnostics/DexpaceLogEvents.cs | 8 +- .../Errors/TokenProviderException.cs | 20 + src/Dexpace.Sdk.Core/Internal/AuthLog.cs | 44 + .../Internal/BackgroundWork.cs | 32 + .../Internal/BoundedMapExtensions.cs | 33 + .../Policies/BearerTokenAuthPolicy.cs | 107 ++- src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt | 9 + .../Architecture/BannedSymbolsSiteTests.cs | 62 ++ .../Auth/AccessTokenCacheTests.cs | 749 +++++++++++++++++- .../Diagnostics/AuthLogTests.cs | 53 ++ .../Diagnostics/LogVocabularyTests.cs | 9 +- .../Errors/HttpsRequiredExceptionTests.cs | 1 + .../Errors/TokenProviderExceptionTests.cs | 45 ++ .../Internal/BackgroundWorkTests.cs | 83 ++ .../Internal/BoundedMapExtensionsTests.cs | 38 + .../Pipeline/BearerPipelineTests.cs | 76 ++ .../Policies/BearerTokenAuthPolicyTests.cs | 206 ++++- 20 files changed, 1860 insertions(+), 145 deletions(-) create mode 100644 src/Dexpace.Sdk.Core/Errors/TokenProviderException.cs create mode 100644 src/Dexpace.Sdk.Core/Internal/AuthLog.cs create mode 100644 src/Dexpace.Sdk.Core/Internal/BackgroundWork.cs create mode 100644 src/Dexpace.Sdk.Core/Internal/BoundedMapExtensions.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Architecture/BannedSymbolsSiteTests.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Diagnostics/AuthLogTests.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Errors/TokenProviderExceptionTests.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Internal/BackgroundWorkTests.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Internal/BoundedMapExtensionsTests.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/BearerPipelineTests.cs diff --git a/BannedSymbols.txt b/BannedSymbols.txt index 39d3c5b..f4ab9be 100644 --- a/BannedSymbols.txt +++ b/BannedSymbols.txt @@ -6,7 +6,7 @@ # with a why-comment naming the design section that sanctions it; never delete a line to land a change. # Cancellation and execution-context discipline (design §8.1, §8.3). Flow suppression is allowed only inside the -# one background-launch helper of design §5.4, which does not exist yet. +# one background-launch helper of design §5.4, Internal/BackgroundWork.cs. M:System.Threading.Thread.Interrupt;Thread.Interrupt is prohibited (design §8.3); cancel through a CancellationToken M:System.Threading.ExecutionContext.SuppressFlow;Flow suppression belongs only in the background-launch helper of design §5.4 M:System.Threading.ThreadPool.UnsafeQueueUserWorkItem(System.Threading.WaitCallback,System.Object);Flow-suppressing queueing belongs only in the background-launch helper of design §5.4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f792a9..72c36b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -301,6 +301,14 @@ Roadmap phase 1, defects S1–S9, each pinned by a `[Trait("Category", "Security - **Breaking:** every auth policy honours `RequestOptions.Auth` / `OperationAuth` (a per-call `NoAuth` sends the call anonymously, an unservable scheme throws `AuthResolutionException` before anything is sent) and answers a `401` carrying `WWW-Authenticate` through its hook, replaying once when the replacement is same-origin and replayable. +- **Breaking:** `AccessTokenCache` refreshes 30 seconds before expiry by default (was: at expiry); stamps a still-valid token and + refreshes it in the background, one refresh per key (was: awaited the refresh on the request path); logs a failed + background refresh as event 160 `dexpace.auth.token_refresh_failed` (was: silent); rejects a default or already-expired + provider token with `TokenProviderException`; lets the waiters of a failed fetch share its outcome; and is bounded to + 1024 keys (`AUTH-11`, `AUTH-34`..`AUTH-37`, `XCUT-12`, `XCUT-14`). +- **Breaking:** `BearerTokenAuthPolicy` retries a call once with a freshly fetched token when the server answers `401` with + a `Bearer` challenge (was: returned the `401`), and its synchronous path no longer blocks on the asynchronous one + (`AUTH-30`, `AUTH-31`, `AUTH-36`). - **Breaking (phase 6b redirect, PR 1):** `RedirectOptions` is reshaped (`REDIR-3`, `REDIR-4`, `REDIR-5`, `REDIR-15`, `REDIR-17`, `REDIR-20`, `REDIR-26`). `StripSensitiveHeadersOnCrossOrigin` is removed (it has had no effect since phase 1; stripping always @@ -520,5 +528,8 @@ Roadmap phase 1, defects S1–S9, each pinned by a `[Trait("Category", "Security `ProxyAuthenticate` and `ProxyAuthorization` (`AUTH-12`, `AUTH-13`, `AUTH-14`, `AUTH-23`, `AUTH-25`). - `ChallengeAuthPolicy` (Basic or Digest answered on a `401`, never preemptive), `AuthChallengeContext` and `HttpsRequiredException` (`AUTH-26`..`AUTH-33`, `AUTH-38`). +- `AccessTokenCache.Get` (a real synchronous path), `AccessTokenCache.DefaultRefreshMargin` and `RefreshMargin`, + `BearerTokenAuthPolicy(AccessTokenCache, params string[])`, `TokenProviderException`, and log event 160 + `DexpaceLogEvents.TokenRefreshFailed` (`AUTH-35`, `AUTH-37`). [Unreleased]: https://github.com/dexpace/dotnet-sdk/commits/main diff --git a/src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs b/src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs index 305840a..6ba7564 100644 --- a/src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs +++ b/src/Dexpace.Sdk.Core/Auth/AccessTokenCache.cs @@ -1,54 +1,56 @@ // Copyright (c) 2026 dexpace and Omar Aljarrah. // Licensed under the MIT License. See LICENSE in the repository root for details. +using System.Runtime.ExceptionServices; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Internal; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + namespace Dexpace.Sdk.Core.Auth; /// -/// An in-memory token cache that wraps a and provides -/// proactive refresh, expiry-based invalidation, and single-flight protection against -/// concurrent refresh stampedes. +/// A per-key, bounded cache of access tokens in front of a (AUTH-11, AUTH-34 to AUTH-37). /// /// /// -/// Tokens are cached keyed by the . A cached token -/// is served without calling the underlying credential as long as both: -/// -/// now < ExpiresOn -/// RefreshOn is OR now < RefreshOn -/// +/// For a cached token t and margin M (, 30 seconds by default), a request finds +/// the token in one of three zones. Fresh (t is not within M of its expiry and not past its +/// ): stamped at once, no lock taken. Expiring (still valid, but not fresh): the +/// valid token is stamped now and at most one background refresh per key is started. Expired or missing: the +/// request awaits a foreground fetch. The cached token's own expiry is judged with AUTH-10's strict "after". /// /// -/// Once either condition fails, a single caller acquires the per-key semaphore and calls -/// the credential. All concurrent callers wait on the semaphore and perform a double-checked -/// read after acquiring it. Only one network round-trip fires per key per refresh cycle. +/// A burst of requests at expiry costs one fetch: a waiter that finds a fetch finished while it waited adopts that +/// outcome, the new token or the same failure, instead of fetching again, so a provider outage fails the waiters +/// together after one attempt. A failure is adopted only by waiters already waiting, so no failure is cached, and a +/// fetcher's own cancellation is never adopted. Coalesced failures share one exception instance. The background +/// refresh runs under and cannot be cancelled; it captures no ambient state, and a +/// failure is logged (event 160) and fails nothing. Entries live in a bounded map of 1024 keys; evicting a live entry +/// costs at most one extra fetch. /// /// -/// Failure while valid: if the credential throws but a token remains valid -/// (now < ExpiresOn), the cached token is returned silently. If no valid token -/// exists the exception propagates. +/// A token the provider returns that is default or already expired is a , and +/// a provider exception propagates unwrapped; neither is cached. /// /// -/// This class is thread-safe. Inject a custom for deterministic -/// unit testing. +/// Breaking: tokens are refreshed 30 seconds before expiry by default (was: at expiry); a still-valid token is +/// stamped while the refresh runs in the background (was: the refresh was awaited on the request path); a failed refresh +/// while the token is valid is logged (was: silent); a default or already-expired provider token is rejected; waiters +/// share one fetch's outcome; the cache is bounded. /// /// public sealed class AccessTokenCache { + private const int Capacity = 1024; + private readonly TokenCredential _credential; private readonly TimeProvider _time; + private readonly BoundedMap _entries = new(Capacity); - // Per-key state: the current cached token (null = never fetched) + a semaphore for - // single-flight. One CacheEntry per unique TokenRequestContext.CacheKey. - private readonly System.Collections.Concurrent.ConcurrentDictionary _entries = new(); - - /// - /// Initializes an backed by the given credential. - /// - /// The underlying credential to call when a token is needed. - /// - /// A used to determine "now". Defaults to - /// when . - /// + /// Initializes a cache over . + /// The provider the cache fronts. + /// The clock; defaults to . /// is . public AccessTokenCache(TokenCredential credential, TimeProvider? timeProvider = null) { @@ -57,116 +59,323 @@ public AccessTokenCache(TokenCredential credential, TimeProvider? timeProvider = _time = timeProvider ?? TimeProvider.System; } - /// - /// Returns a valid for the given , - /// fetching and caching one if necessary. - /// - /// The token request context identifying the scopes and claims. - /// A token to cancel an in-progress credential call. - /// - /// A resolving to a valid access token. - /// - public async ValueTask GetAsync(TokenRequestContext context, CancellationToken ct = default) + /// The default refresh margin: 30 seconds (AUTH-34). + public static TimeSpan DefaultRefreshMargin { get; } = TimeSpan.FromSeconds(30); + + /// How long before expiry a token counts as expiring and is refreshed in the background. + /// The value is negative. + public TimeSpan RefreshMargin { - var entry = _entries.GetOrAdd(context.CacheKey, static _ => new CacheEntry()); - var now = _time.GetUtcNow(); + get; + init + { + ArgumentOutOfRangeException.ThrowIfLessThan(value, TimeSpan.Zero); + field = value; + } + } = DefaultRefreshMargin; + + internal int EntryCount => _entries.EntryCount; + + internal int EntryCapacity => _entries.Capacity; + + /// Returns a token for , fetching one when none is usable. + /// The scopes and claims requested. + /// Cancels this caller's wait, never a fetch another caller is running. + /// The token. + public ValueTask GetAsync(TokenRequestContext context, CancellationToken ct = default) => + GetAsync(context, NullLogger.Instance, ct); - // Fast path: read the holder once (volatile acquire) and return immediately when the - // token is still valid and does not need a proactive refresh. The volatile read ensures - // that any holder published by a slow-path writer is fully visible to this thread. - var fastHolder = entry.Holder; - if (fastHolder is not null && IsValid(fastHolder.Token, now) && !NeedsRefresh(fastHolder.Token, now)) + /// Returns a token for on the synchronous path (P6c-26). + /// The scopes and claims requested. + /// Cancels this caller's wait. + /// The token. + public AccessToken Get(TokenRequestContext context, CancellationToken ct = default) => + Get(context, NullLogger.Instance, ct); + + internal ValueTask GetAsync(TokenRequestContext context, ILogger logger, CancellationToken ct) + { + var entry = EntryFor(context); + if (TryUseCached(entry, context, logger, out var cached)) { - return fastHolder.Token; + return new ValueTask(cached); + } + + return FetchAsync(entry, context, async: true, ct); + } + + internal AccessToken Get(TokenRequestContext context, ILogger logger, CancellationToken ct) + { + var entry = EntryFor(context); + return TryUseCached(entry, context, logger, out var cached) + ? cached + : SyncPath.GetResult(FetchAsync(entry, context, async: false, ct)); + } + + internal ValueTask GetAfterRejectionAsync( + TokenRequestContext context, + string rejectedHeaderValue, + CancellationToken ct) => + RejectAsync(EntryFor(context), context, rejectedHeaderValue, async: true, ct); + + internal AccessToken GetAfterRejection(TokenRequestContext context, string rejectedHeaderValue, CancellationToken ct) => + SyncPath.GetResult(RejectAsync(EntryFor(context), context, rejectedHeaderValue, async: false, ct)); + + internal Task? PendingRefresh(TokenRequestContext context) => + _entries.TryGetValue(context.CacheKey, out var entry) ? entry.RefreshTask : null; + + private static ZoneKind Classify(AccessToken token, DateTimeOffset now, TimeSpan margin) + { + if (token.IsExpired(now, TimeSpan.Zero)) + { + return ZoneKind.Expired; + } + + return !token.IsExpired(now, margin) && (token.RefreshOn is not { } refreshOn || now < refreshOn) + ? ZoneKind.Fresh + : ZoneKind.Expiring; + } + + private static void Validate(AccessToken token, DateTimeOffset now) + { + if (token.Token is null) + { + throw new TokenProviderException("The token provider returned a default token."); + } + + if (token.IsExpired(now, TimeSpan.Zero)) + { + throw new TokenProviderException("The token provider returned a token that is already expired."); + } + } + + private static void Publish(CacheEntry entry, FetchOutcome outcome) + { + if (outcome.Token is { } token) + { + entry.Holder = new TokenHolder(token); + } + + entry.Last = outcome; + entry.AdvanceGeneration(); + } + + private CacheEntry EntryFor(TokenRequestContext context) => + _entries.GetOrAdd(context.CacheKey, static () => new CacheEntry()); + + // The hot path: no lock. A fresh token is returned; an expiring one is returned and one refresh is started. + private bool TryUseCached(CacheEntry entry, TokenRequestContext context, ILogger logger, out AccessToken token) + { + token = default; + if (entry.Holder is not { } holder) + { + return false; + } + + switch (Classify(holder.Token, _time.GetUtcNow(), RefreshMargin)) + { + case ZoneKind.Fresh: + token = holder.Token; + return true; + case ZoneKind.Expiring: + StartBackgroundRefresh(entry, context, logger); + token = holder.Token; + return true; + default: + return false; + } + } + + private void StartBackgroundRefresh(CacheEntry entry, TokenRequestContext context, ILogger logger) + { + // Wait(0) never blocks: if the gate is taken a fetch is already running and this request just uses its valid token. + if (!entry.Gate.Wait(0)) + { + return; } - // Slow path: acquire the semaphore for this key. - await entry.Semaphore.WaitAsync(ct).ConfigureAwait(false); try { - // Double-check after acquiring. - now = _time.GetUtcNow(); - var recheckedHolder = entry.Holder; - if (recheckedHolder is not null && IsValid(recheckedHolder.Token, now) && !NeedsRefresh(recheckedHolder.Token, now)) + if (entry.Holder is { } holder && Classify(holder.Token, _time.GetUtcNow(), RefreshMargin) == ZoneKind.Fresh) { - return recheckedHolder.Token; + entry.Gate.Release(); + return; } - // Try to refresh from the credential. - try - { - var fresh = await _credential.GetTokenAsync(context, ct).ConfigureAwait(false); - // Volatile write releases the fully-constructed holder to all threads. - entry.Holder = new TokenHolder(fresh); - return fresh; - } - catch + entry.RefreshTask = BackgroundWork.Run(() => RefreshInBackgroundAsync(entry, context, logger)); + } + catch + { + entry.Gate.Release(); + throw; + } + } + + private async Task RefreshInBackgroundAsync(CacheEntry entry, TokenRequestContext context, ILogger logger) + { + try + { + // The refresh outlives the request that triggered it, so it runs under no token; a provider bounds its own call. + var token = await _credential.GetTokenAsync(context, CancellationToken.None).ConfigureAwait(false); + Validate(token, _time.GetUtcNow()); + Publish(entry, new FetchOutcome(token, null, Canceled: false)); + } + catch (Exception ex) when (!ExceptionFacts.IsFatal(ex)) + { + Publish(entry, new FetchOutcome(null, ExceptionDispatchInfo.Capture(ex), Canceled: false)); + AuthLog.TokenRefreshFailed(logger, ex); + } + finally + { + entry.Gate.Release(); + } + } + + private async ValueTask FetchAsync( + CacheEntry entry, + TokenRequestContext context, + bool async, + CancellationToken ct) + { + var generation = entry.ReadGeneration(); + await AcquireAsync(entry, async, ct).ConfigureAwait(false); + try + { + // A fetch finished while this caller waited: adopt its outcome instead of fetching again. + if (entry.ReadGeneration() != generation && entry.Last is { Canceled: false } last) { - // Re-sample time: GetTokenAsync may have taken a long time. Validate the - // cached token against the clock *after* the (slow) failing call, so a token - // that expired during the attempt is not returned as valid. - var nowAfterFailure = _time.GetUtcNow(); - var fallbackHolder = entry.Holder; - if (fallbackHolder is not null && IsValid(fallbackHolder.Token, nowAfterFailure)) + last.Failure?.Throw(); + if (last.Token is { } adopted) { - return fallbackHolder.Token; + return adopted; } + } - throw; + if (entry.Holder is { } holder && Classify(holder.Token, _time.GetUtcNow(), RefreshMargin) != ZoneKind.Expired) + { + return holder.Token; } + + return await FetchUnderGateAsync(entry, context, async, ct).ConfigureAwait(false); } finally { - entry.Semaphore.Release(); + entry.Gate.Release(); } } - // A token is "valid" while the clock hasn't yet reached ExpiresOn. - private static bool IsValid(AccessToken token, DateTimeOffset now) => - token.ExpiresOn is not { } expiresOn || now < expiresOn; + private async ValueTask RejectAsync( + CacheEntry entry, + TokenRequestContext context, + string rejectedHeaderValue, + bool async, + CancellationToken ct) + { + await AcquireAsync(entry, async, ct).ConfigureAwait(false); + try + { + // AUTH-36: evict only the token that was rejected; one another request already replaced survives. + if (entry.Holder is { } holder + && string.Equals("Bearer " + holder.Token.Token, rejectedHeaderValue, StringComparison.Ordinal)) + { + entry.Holder = null; + } - // A token "needs refresh" once RefreshOn has been reached (proactive hint). - private static bool NeedsRefresh(AccessToken token, DateTimeOffset now) => - token.RefreshOn is { } refreshOn && now >= refreshOn; + if (entry.Holder is { } current && Classify(current.Token, _time.GetUtcNow(), RefreshMargin) != ZoneKind.Expired) + { + return current.Token; + } - // Immutable wrapper so the token can be published through a single volatile reference, - // giving acquire/release ordering on all platforms. AccessToken is a multi-field struct; - // publishing it directly would allow fast-path readers to observe a torn or partially - // written value. Boxing it inside a reference type (TokenHolder) reduces the published - // value to a single pointer-width write, which the .NET memory model guarantees to be - // atomic and not torn. - private sealed class TokenHolder + return await FetchUnderGateAsync(entry, context, async, ct).ConfigureAwait(false); + } + finally + { + entry.Gate.Release(); + } + } + + private static async ValueTask AcquireAsync(CacheEntry entry, bool async, CancellationToken ct) + { + if (async) + { + await entry.Gate.WaitAsync(ct).ConfigureAwait(false); + } + else + { + entry.Gate.Wait(ct); + } + } + + // The gate is held. Fetch, validate, publish and record the outcome; a failure is recorded for the waiters and rethrown. + private async ValueTask FetchUnderGateAsync( + CacheEntry entry, + TokenRequestContext context, + bool async, + CancellationToken ct) + { + try + { + var token = async + ? await _credential.GetTokenAsync(context, ct).ConfigureAwait(false) + : _credential.GetToken(context, ct); + Validate(token, _time.GetUtcNow()); + Publish(entry, new FetchOutcome(token, null, Canceled: false)); + return token; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // The fetcher's own cancellation is not the waiters' outcome: the next one fetches. + Publish(entry, new FetchOutcome(null, null, Canceled: true)); + throw; + } + catch (Exception ex) when (!ExceptionFacts.IsFatal(ex)) + { + Publish(entry, new FetchOutcome(null, ExceptionDispatchInfo.Capture(ex), Canceled: false)); + throw; + } + } + + private enum ZoneKind { - /// Initializes a wrapping the given token. - /// The token to publish atomically. - public TokenHolder(AccessToken token) => Token = token; + Fresh, + Expiring, + Expired, + } - /// The wrapped access token. - public AccessToken Token { get; } + // Immutable wrapper so the token can be published through a single volatile reference, giving acquire/release + // ordering on all platforms. AccessToken is a multi-field struct; publishing it directly would allow fast-path readers + // to observe a torn value. + private sealed class TokenHolder(AccessToken token) + { + public AccessToken Token { get; } = token; } + private sealed record FetchOutcome(AccessToken? Token, ExceptionDispatchInfo? Failure, bool Canceled); + private sealed class CacheEntry { - // volatile ensures that a fast-path reader always observes the most recently published - // holder (acquire semantics on read, release semantics on write). Combined with the - // pointer-width atomicity of reference reads/writes, this is safe without a lock on - // the fast path. + // The generation advances once per finished fetch, after Last is set. + private int _generation; private volatile TokenHolder? _holder; + private volatile FetchOutcome? _last; - /// - /// The most recently published token holder, or if no token - /// has been fetched yet. Reads and writes are reference-atomic and carry - /// acquire/release ordering via the modifier. - /// - public TokenHolder? Holder + internal TokenHolder? Holder { get => _holder; set => _holder = value; } - /// - /// A semaphore (initial count = 1) that serializes refresh calls for this key. - /// - public SemaphoreSlim Semaphore { get; } = new SemaphoreSlim(1, 1); + internal FetchOutcome? Last + { + get => _last; + set => _last = value; + } + + internal int ReadGeneration() => Volatile.Read(ref _generation); + + internal void AdvanceGeneration() => Interlocked.Increment(ref _generation); + + internal SemaphoreSlim Gate { get; } = new(1, 1); + + internal Task? RefreshTask { get; set; } } } diff --git a/src/Dexpace.Sdk.Core/Diagnostics/DexpaceLogEvents.cs b/src/Dexpace.Sdk.Core/Diagnostics/DexpaceLogEvents.cs index 21ed73c..192f133 100644 --- a/src/Dexpace.Sdk.Core/Diagnostics/DexpaceLogEvents.cs +++ b/src/Dexpace.Sdk.Core/Diagnostics/DexpaceLogEvents.cs @@ -13,7 +13,7 @@ namespace Dexpace.Sdk.Core.Diagnostics; /// Every member is a , so PublicAPI.Unshipped.txt records each value and renaming one is a /// reviewed change. The ids are partitioned by subsystem (P5b-21): 100-109 request and response, 110-119 transport header /// drops (reserved for the transport conformance phase, OBS-19), 120-129 instrumentation diagnostics, 130-139 disposal, -/// 140-149 retry, 150-159 redirect and 160-169 authentication (reserved). +/// 140-149 retry, 150-159 redirect and 160-169 authentication. /// /// public static class DexpaceLogEvents @@ -51,6 +51,9 @@ public static class DexpaceLogEvents /// The warning written when disposing a resource failed and the failure was suppressed (). public const string DisposeSuppressed = "dexpace.dispose.suppressed"; + /// The warning written when a background bearer-token refresh failed and the still-valid token kept being used (, AUTH-37). + public const string TokenRefreshFailed = "dexpace.auth.token_refresh_failed"; + /// The numeric id of . public const int HttpRequestId = 100; @@ -86,4 +89,7 @@ public static class DexpaceLogEvents /// The numeric id of . public const int RedirectLocationMalformedId = 154; + + /// The numeric id of ; it sits in the auth range reserved from 160 (OBS-39). + public const int TokenRefreshFailedId = 160; } diff --git a/src/Dexpace.Sdk.Core/Errors/TokenProviderException.cs b/src/Dexpace.Sdk.Core/Errors/TokenProviderException.cs new file mode 100644 index 0000000..9433275 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Errors/TokenProviderException.cs @@ -0,0 +1,20 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Errors; + +/// +/// A token provider returned a default or an already-expired token (AUTH-35). +/// +/// +/// Never retryable and never contains the token. +/// +public sealed class TokenProviderException : SdkException +{ + /// Initializes the exception. + /// A description that does not contain the token. + public TokenProviderException(string message) + : base(message) + { + } +} diff --git a/src/Dexpace.Sdk.Core/Internal/AuthLog.cs b/src/Dexpace.Sdk.Core/Internal/AuthLog.cs new file mode 100644 index 0000000..2ae580f --- /dev/null +++ b/src/Dexpace.Sdk.Core/Internal/AuthLog.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Diagnostics; +using Dexpace.Sdk.Core.Errors; +using Microsoft.Extensions.Logging; + +namespace Dexpace.Sdk.Core.Internal; + +/// +/// The authentication layer's one log event, dexpace.auth.token_refresh_failed (id 160; AUTH-37, P6c-37). +/// +/// +/// Written under the emission guard of 5b (OBS-20): a logger that is disabled, or that throws, never affects the call. +/// +internal static class AuthLog +{ + // The placeholder name is the published OpenTelemetry-style state key (OBS-39, P5b-3), which CA1727's PascalCase rule + // cannot express; the fixed-key Define form is the verified way (design §8.1). +#pragma warning disable CA1727 + private static readonly Action s_tokenRefreshFailed = LoggerMessage.Define( + LogLevel.Warning, + new EventId(DexpaceLogEvents.TokenRefreshFailedId, DexpaceLogEvents.TokenRefreshFailed), + "Background token refresh failed with {error.type}."); +#pragma warning restore CA1727 + + /// Reports that a background token refresh failed; the still-valid token keeps being used. + /// The logger of the call that launched the refresh. + /// What the provider threw. + internal static void TokenRefreshFailed(ILogger logger, Exception failure) + { + try + { + if (logger.IsEnabled(LogLevel.Warning)) + { + s_tokenRefreshFailed(logger, HttpSemanticConventions.ErrorType(failure), failure); + } + } + catch (Exception ex) when (!ExceptionFacts.IsFatal(ex)) + { + // A logger that throws must not change the outcome of a token fetch (OBS-20). + } + } +} diff --git a/src/Dexpace.Sdk.Core/Internal/BackgroundWork.cs b/src/Dexpace.Sdk.Core/Internal/BackgroundWork.cs new file mode 100644 index 0000000..6ed8124 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Internal/BackgroundWork.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Internal; + +/// +/// Starts work that must not capture the caller's ambient state: the one sanctioned site of +/// ExecutionContext.SuppressFlow (design §5.4, §8.1; CTX-19). +/// +internal static class BackgroundWork +{ + /// Runs on the thread pool with no execution context flowed into it. + /// The work. A failure faults the returned task, never the caller. + /// The task running the work; callers observe it. + internal static Task Run(Func work) + { + ArgumentNullException.ThrowIfNull(work); + if (ExecutionContext.IsFlowSuppressed()) + { + return Task.Run(work); + } + + // The one sanctioned use (design §5.4): the task captures no Activity.Current, no AsyncLocal and no culture, so a + // background refresh neither pins the triggering call's span nor reports under it (CTX-19, P6c-25). +#pragma warning disable RS0030 + using (ExecutionContext.SuppressFlow()) + { + return Task.Run(work); + } +#pragma warning restore RS0030 + } +} diff --git a/src/Dexpace.Sdk.Core/Internal/BoundedMapExtensions.cs b/src/Dexpace.Sdk.Core/Internal/BoundedMapExtensions.cs new file mode 100644 index 0000000..e3abd65 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Internal/BoundedMapExtensions.cs @@ -0,0 +1,33 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Internal; + +/// Helpers over . +internal static class BoundedMapExtensions +{ + /// + /// Returns the value for , creating and adding one when absent. Under a race exactly one value + /// wins; a value evicted while a caller still holds it keeps working for that caller (the cap's documented price, + /// XCUT-14). + /// + internal static TValue GetOrAdd(this BoundedMap map, TKey key, Func factory) + where TKey : notnull + where TValue : class + { + ArgumentNullException.ThrowIfNull(map); + ArgumentNullException.ThrowIfNull(factory); + if (map.TryGetValue(key, out var existing)) + { + return existing; + } + + var created = factory(); + if (map.TryAdd(key, created)) + { + return created; + } + + return map.TryGetValue(key, out existing) ? existing : created; + } +} diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/BearerTokenAuthPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/BearerTokenAuthPolicy.cs index 2232e4c..7664bfe 100644 --- a/src/Dexpace.Sdk.Core/Pipeline/Policies/BearerTokenAuthPolicy.cs +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/BearerTokenAuthPolicy.cs @@ -15,25 +15,31 @@ namespace Dexpace.Sdk.Core.Pipeline.Policies; /// /// /// The policy creates exactly one for the lifetime of the policy -/// instance, wrapping the supplied . All pipeline invocations share -/// that cache, so concurrent or sequential requests reuse a valid cached token and avoid redundant -/// credential calls. +/// instance, wrapping the supplied , or uses the one a caller hands it. All pipeline +/// invocations share that cache, so concurrent or sequential requests reuse a valid cached token, a token near expiry is +/// refreshed in the background, and a burst at expiry costs one fetch (AUTH-34, AUTH-37). /// /// -/// Token acquisition is always async; the policy calls -/// using the -/// so that request cancellation propagates into -/// token fetching. +/// Both paths are real: the asynchronous path awaits +/// and the synchronous path calls , so no thread is blocked on a task. The scopes of +/// the resolved requirement are used (a per-call OAuth2 requirement may ask for others), falling back to the +/// policy's. /// /// -/// 401 re-acquisition: re-acquiring a token on a 401 challenge response is deferred -/// to the challenge-handling work (to arrive with ChallengeHandler). This policy performs -/// a single token-get per request. +/// 401 re-acquisition: a 401 whose WWW-Authenticate carries a Bearer challenge evicts the +/// rejected token (only if it is still the cached one), fetches a fresh one and retries the call once, whatever the +/// method; the base's replayability gate protects a body that cannot be written again (AUTH-31, AUTH-36). If the provider +/// hands back the very token that was rejected, the 401 is returned. A second 401 is returned as it is +/// (AUTH-30). /// /// /// Credentials are withheld when the request has been redirected to a different origin; see /// for the cross-origin withholding contract. /// +/// +/// Breaking: a 401 with a Bearer challenge is now retried once with a fresh token (was: returned +/// to the caller). +/// /// public sealed class BearerTokenAuthPolicy : AuthorizationPolicy { @@ -56,11 +62,22 @@ public sealed class BearerTokenAuthPolicy : AuthorizationPolicy /// or is . /// public BearerTokenAuthPolicy(TokenCredential credential, params string[] scopes) - : base(ClientDescriptor(scopes), [AuthScheme.OAuth2]) + : this(CacheOver(credential), scopes) { - ArgumentNullException.ThrowIfNull(credential); + } - _cache = new AccessTokenCache(credential); + /// + /// Initializes a over an existing cache, so a caller can set the refresh margin and + /// the , or share one cache between policies. + /// + /// The token cache. + /// The OAuth 2.0 scopes to request. + /// or is . + public BearerTokenAuthPolicy(AccessTokenCache cache, params string[] scopes) + : base(ClientDescriptor(scopes), [AuthScheme.OAuth2]) + { + ArgumentNullException.ThrowIfNull(cache); + _cache = cache; _scopes = [.. scopes]; } @@ -74,10 +91,10 @@ public BearerTokenAuthPolicy(TokenCredential credential, params string[] scopes) PipelineContext context) { var token = await _cache - .GetAsync(TokenContextFor(requirement), context.CancellationToken) + .GetAsync(TokenContextFor(requirement), context.State.Logger, context.CancellationToken) .ConfigureAwait(false); - return (HttpHeaderName.WellKnown.Authorization.Original, $"Bearer {token.Token}"); + return (HttpHeaderName.WellKnown.Authorization.Original, Bearer(token)); } /// @@ -86,13 +103,59 @@ protected override (string HeaderName, string HeaderValue)? GetCredential( Request request, PipelineContext context) { - // Removed by task 5.5 (P6c-26): the cache gains a real synchronous path there. -#pragma warning disable RS0030 - var token = _cache - .GetAsync(TokenContextFor(requirement), context.CancellationToken) - .AsTask().GetAwaiter().GetResult(); -#pragma warning restore RS0030 - return (HttpHeaderName.WellKnown.Authorization.Original, $"Bearer {token.Token}"); + var token = _cache.Get(TokenContextFor(requirement), context.State.Logger, context.CancellationToken); + return (HttpHeaderName.WellKnown.Authorization.Original, Bearer(token)); + } + + /// + protected override async ValueTask OnChallengeAsync(AuthChallengeContext challenge) + { + ArgumentNullException.ThrowIfNull(challenge); + if (RejectedHeader(challenge) is not { } rejected) + { + return null; + } + + var token = await _cache + .GetAfterRejectionAsync(TokenContextFor(challenge.Requirement), rejected, challenge.Context.CancellationToken) + .ConfigureAwait(false); + return Replacement(challenge.Request, rejected, token); + } + + /// + protected override Request? OnChallenge(AuthChallengeContext challenge) + { + ArgumentNullException.ThrowIfNull(challenge); + if (RejectedHeader(challenge) is not { } rejected) + { + return null; + } + + var token = _cache.GetAfterRejection(TokenContextFor(challenge.Requirement), rejected, challenge.Context.CancellationToken); + return Replacement(challenge.Request, rejected, token); + } + + private static AccessTokenCache CacheOver(TokenCredential credential) + { + ArgumentNullException.ThrowIfNull(credential); + return new AccessTokenCache(credential); + } + + private static string Bearer(AccessToken token) => "Bearer " + token.Token; + + // Only a Bearer challenge to a request that carried an Authorization header is answered (AUTH-36). + private static string? RejectedHeader(AuthChallengeContext challenge) => + challenge.Challenges.Any(static c => string.Equals(c.Scheme, "bearer", StringComparison.Ordinal)) + ? challenge.Request.Headers.Get(HttpHeaderName.WellKnown.Authorization) + : null; + + // A provider that hands back the rejected token yields no retry (P6c-27): the 401 surfaces. + private static Request? Replacement(Request sent, string rejected, AccessToken token) + { + var value = Bearer(token); + return string.Equals(value, rejected, StringComparison.Ordinal) + ? null + : sent.WithHeaders(sent.Headers.Set(HttpHeaderName.WellKnown.Authorization, value)); } private static AuthDescriptor ClientDescriptor(string[] scopes) diff --git a/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt b/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt index 93cfd6b..2c324fd 100644 --- a/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt +++ b/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt @@ -54,6 +54,8 @@ const Dexpace.Sdk.Core.Diagnostics.DexpaceLogKeys.UrlFull = "url.full" -> string const Dexpace.Sdk.Core.Configuration.BuildInfo.Unknown = "unknown" -> string! const Dexpace.Sdk.Core.Diagnostics.DexpaceLogEvents.RetryDelayOverrideFailed = "dexpace.retry.delay_override_failed" -> string! const Dexpace.Sdk.Core.Diagnostics.DexpaceLogEvents.RetryDelayOverrideFailedId = 140 -> int +const Dexpace.Sdk.Core.Diagnostics.DexpaceLogEvents.TokenRefreshFailed = "dexpace.auth.token_refresh_failed" -> string! +const Dexpace.Sdk.Core.Diagnostics.DexpaceLogEvents.TokenRefreshFailedId = 160 -> int const Dexpace.Sdk.Core.Http.Response.Response.MaxBufferedErrorBytes = 1048576 -> int const Dexpace.Sdk.Core.Http.Response.ResponseBody.DefaultMaxMaterializedBytes = 67108864 -> long Dexpace.Sdk.Core.Auth.AccessToken @@ -68,7 +70,10 @@ Dexpace.Sdk.Core.Auth.AccessToken.RefreshOn.get -> System.DateTimeOffset? Dexpace.Sdk.Core.Auth.AccessToken.Token.get -> string! Dexpace.Sdk.Core.Auth.AccessTokenCache Dexpace.Sdk.Core.Auth.AccessTokenCache.AccessTokenCache(Dexpace.Sdk.Core.Auth.TokenCredential! credential, System.TimeProvider? timeProvider = null) -> void +Dexpace.Sdk.Core.Auth.AccessTokenCache.Get(Dexpace.Sdk.Core.Auth.TokenRequestContext context, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> Dexpace.Sdk.Core.Auth.AccessToken Dexpace.Sdk.Core.Auth.AccessTokenCache.GetAsync(Dexpace.Sdk.Core.Auth.TokenRequestContext context, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Dexpace.Sdk.Core.Auth.AccessTokenCache.RefreshMargin.get -> System.TimeSpan +Dexpace.Sdk.Core.Auth.AccessTokenCache.RefreshMargin.init -> void Dexpace.Sdk.Core.Auth.ApiKeyCredential Dexpace.Sdk.Core.Auth.ApiKeyCredential.ApiKeyCredential(string! key, Dexpace.Sdk.Core.Http.Common.HttpHeaderName? header = null, string? scheme = null) -> void Dexpace.Sdk.Core.Auth.ApiKeyCredential.HeaderName.get -> Dexpace.Sdk.Core.Http.Common.HttpHeaderName! @@ -709,6 +714,7 @@ override sealed Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy.Process(D override sealed Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy.ProcessAsync(Dexpace.Sdk.Core.Http.Request.Request! request, Dexpace.Sdk.Core.Pipeline.PipelineContext! context, Dexpace.Sdk.Core.Pipeline.PipelineRunner continuation) -> System.Threading.Tasks.ValueTask static Dexpace.Sdk.Core.Auth.AccessToken.operator !=(Dexpace.Sdk.Core.Auth.AccessToken left, Dexpace.Sdk.Core.Auth.AccessToken right) -> bool static Dexpace.Sdk.Core.Auth.AccessToken.operator ==(Dexpace.Sdk.Core.Auth.AccessToken left, Dexpace.Sdk.Core.Auth.AccessToken right) -> bool +static Dexpace.Sdk.Core.Auth.AccessTokenCache.DefaultRefreshMargin.get -> System.TimeSpan static Dexpace.Sdk.Core.Auth.AuthCredentials.operator !=(Dexpace.Sdk.Core.Auth.AuthCredentials? left, Dexpace.Sdk.Core.Auth.AuthCredentials? right) -> bool static Dexpace.Sdk.Core.Auth.AuthCredentials.operator ==(Dexpace.Sdk.Core.Auth.AuthCredentials? left, Dexpace.Sdk.Core.Auth.AuthCredentials? right) -> bool static Dexpace.Sdk.Core.Auth.AuthDescriptor.operator !=(Dexpace.Sdk.Core.Auth.AuthDescriptor? left, Dexpace.Sdk.Core.Auth.AuthDescriptor? right) -> bool @@ -1018,6 +1024,8 @@ Dexpace.Sdk.Core.Errors.OperationTimeoutException.OperationTimeoutException() -> Dexpace.Sdk.Core.Errors.OperationTimeoutException.OperationTimeoutException(string! message) -> void Dexpace.Sdk.Core.Errors.OperationTimeoutException.OperationTimeoutException(string! message, System.Exception? innerException) -> void Dexpace.Sdk.Core.Errors.SdkException.Suppressed.get -> System.Collections.Generic.IReadOnlyList! +Dexpace.Sdk.Core.Errors.TokenProviderException +Dexpace.Sdk.Core.Errors.TokenProviderException.TokenProviderException(string! message) -> void Dexpace.Sdk.Core.Http.Request.RequestOptions.Auth.get -> Dexpace.Sdk.Core.Auth.AuthDescriptor? Dexpace.Sdk.Core.Http.Request.RequestOptions.Auth.init -> void Dexpace.Sdk.Core.Http.Request.RequestOptions.OperationAuth.get -> Dexpace.Sdk.Core.Auth.AuthDescriptor? @@ -1029,6 +1037,7 @@ Dexpace.Sdk.Core.Pipeline.Policies.AuthChallengeContext.Request.get -> Dexpace.S Dexpace.Sdk.Core.Pipeline.Policies.AuthChallengeContext.Requirement.get -> Dexpace.Sdk.Core.Auth.AuthRequirement! Dexpace.Sdk.Core.Pipeline.Policies.AuthChallengeContext.Response.get -> Dexpace.Sdk.Core.Http.Response.Response! Dexpace.Sdk.Core.Pipeline.Policies.AuthorizationPolicy.AuthorizationPolicy(Dexpace.Sdk.Core.Auth.AuthDescriptor! clientDescriptor, System.Collections.Generic.IEnumerable! availableSchemes) -> void +Dexpace.Sdk.Core.Pipeline.Policies.BearerTokenAuthPolicy.BearerTokenAuthPolicy(Dexpace.Sdk.Core.Auth.AccessTokenCache! cache, params string![]! scopes) -> void Dexpace.Sdk.Core.Pipeline.Policies.ChallengeAuthPolicy Dexpace.Sdk.Core.Pipeline.Policies.ChallengeAuthPolicy.ChallengeAuthPolicy(Dexpace.Sdk.Core.Auth.IChallengeHandler! handler) -> void override Dexpace.Sdk.Core.Errors.SdkException.ToString() -> string! diff --git a/tests/Dexpace.Sdk.Core.Tests/Architecture/BannedSymbolsSiteTests.cs b/tests/Dexpace.Sdk.Core.Tests/Architecture/BannedSymbolsSiteTests.cs new file mode 100644 index 0000000..df3d1e1 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Architecture/BannedSymbolsSiteTests.cs @@ -0,0 +1,62 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Architecture; + +/// +/// Design §5.4 / §8.1, P6c-25: ExecutionContext.SuppressFlow appears in exactly one source file, the background-work +/// helper that BannedSymbols.txt reserves it for. A source scan, with a self-test that proves it can fail. +/// +[Trait("Category", "Unit")] +public sealed class BannedSymbolsSiteTests +{ + [Fact] + public void SuppressFlow_appears_only_in_BackgroundWork() + { + var files = FilesContaining("SuppressFlow", SourceRoot()); + + Assert.Equal(["Internal/BackgroundWork.cs"], files); + } + + [Fact] + public void The_scanner_can_fail() + { + var directory = Directory.CreateTempSubdirectory("scan"); + try + { + File.WriteAllText(Path.Combine(directory.FullName, "a.cs"), "ExecutionContext.SuppressFlow();"); + File.WriteAllText(Path.Combine(directory.FullName, "b.cs"), "// SuppressFlow again"); + File.WriteAllText(Path.Combine(directory.FullName, "c.cs"), "nothing here"); + + Assert.Equal(["a.cs", "b.cs"], FilesContaining("SuppressFlow", directory.FullName)); + } + finally + { + directory.Delete(recursive: true); + } + } + + private static List FilesContaining(string token, string root) => + [.. Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories) + .Where(f => !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + && !f.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + .Where(f => File.ReadAllText(f).Contains(token, StringComparison.Ordinal)) + .Select(f => Path.GetRelativePath(root, f).Replace('\\', '/')) + .Order(StringComparer.Ordinal)]; + + private static string SourceRoot() + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) + { + var candidate = Path.Combine(directory.FullName, "src", "Dexpace.Sdk.Core"); + if (Directory.Exists(candidate)) + { + return candidate; + } + } + + throw new DirectoryNotFoundException("The src/Dexpace.Sdk.Core tree was not found above the test output."); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Auth/AccessTokenCacheTests.cs b/tests/Dexpace.Sdk.Core.Tests/Auth/AccessTokenCacheTests.cs index deaa5d6..ea3c5f6 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Auth/AccessTokenCacheTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Auth/AccessTokenCacheTests.cs @@ -1,7 +1,11 @@ // Copyright (c) 2026 dexpace and Omar Aljarrah. // Licensed under the MIT License. See LICENSE in the repository root for details. +using System.Diagnostics; using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.TestSupport.Diagnostics; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Time.Testing; using Xunit; @@ -84,11 +88,12 @@ public async Task GetAsync_WithinValidity_ReturnsTokenWithoutRefetch() } // ----------------------------------------------------------------------- - // 2. Advancing past RefreshOn triggers exactly one refresh + // 2. Past RefreshOn the valid token is stamped and one background refresh runs + // (migrated: the refresh was awaited on the request path; AUTH-37, breaking 5) // ----------------------------------------------------------------------- [Fact] - public async Task GetAsync_PastRefreshOn_RefreshesOnce() + public async Task Past_RefreshOn_the_valid_token_is_stamped_and_one_background_refresh_runs() { var start = Now(); var time = new FakeTimeProvider(start); @@ -111,14 +116,15 @@ public async Task GetAsync_PastRefreshOn_RefreshesOnce() Assert.Equal("first", t1.Token); Assert.Equal(1, callCount); - // Advance past RefreshOn but still before ExpiresOn + // Advance past RefreshOn but still before ExpiresOn: the valid token is stamped now. time.Advance(TimeSpan.FromMinutes(51)); var t2 = await cache.GetAsync(ctx, TestContext.Current.CancellationToken); - Assert.Equal("second", t2.Token); + Assert.Equal("first", t2.Token); + await cache.PendingRefresh(ctx)!; Assert.Equal(2, callCount); - // Further calls still use refreshed token — no additional fetches + // The refresh finished: further calls use the refreshed token and cost no additional fetch. var t3 = await cache.GetAsync(ctx, TestContext.Current.CancellationToken); Assert.Equal("second", t3.Token); Assert.Equal(2, callCount); @@ -192,11 +198,12 @@ public async Task GetAsync_Concurrent_SingleFlightRefresh() } // ----------------------------------------------------------------------- - // 5. Refresh that throws while valid returns cached token + // 5. A failed background refresh while valid stamps the valid token and logs + // (migrated: the failure was swallowed silently; AUTH-37, P6c-25, breaking 5) // ----------------------------------------------------------------------- [Fact] - public async Task GetAsync_RefreshThrows_WhileStillValid_ReturnsCachedToken() + public async Task A_failed_background_refresh_while_valid_stamps_the_valid_token_and_logs() { var start = Now(); var time = new FakeTimeProvider(start); @@ -215,22 +222,26 @@ public async Task GetAsync_RefreshThrows_WhileStillValid_ReturnsCachedToken() throw new InvalidOperationException("Credential failure"); }); + var logger = new RecordingLogger(); var cache = new AccessTokenCache(cred, time); var ctx = Ctx(); // Populate cache - var t1 = await cache.GetAsync(ctx, TestContext.Current.CancellationToken); + var t1 = await cache.GetAsync(ctx, logger, TestContext.Current.CancellationToken); Assert.Equal("valid", t1.Token); - // Advance past RefreshOn — triggers a refresh attempt + // Advance past RefreshOn — triggers a background refresh attempt time.Advance(TimeSpan.FromMinutes(51)); - // Despite the throw, should return the still-valid cached token - var t2 = await cache.GetAsync(ctx, TestContext.Current.CancellationToken); + // Despite the failing refresh, the still-valid cached token is stamped + var t2 = await cache.GetAsync(ctx, logger, TestContext.Current.CancellationToken); Assert.Equal("valid", t2.Token); + await cache.PendingRefresh(ctx)!; - // Credential was called for the failed refresh + // Credential was called for the failed refresh, and the failure was logged once Assert.Equal(2, callCount); + var entry = Assert.Single(logger.Entries); + Assert.Equal(160, entry.EventId.Id); } // ----------------------------------------------------------------------- @@ -278,6 +289,720 @@ public async Task GetAsync_DifferentContexts_CachedIndependently() // scope1 fetched once, scope2 fetched once = 2 calls Assert.Equal(2, callCount); } + + // ======================================================================= + // Phase 6c: zones, coalescing, background refresh, validation, sync path, bound, rejection + // (Ruby bearer_stamper_test, async_bearer_stamper_test, bearer_provider_test; Node bearer-cache.test.ts) + // ======================================================================= + + private static readonly DateTimeOffset s_start = new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static AccessToken Tok(string value, TimeSpan? lifetime = null, TimeSpan? refreshAfter = null) => + new( + value, + lifetime is { } l ? s_start + l : null, + refreshAfter is { } r ? s_start + r : null); + + // ---- Zones (AUTH-34, AUTH-37) ----------------------------------------- + + [Fact] + public async Task A_fresh_token_is_returned_without_calling_the_provider_again() + { + var time = new FakeTimeProvider(s_start); + var cred = new GatedCredential { Immediate = _ => Tok("t", TimeSpan.FromHours(1)) }; + var cache = new AccessTokenCache(cred, time); + + _ = await cache.GetAsync(Ctx(), Ct); + time.Advance(TimeSpan.FromMinutes(29)); + var again = await cache.GetAsync(Ctx(), Ct); + + Assert.Equal("t", again.Token); + Assert.Equal(1, cred.Calls); + } + + [Fact] + public async Task The_hot_path_takes_no_lock() + { + var time = new FakeTimeProvider(s_start); + var cred = new GatedCredential { Gated = scope => scope == "B", Immediate = c => Tok("tok-" + c.Scopes[0], TimeSpan.FromHours(1)) }; + var cache = new AccessTokenCache(cred, time); + _ = await cache.GetAsync(Ctx("A"), Ct); + + // Key B has a fetch in flight (its gate is held); key A is fresh and answers synchronously. + var pendingB = cache.GetAsync(Ctx("B"), Ct); + var hot = cache.GetAsync(Ctx("A"), Ct); + + Assert.True(hot.IsCompletedSuccessfully); + Assert.Equal("tok-A", (await hot).Token); + cred.Release(0, Tok("tok-B", TimeSpan.FromHours(1))); + Assert.Equal("tok-B", (await pendingB).Token); + } + + [Fact] + public async Task A_token_inside_the_margin_is_stamped_now_and_refreshed_in_the_background() + { + var time = new FakeTimeProvider(s_start); + var cred = new GatedCredential(); + var cache = new AccessTokenCache(cred, time); + var first = cache.GetAsync(Ctx(), Ct); + cred.Release(0, Tok("old", TimeSpan.FromSeconds(60))); + Assert.Equal("old", (await first).Token); + + time.Advance(TimeSpan.FromSeconds(40)); // 20 s left: inside the 30 s margin, still valid + var stamped = cache.GetAsync(Ctx(), Ct); + + Assert.True(stamped.IsCompletedSuccessfully); + Assert.Equal("old", (await stamped).Token); + await cred.WaitForCallsAsync(2); // the refresh started + Assert.Equal(2, cred.Calls); + cred.Release(1, new AccessToken("new", time.GetUtcNow().AddHours(1))); + await cache.PendingRefresh(Ctx())!; + Assert.Equal("new", (await cache.GetAsync(Ctx(), Ct)).Token); + } + + [Fact] + public async Task The_default_margin_is_30_seconds_and_configurable() + { + Assert.Equal(TimeSpan.FromSeconds(30), AccessTokenCache.DefaultRefreshMargin); + var time = new FakeTimeProvider(s_start); + var cred = new GatedCredential { Immediate = _ => new AccessToken("t", time.GetUtcNow().AddSeconds(100)) }; + var defaultCache = new AccessTokenCache(cred, time); + Assert.Equal(AccessTokenCache.DefaultRefreshMargin, defaultCache.RefreshMargin); + _ = await defaultCache.GetAsync(Ctx(), Ct); + + time.Advance(TimeSpan.FromSeconds(60)); // 40 s left: fresh under the 30 s default + _ = await defaultCache.GetAsync(Ctx(), Ct); + Assert.Equal(1, cred.Calls); + Assert.Null(defaultCache.PendingRefresh(Ctx())); + + var wide = new AccessTokenCache(cred, time) { RefreshMargin = TimeSpan.FromSeconds(90) }; + _ = await wide.GetAsync(Ctx(), Ct); // 100 s left: fresh under 90 s + Assert.Null(wide.PendingRefresh(Ctx())); + time.Advance(TimeSpan.FromSeconds(11)); // 89 s left: expiring under 90 s + _ = await wide.GetAsync(Ctx(), Ct); + await wide.PendingRefresh(Ctx())!; + Assert.Equal(3, cred.Calls); + } + + [Fact] + public void A_negative_margin_throws() + { + Assert.Throws( + () => new AccessTokenCache(new GatedCredential(), null) { RefreshMargin = TimeSpan.FromTicks(-1) }); + } + + [Fact] + public async Task RefreshOn_before_ExpiresOn_minus_margin_wins() + { + var time = new FakeTimeProvider(s_start); + var n = 0; + var cred = new GatedCredential + { + Immediate = _ => Interlocked.Increment(ref n) == 1 + ? Tok("old", TimeSpan.FromHours(1), TimeSpan.FromMinutes(10)) + : new AccessToken("new", time.GetUtcNow().AddHours(1)), + }; + var cache = new AccessTokenCache(cred, time); + _ = await cache.GetAsync(Ctx(), Ct); + + time.Advance(TimeSpan.FromMinutes(11)); + var stamped = await cache.GetAsync(Ctx(), Ct); + await cache.PendingRefresh(Ctx())!; + + Assert.Equal("old", stamped.Token); + Assert.Equal(2, cred.Calls); + } + + [Fact] + public async Task A_token_at_exactly_its_expiry_is_still_valid_but_expiring() + { + var time = new FakeTimeProvider(s_start); + var n = 0; + var cred = new GatedCredential + { + Immediate = _ => Interlocked.Increment(ref n) == 1 ? Tok("old", TimeSpan.FromMinutes(5)) : new AccessToken("new", time.GetUtcNow().AddHours(1)), + }; + var cache = new AccessTokenCache(cred, time); + _ = await cache.GetAsync(Ctx(), Ct); + + time.Advance(TimeSpan.FromMinutes(5)); + var stamped = await cache.GetAsync(Ctx(), Ct); + await cache.PendingRefresh(Ctx())!; + + Assert.Equal("old", stamped.Token); + Assert.Equal(2, cred.Calls); + } + + [Fact] + public async Task An_expired_token_is_fetched_in_the_foreground() + { + var time = new FakeTimeProvider(s_start); + var n = 0; + var cred = new GatedCredential + { + Immediate = _ => Interlocked.Increment(ref n) == 1 ? Tok("old", TimeSpan.FromMinutes(5)) : new AccessToken("new", time.GetUtcNow().AddHours(1)), + }; + var cache = new AccessTokenCache(cred, time); + _ = await cache.GetAsync(Ctx(), Ct); + + time.Advance(TimeSpan.FromMinutes(5) + TimeSpan.FromTicks(1)); + + Assert.Equal("new", (await cache.GetAsync(Ctx(), Ct)).Token); + Assert.Null(cache.PendingRefresh(Ctx())); + } + + [Fact] + public async Task A_token_without_expiry_is_never_refreshed() + { + var time = new FakeTimeProvider(s_start); + var cred = new GatedCredential { Immediate = _ => new AccessToken("forever") }; + var cache = new AccessTokenCache(cred, time); + _ = await cache.GetAsync(Ctx(), Ct); + + time.Advance(TimeSpan.FromDays(3650)); + _ = await cache.GetAsync(Ctx(), Ct); + + Assert.Equal(1, cred.Calls); + } + + // ---- Coalescing (AUTH-34, AUTH-37, P6c-24) ----------------------------- + + [Fact] + public async Task N_concurrent_requests_at_expiry_cost_one_fetch() + { + var time = new FakeTimeProvider(s_start); + var cred = new GatedCredential(); + var cache = new AccessTokenCache(cred, time); + + // Each call runs synchronously up to its first await, so all 16 are queued on the gate before the fetch ends. + var tasks = Enumerable.Range(0, 16).Select(_ => cache.GetAsync(Ctx(), Ct).AsTask()).ToArray(); + cred.Release(0, Tok("shared", TimeSpan.FromHours(1))); + var results = await Task.WhenAll(tasks); + + Assert.All(results, r => Assert.Equal("shared", r.Token)); + Assert.Equal(1, cred.Calls); + } + + [Fact] + public async Task With_a_failing_provider_N_waiters_fail_together_after_one_attempt() + { + var cred = new GatedCredential(); + var cache = new AccessTokenCache(cred, new FakeTimeProvider(s_start)); + + var tasks = Enumerable.Range(0, 16).Select(_ => cache.GetAsync(Ctx(), Ct).AsTask()).ToArray(); + cred.Fail(0, new InvalidOperationException("outage")); + + foreach (var task in tasks) + { + var ex = await Assert.ThrowsAsync(() => task); + Assert.Equal("outage", ex.Message); + } + + Assert.Equal(1, cred.Calls); + } + + [Fact] + public async Task A_request_arriving_after_a_failure_fetches_again() + { + var cred = new GatedCredential(); + var cache = new AccessTokenCache(cred, new FakeTimeProvider(s_start)); + var failing = cache.GetAsync(Ctx(), Ct).AsTask(); + cred.Fail(0, new InvalidOperationException("outage")); + await Assert.ThrowsAsync(() => failing); + + var next = cache.GetAsync(Ctx(), Ct).AsTask(); + cred.Release(1, Tok("recovered", TimeSpan.FromHours(1))); + + Assert.Equal("recovered", (await next).Token); + Assert.Equal(2, cred.Calls); + } + + [Fact] + public async Task A_waiters_own_cancellation_leaves_the_fetch_running() + { + var cred = new GatedCredential(); + var cache = new AccessTokenCache(cred, new FakeTimeProvider(s_start)); + using var cts = new CancellationTokenSource(); + var fetcher = cache.GetAsync(Ctx(), Ct).AsTask(); + var waiter = cache.GetAsync(Ctx(), cts.Token).AsTask(); + + await cts.CancelAsync(); + await Assert.ThrowsAnyAsync(() => waiter); + cred.Release(0, Tok("t", TimeSpan.FromHours(1))); + + Assert.Equal("t", (await fetcher).Token); + Assert.Equal(1, cred.Calls); + } + + [Fact] + public async Task A_fetchers_own_cancellation_is_not_adopted_by_the_waiters() + { + var cred = new GatedCredential(); + var cache = new AccessTokenCache(cred, new FakeTimeProvider(s_start)); + using var cts = new CancellationTokenSource(); + var fetcher = cache.GetAsync(Ctx(), cts.Token).AsTask(); + var waiter = cache.GetAsync(Ctx(), Ct).AsTask(); + + await cts.CancelAsync(); + await Assert.ThrowsAnyAsync(() => fetcher); + await cred.WaitForCallsAsync(2); // the waiter fetches for itself + cred.Release(1, Tok("second", TimeSpan.FromHours(1))); + + Assert.Equal("second", (await waiter).Token); + Assert.Equal(2, cred.Calls); + } + + [Fact] + public async Task Different_keys_do_not_block_each_other() + { + var cred = new GatedCredential { Gated = scope => scope == "A", Immediate = c => Tok("tok-" + c.Scopes[0], TimeSpan.FromHours(1)) }; + var cache = new AccessTokenCache(cred, new FakeTimeProvider(s_start)); + + var blocked = cache.GetAsync(Ctx("A"), Ct).AsTask(); + var other = await cache.GetAsync(Ctx("B"), Ct); + + Assert.Equal("tok-B", other.Token); + Assert.False(blocked.IsCompleted); + cred.Release(0, Tok("tok-A", TimeSpan.FromHours(1))); + Assert.Equal("tok-A", (await blocked).Token); + } + + // ---- Background refresh (AUTH-37) -------------------------------------- + + private static async Task<(AccessTokenCache Cache, GatedCredential Cred, FakeTimeProvider Time)> ExpiringAsync() + { + var time = new FakeTimeProvider(s_start); + var cred = new GatedCredential(); + var cache = new AccessTokenCache(cred, time); + var first = cache.GetAsync(Ctx(), Ct); + cred.Release(0, Tok("old", TimeSpan.FromSeconds(60))); + _ = await first; + time.Advance(TimeSpan.FromSeconds(40)); + return (cache, cred, time); + } + + [Fact] + public async Task Only_one_background_refresh_runs_per_key_however_many_requests_arrive() + { + var (cache, cred, _) = await ExpiringAsync(); + + for (var i = 0; i < 20; i++) + { + Assert.Equal("old", (await cache.GetAsync(Ctx(), Ct)).Token); + } + + await cred.WaitForCallsAsync(2); + Assert.Equal(2, cred.Calls); + cred.Release(1, new AccessToken("new", s_start.AddHours(1))); + await cache.PendingRefresh(Ctx())!; + } + + [Fact] + public async Task The_background_refresh_sees_no_ambient_Activity() + { + var time = new FakeTimeProvider(s_start); + var seen = new List(); + var cred = new GatedCredential + { + Immediate = _ => + { + seen.Add(Activity.Current); + return seen.Count == 1 ? Tok("old", TimeSpan.FromSeconds(60)) : new AccessToken("new", s_start.AddHours(1)); + }, + }; + var cache = new AccessTokenCache(cred, time); + _ = await cache.GetAsync(Ctx(), Ct); + time.Advance(TimeSpan.FromSeconds(40)); + + using var source = new ActivitySource("Dexpace.Tests.Auth.Ambient"); + using var listener = new ActivityListener + { + ShouldListenTo = s => s.Name == source.Name, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + }; + ActivitySource.AddActivityListener(listener); + using (var activity = source.StartActivity("call")) + { + Assert.NotNull(activity); + _ = await cache.GetAsync(Ctx(), Ct); + } + + await cache.PendingRefresh(Ctx())!; + Assert.Equal(2, seen.Count); + Assert.Null(seen[1]); + } + + [Fact] + public async Task A_failing_background_refresh_logs_event_160_once_and_the_triggering_call_is_unaffected() + { + var time = new FakeTimeProvider(s_start); + var cred = new GatedCredential(); + var cache = new AccessTokenCache(cred, time); + var logger = new RecordingLogger(); + var first = cache.GetAsync(Ctx(), logger, Ct); + cred.Release(0, Tok("old", TimeSpan.FromSeconds(60))); + _ = await first; + time.Advance(TimeSpan.FromSeconds(40)); + + var stamped = await cache.GetAsync(Ctx(), logger, Ct); + await cred.WaitForCallsAsync(2); + cred.Fail(1, new InvalidOperationException("provider down")); + await cache.PendingRefresh(Ctx())!; + + Assert.Equal("old", stamped.Token); + var entry = Assert.Single(logger.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.Equal(160, entry.EventId.Id); + Assert.Equal("dexpace.auth.token_refresh_failed", entry.EventId.Name); + Assert.Equal("System.InvalidOperationException", entry["error.type"]); + Assert.IsType(entry.Exception); + } + + [Fact] + public async Task After_a_background_failure_the_old_token_stays_valid_and_the_next_request_starts_another_refresh() + { + var (cache, cred, _) = await ExpiringAsync(); + _ = await cache.GetAsync(Ctx(), Ct); + await cred.WaitForCallsAsync(2); + cred.Fail(1, new InvalidOperationException("provider down")); + await cache.PendingRefresh(Ctx())!; + + var still = await cache.GetAsync(Ctx(), Ct); // inside the margin again: starts another refresh + + Assert.Equal("old", still.Token); + await cred.WaitForCallsAsync(3); + Assert.Equal(3, cred.Calls); + cred.Release(2, new AccessToken("new", s_start.AddHours(1))); + await cache.PendingRefresh(Ctx())!; + } + + [Fact] + public async Task The_refresh_task_is_observed_and_completes_without_faulting() + { + var (cache, cred, _) = await ExpiringAsync(); + _ = await cache.GetAsync(Ctx(), Ct); + await cred.WaitForCallsAsync(2); + cred.Fail(1, new InvalidOperationException("provider down")); + + var refresh = cache.PendingRefresh(Ctx())!; + await refresh; + + Assert.Equal(TaskStatus.RanToCompletion, refresh.Status); + } + + // ---- Validation (AUTH-35, AUTH-11) -------------------------------------- + + [Fact] + public async Task A_default_token_from_the_provider_throws_TokenProviderException() + { + var cache = new AccessTokenCache(new GatedCredential { Immediate = _ => default }, new FakeTimeProvider(s_start)); + + await Assert.ThrowsAsync(() => cache.GetAsync(Ctx(), Ct).AsTask()); + Assert.Throws(() => cache.Get(Ctx(), Ct)); + } + + [Fact] + public async Task A_token_already_expired_at_fetch_throws_TokenProviderException_and_never_contains_the_token() + { + var cred = new GatedCredential { Immediate = _ => new AccessToken("s3cr3t-Zx9", s_start.AddMinutes(-1)) }; + var cache = new AccessTokenCache(cred, new FakeTimeProvider(s_start)); + + var ex = await Assert.ThrowsAsync(() => cache.GetAsync(Ctx(), Ct).AsTask()); + + Assert.DoesNotContain("s3cr3t-Zx9", ex.Message, StringComparison.Ordinal); + Assert.False(ex.IsRetryable); + } + + [Fact] + public async Task A_provider_exception_propagates_unwrapped_and_nothing_is_cached() + { + var failure = new InvalidOperationException("boom"); + var cred = new GatedCredential { Immediate = _ => throw failure }; + var cache = new AccessTokenCache(cred, new FakeTimeProvider(s_start)); + + Assert.Same(failure, await Assert.ThrowsAsync(() => cache.GetAsync(Ctx(), Ct).AsTask())); + Assert.Same(failure, Assert.Throws(() => cache.Get(Ctx(), Ct))); + Assert.Equal(2, cred.Calls); + } + + [Fact] + public async Task A_synchronous_throw_from_a_non_async_GetTokenAsync_override_surfaces_through_the_returned_task() + { + var failure = new InvalidOperationException("sync throw"); + var cache = new AccessTokenCache(new SyncThrowingCredential(failure), new FakeTimeProvider(s_start)); + + ValueTask task = default; + var exception = Record.Exception(() => task = cache.GetAsync(Ctx(), Ct)); + + Assert.Null(exception); + Assert.Same(failure, await Assert.ThrowsAsync(() => task.AsTask())); + } + + // ---- Sync path (P6c-26) --------------------------------------------------- + + [Fact] + public async Task Get_returns_the_same_tokens_as_GetAsync() + { + var time = new FakeTimeProvider(s_start); + var cred = new GatedCredential { Immediate = c => Tok("tok-" + c.Scopes[0], TimeSpan.FromHours(1)) }; + var cache = new AccessTokenCache(cred, time); + + Assert.Equal("tok-s", cache.Get(Ctx("s"), Ct).Token); + Assert.Equal("tok-s", (await cache.GetAsync(Ctx("s"), Ct)).Token); + Assert.Equal(1, cred.Calls); + } + + [Fact] + public async Task Get_honours_cancellation_while_waiting() + { + var cred = new GatedCredential(); + var cache = new AccessTokenCache(cred, new FakeTimeProvider(s_start)); + var holder = cache.GetAsync(Ctx(), Ct).AsTask(); // holds the gate + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => Task.Run(() => cache.Get(Ctx(), cts.Token), Ct)); + + cred.Release(0, Tok("t", TimeSpan.FromHours(1))); + _ = await holder; + } + + [Fact] + public void Get_uses_TokenCredential_GetToken_not_GetTokenAsync() + { + var cred = new SyncOnlyCredential(); + var cache = new AccessTokenCache(cred, new FakeTimeProvider(s_start)); + + var token = cache.Get(Ctx(), Ct); + + Assert.Equal("sync", token.Token); + Assert.Equal(1, cred.SyncCalls); + } + + [Fact] + public async Task A_sync_and_an_async_waiter_coalesce_onto_one_fetch() + { + var cred = new BlockingSyncCredential(); + var cache = new AccessTokenCache(cred, new FakeTimeProvider(s_start)); + var syncFetch = Task.Run(() => cache.Get(Ctx(), Ct), Ct); + cred.Entered.Wait(Ct); + + var asyncWaiter = cache.GetAsync(Ctx(), Ct).AsTask(); // queues on the gate the sync fetch holds + cred.Proceed.Set(); + + Assert.Equal("blocking", (await syncFetch).Token); + Assert.Equal("blocking", (await asyncWaiter).Token); + Assert.Equal(1, cred.Calls); + } + + // ---- Bound (XCUT-14, P6c-28) ---------------------------------------------- + + [Fact] + public async Task The_cache_holds_at_most_1024_keys_once_quiescent() + { + var cred = new GatedCredential { Immediate = _ => Tok("t", TimeSpan.FromHours(1)) }; + var cache = new AccessTokenCache(cred, new FakeTimeProvider(s_start)); + + for (var i = 0; i < 3000; i++) + { + _ = await cache.GetAsync(new TokenRequestContext(["s"], "claims-" + i), Ct); + } + + Assert.Equal(1024, cache.EntryCapacity); + Assert.True(cache.EntryCount <= cache.EntryCapacity, $"{cache.EntryCount} entries"); + } + + [Fact] + public async Task An_evicted_live_entry_costs_one_extra_fetch_and_no_failure() + { + var cred = new GatedCredential { Immediate = _ => Tok("t", TimeSpan.FromHours(1)) }; + var cache = new AccessTokenCache(cred, new FakeTimeProvider(s_start)); + for (var i = 0; i < 2000; i++) + { + _ = await cache.GetAsync(new TokenRequestContext(["s"], "claims-" + i), Ct); + } + + var calls = cred.Calls; + var token = await cache.GetAsync(new TokenRequestContext(["s"], "claims-0"), Ct); + + Assert.Equal("t", token.Token); + Assert.InRange(cred.Calls - calls, 0, 1); + } + + [Fact] + public async Task The_cache_key_includes_claims() + { + var cred = new GatedCredential { Immediate = _ => Tok("t", TimeSpan.FromHours(1)) }; + var cache = new AccessTokenCache(cred, new FakeTimeProvider(s_start)); + + _ = await cache.GetAsync(new TokenRequestContext(["s"]), Ct); + _ = await cache.GetAsync(new TokenRequestContext(["s"], "c1"), Ct); + _ = await cache.GetAsync(new TokenRequestContext(["s"], "c1"), Ct); + + Assert.Equal(2, cred.Calls); + } + + // ---- Rejection path (AUTH-36, AUTH-37) --------------------------------------- + + [Fact] + public async Task A_rejection_of_the_current_token_evicts_it_and_fetches_fresh() + { + var counter = 0; + var cred = new GatedCredential { Immediate = _ => new AccessToken("t" + Interlocked.Increment(ref counter), s_start.AddHours(1)) }; + var cache = new AccessTokenCache(cred, new FakeTimeProvider(s_start)); + Assert.Equal("t1", (await cache.GetAsync(Ctx(), Ct)).Token); + + var fresh = await cache.GetAfterRejectionAsync(Ctx(), "Bearer t1", Ct); + + Assert.Equal("t2", fresh.Token); + Assert.Equal(2, cred.Calls); + Assert.Equal("t3", cache.GetAfterRejection(Ctx(), "Bearer t2", Ct).Token); + } + + [Fact] + public async Task A_rejection_of_a_stale_header_keeps_a_token_another_request_already_refreshed() + { + var cred = new GatedCredential { Immediate = _ => new AccessToken("new", s_start.AddHours(1)) }; + var cache = new AccessTokenCache(cred, new FakeTimeProvider(s_start)); + _ = await cache.GetAsync(Ctx(), Ct); // cached: Bearer new + + var kept = await cache.GetAfterRejectionAsync(Ctx(), "Bearer old", Ct); + + Assert.Equal("new", kept.Token); + Assert.Equal(1, cred.Calls); + } + + [Fact] + public async Task Eviction_and_fetch_happen_under_one_critical_section() + { + var cred = new GatedCredential(); + var cache = new AccessTokenCache(cred, new FakeTimeProvider(s_start)); + var seed = cache.GetAsync(Ctx(), Ct); + cred.Release(0, Tok("t1", TimeSpan.FromHours(1))); + _ = await seed; + + var a = cache.GetAfterRejectionAsync(Ctx(), "Bearer t1", Ct).AsTask(); + var b = cache.GetAfterRejectionAsync(Ctx(), "Bearer t1", Ct).AsTask(); + cred.Release(1, Tok("t2", TimeSpan.FromHours(1))); + + Assert.Equal("t2", (await a).Token); + Assert.Equal("t2", (await b).Token); + Assert.Equal(2, cred.Calls); + } + + [Fact] + public async Task A_rejection_waits_for_a_background_refresh_in_flight_and_takes_its_result() + { + var (cache, cred, _) = await ExpiringAsync(); + _ = await cache.GetAsync(Ctx(), Ct); // starts the background refresh (holds the gate) + await cred.WaitForCallsAsync(2); + + var rejection = cache.GetAfterRejectionAsync(Ctx(), "Bearer old", Ct).AsTask(); + cred.Release(1, new AccessToken("new", s_start.AddHours(1))); + + Assert.Equal("new", (await rejection).Token); + Assert.Equal(2, cred.Calls); + } +} + +internal sealed class GatedCredential : TokenCredential +{ + private readonly List> _pending = []; + private int _calls; + + public int Calls => Volatile.Read(ref _calls); + + /// Which scopes are gated; by default every call is gated unless is set. + public Func? Gated { get; init; } + + public Func? Immediate { get; init; } + + public async Task WaitForCallsAsync(int count) + { + while (Calls < count) + { + TestContext.Current.CancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + } + } + + public void Release(int index, AccessToken token) => Pending(index).SetResult(token); + + public void Fail(int index, Exception exception) => Pending(index).SetException(exception); + + public override ValueTask GetTokenAsync(TokenRequestContext context, CancellationToken ct = default) + { + var scope = context.Scopes.Count > 0 ? context.Scopes[0] : string.Empty; + var gated = Gated is { } predicate ? predicate(scope) : Immediate is null; + if (!gated) + { + Interlocked.Increment(ref _calls); + return new ValueTask(Immediate!(context)); + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + lock (_pending) + { + _pending.Add(tcs); + } + + Interlocked.Increment(ref _calls); + return new ValueTask(tcs.Task.WaitAsync(ct)); + } + + private TaskCompletionSource Pending(int index) + { + lock (_pending) + { + return _pending[index]; + } + } +} + +file sealed class SyncThrowingCredential(Exception failure) : TokenCredential +{ + public override ValueTask GetTokenAsync(TokenRequestContext context, CancellationToken ct = default) => + throw failure; +} + +file sealed class SyncOnlyCredential : TokenCredential +{ + private int _syncCalls; + + public int SyncCalls => Volatile.Read(ref _syncCalls); + + public override ValueTask GetTokenAsync(TokenRequestContext context, CancellationToken ct = default) => + throw new InvalidOperationException("The sync path must call GetToken."); + + public override AccessToken GetToken(TokenRequestContext context, CancellationToken ct = default) + { + Interlocked.Increment(ref _syncCalls); + return new AccessToken("sync"); + } +} + +file sealed class BlockingSyncCredential : TokenCredential +{ + private int _calls; + + public int Calls => Volatile.Read(ref _calls); + + public ManualResetEventSlim Entered { get; } = new(false); + + public ManualResetEventSlim Proceed { get; } = new(false); + + public override ValueTask GetTokenAsync(TokenRequestContext context, CancellationToken ct = default) => + throw new InvalidOperationException("Only the sync path is used."); + + public override AccessToken GetToken(TokenRequestContext context, CancellationToken ct = default) + { + Interlocked.Increment(ref _calls); + Entered.Set(); + Proceed.Wait(ct); + return new AccessToken("blocking"); + } } // Helper that cannot easily be expressed as a lambda: diff --git a/tests/Dexpace.Sdk.Core.Tests/Diagnostics/AuthLogTests.cs b/tests/Dexpace.Sdk.Core.Tests/Diagnostics/AuthLogTests.cs new file mode 100644 index 0000000..2af691b --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Diagnostics/AuthLogTests.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Diagnostics; +using Dexpace.Sdk.Core.Internal; +using Dexpace.Sdk.TestSupport.Diagnostics; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Diagnostics; + +/// AUTH-37, OBS-39: the one auth log event. +[Trait("Category", "Unit")] +public sealed class AuthLogTests +{ + [Fact] + public void TokenRefreshFailed_is_id_160_named_dexpace_auth_token_refresh_failed_at_Warning() + { + var logger = new RecordingLogger(); + var failure = new InvalidOperationException("down"); + + AuthLog.TokenRefreshFailed(logger, failure); + + var entry = Assert.Single(logger.Entries); + Assert.Equal(160, entry.EventId.Id); + Assert.Equal("dexpace.auth.token_refresh_failed", entry.EventId.Name); + Assert.Equal(DexpaceLogEvents.TokenRefreshFailedId, entry.EventId.Id); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.Equal("System.InvalidOperationException", entry["error.type"]); + Assert.Same(failure, entry.Exception); + Assert.DoesNotContain("down", entry.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_throwing_logger_does_not_throw() + { + var logger = new ThrowingLogger { ThrowOnLog = true }; + + var thrown = Record.Exception(() => AuthLog.TokenRefreshFailed(logger, new InvalidOperationException("x"))); + + Assert.Null(thrown); + } + + [Fact] + public void A_disabled_level_writes_nothing() + { + var logger = new RecordingLogger { MinimumLevel = LogLevel.Error }; + + AuthLog.TokenRefreshFailed(logger, new InvalidOperationException("x")); + + Assert.Empty(logger.Entries); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Diagnostics/LogVocabularyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Diagnostics/LogVocabularyTests.cs index 647feff..e3f1889 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Diagnostics/LogVocabularyTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Diagnostics/LogVocabularyTests.cs @@ -90,14 +90,17 @@ public void Id_ranges_are_inside_the_reserved_blocks() // 6a (P6a-28): 140 is the only id in the retry block; 141-149 stay reserved. 6b and 6c each edit their own block. Assert.Equal(140, ids["RetryDelayOverrideFailedId"]); - // 6b (P6b-19): 150-154 are the redirect block; 155-159 stay reserved. 6c edits its own block (160-169). + // 6b (P6b-19): 150-154 are the redirect block; 155-159 stay reserved. foreach (var name in (string[])["RedirectHopId", "RedirectLoopDetectedId", "RedirectSchemeDowngradeRejectedId", "RedirectSchemeDowngradePermittedId", "RedirectLocationMalformedId"]) { Assert.InRange(ids[name], 150, 159); } - // 110-119 (8b header drops), 141-149 (retry), 155-159 (redirect) and 160-169 (auth) are reserved and unused. - Assert.DoesNotContain(ids.Values, id => id is (>= 110 and <= 119) or (>= 141 and <= 149) or (>= 155 and <= 169)); + // 6c (P6c-37): 160 is the only id in the auth block; 161-169 stay reserved. + Assert.Equal(160, ids["TokenRefreshFailedId"]); + + // 110-119 (8b header drops), 141-149 (retry), 155-159 (redirect) and 161-169 (auth) are reserved and unused. + Assert.DoesNotContain(ids.Values, id => id is (>= 110 and <= 119) or (>= 141 and <= 149) or (>= 155 and <= 159) or (>= 161 and <= 169)); Assert.Equal(ids.Count, ids.Values.Distinct().Count()); } diff --git a/tests/Dexpace.Sdk.Core.Tests/Errors/HttpsRequiredExceptionTests.cs b/tests/Dexpace.Sdk.Core.Tests/Errors/HttpsRequiredExceptionTests.cs index 789ab45..99a4ce9 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Errors/HttpsRequiredExceptionTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Errors/HttpsRequiredExceptionTests.cs @@ -47,6 +47,7 @@ public sealed class AuthExceptionsAreNotRetryableTests [ new HttpsRequiredException("P", "http"), new AuthResolutionException([AuthScheme.Digest], [AuthScheme.Basic]), + new TokenProviderException("The token provider returned a default token."), ]; [Theory] diff --git a/tests/Dexpace.Sdk.Core.Tests/Errors/TokenProviderExceptionTests.cs b/tests/Dexpace.Sdk.Core.Tests/Errors/TokenProviderExceptionTests.cs new file mode 100644 index 0000000..088714a --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Errors/TokenProviderExceptionTests.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Errors; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Exceptions; + +/// AUTH-35: the failure for a default or already-expired provider token. +[Trait("Category", "Unit")] +public sealed class TokenProviderExceptionTests +{ + [Fact] + public void Is_an_SdkException_and_not_retryable() + { + var ex = new TokenProviderException("bad token"); + + Assert.IsAssignableFrom(ex); + Assert.IsNotAssignableFrom(ex); + Assert.False(ex.IsRetryable); + Assert.Equal("bad token", ex.Message); + } + + [Fact] + public async Task The_message_never_contains_a_token() + { + // The cache is the only producer; its messages are fixed text. + var cache = new Dexpace.Sdk.Core.Auth.AccessTokenCache( + new FixedExpired("s3cr3t-Zx9"), + new Microsoft.Extensions.Time.Testing.FakeTimeProvider(new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero))); + + var ex = await Assert.ThrowsAsync( + () => cache.GetAsync(new Dexpace.Sdk.Core.Auth.TokenRequestContext(["s"]), TestContext.Current.CancellationToken).AsTask()); + + Assert.DoesNotContain("s3cr3t-Zx9", ex.Message, StringComparison.Ordinal); + } + + private sealed class FixedExpired(string token) : Dexpace.Sdk.Core.Auth.TokenCredential + { + public override ValueTask GetTokenAsync( + Dexpace.Sdk.Core.Auth.TokenRequestContext context, + CancellationToken ct = default) => + new(new Dexpace.Sdk.Core.Auth.AccessToken(token, new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero))); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Internal/BackgroundWorkTests.cs b/tests/Dexpace.Sdk.Core.Tests/Internal/BackgroundWorkTests.cs new file mode 100644 index 0000000..34ed1e0 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Internal/BackgroundWorkTests.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Diagnostics; +using Dexpace.Sdk.Core.Internal; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Internal; + +/// CTX-19, P6c-25: background work captures no ambient state. +[Trait("Category", "Unit")] +public sealed class BackgroundWorkTests +{ + private static readonly AsyncLocal s_ambient = new(); + + [Fact] + public async Task The_work_observes_no_Activity_current() + { + using var source = new ActivitySource("Dexpace.Tests.BackgroundWork"); + using var listener = new ActivityListener + { + ShouldListenTo = s => s.Name == source.Name, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + }; + ActivitySource.AddActivityListener(listener); + using var activity = source.StartActivity("caller"); + Assert.NotNull(Activity.Current); + Activity? seen = new Activity("sentinel"); + + await BackgroundWork.Run(() => + { + seen = Activity.Current; + return Task.CompletedTask; + }); + + Assert.Null(seen); + } + + [Fact] + public async Task The_caller_flow_is_restored_after_Run_returns() + { + s_ambient.Value = "caller"; + string? inside = "unset"; + + var task = BackgroundWork.Run(() => + { + inside = s_ambient.Value; + return Task.CompletedTask; + }); + + Assert.Equal("caller", s_ambient.Value); + Assert.False(ExecutionContext.IsFlowSuppressed()); + await task; + Assert.Null(inside); + } + + [Fact] + public async Task A_throwing_delegate_faults_the_returned_task_not_the_caller() + { + Task? task = null; + var thrown = Record.Exception(() => + { + task = BackgroundWork.Run(() => throw new InvalidOperationException("boom")); + }); + + Assert.Null(thrown); + await Assert.ThrowsAsync(() => task!); + } + + [Fact] + public async Task Run_returns_a_task_the_caller_can_await() + { + var ran = false; + + await BackgroundWork.Run(async () => + { + await Task.Yield(); + ran = true; + }); + + Assert.True(ran); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Internal/BoundedMapExtensionsTests.cs b/tests/Dexpace.Sdk.Core.Tests/Internal/BoundedMapExtensionsTests.cs new file mode 100644 index 0000000..ff0a7fb --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Internal/BoundedMapExtensionsTests.cs @@ -0,0 +1,38 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Internal; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Internal; + +/// R1: the shared get-or-add over the bounded map. +[Trait("Category", "Unit")] +public sealed class BoundedMapExtensionsTests +{ + private sealed class Box(string name) + { + public string Name { get; } = name; + } + + [Fact] + public void GetOrAdd_returns_the_existing_value_without_calling_the_factory() + { + var map = new BoundedMap(8); + var first = map.GetOrAdd("k", () => new Box("first")); + + var second = map.GetOrAdd("k", () => throw new InvalidOperationException("must not run")); + + Assert.Same(first, second); + } + + [Fact] + public async Task GetOrAdd_creates_one_value_under_a_race() + { + var map = new BoundedMap(8); + var results = await Task.WhenAll(Enumerable.Range(0, 64).Select( + i => Task.Run(() => map.GetOrAdd("k", () => new Box("v" + i)), TestContext.Current.CancellationToken))); + + Assert.Single(results.Distinct()); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/BearerPipelineTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/BearerPipelineTests.cs new file mode 100644 index 0000000..d263291 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/BearerPipelineTests.cs @@ -0,0 +1,76 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Diagnostics; +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Pipeline.Policies; +using Dexpace.Sdk.TestSupport.Diagnostics; +using Dexpace.Sdk.TestSupport.Time; +using Dexpace.Sdk.TestSupport.Transports; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline; + +/// AUTH-27, AUTH-29, AUTH-30, AUTH-36: the bearer policy inside the default composition. +[Trait("Category", "Unit")] +public sealed class BearerPipelineTests +{ + private static readonly DateTimeOffset s_farFuture = new(2099, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private sealed class CountingCredential : TokenCredential + { + private int _calls; + + public int Calls => Volatile.Read(ref _calls); + + public override ValueTask GetTokenAsync(TokenRequestContext context, CancellationToken ct = default) => + new(new AccessToken("tok-" + Interlocked.Increment(ref _calls), s_farFuture)); + } + + [Fact] + public async Task A_challenged_bearer_call_through_the_default_pipeline_succeeds_with_one_resend() + { + using var recorder = new ActivityRecorder("Dexpace.Sdk"); + var credential = new CountingCredential(); + using var transport = new ScriptedTransport(TestResponses.Unauthorized("Bearer realm=\"r\""), TestResponses.Create(Status.Ok)); + using var pipeline = DexpacePipeline.CreateDefault( + transport, + new BearerTokenAuthPolicy(credential, "s"), + timeProvider: new InstantTimeProvider()); + + using var response = await pipeline.SendAsync(Request.Get("https://api.example.com/v1/items"), TestContext.Current.CancellationToken); + + // The retry stage does not treat the 401 as retryable (RETRY-1, CFG-35): the resend is the auth step's, below it. + Assert.Equal(200, response.Status.Code); + Assert.Equal(2, transport.CallCount); + Assert.Equal(2, credential.Calls); + var attempts = recorder.StartedOfKind(ActivityKind.Client); + Assert.Equal(2, attempts.Count); + Assert.Equal(1, Convert.ToInt32(attempts[1].GetTagItem("http.request.resend_count"), System.Globalization.CultureInfo.InvariantCulture)); + } + + [Fact] + public async Task A_pipeline_with_redirect_forwards_no_bearer_cross_origin() + { + var credential = new CountingCredential(); + using var transport = new ScriptedTransport( + TestResponses.Redirect(302, "https://other.example.org/landing"), + TestResponses.Unauthorized("Bearer realm=\"r\"")); + using var pipeline = DexpacePipeline.CreateDefault( + transport, + new BearerTokenAuthPolicy(credential, "s"), + timeProvider: new InstantTimeProvider()); + + using var response = await pipeline.SendAsync(Request.Get("https://api.example.com/v1/items"), TestContext.Current.CancellationToken); + + // The foreign hop carries no token, is not guarded and a 401 from it is not challenge-handled (AUTH-29, P6c-12). + Assert.Equal(401, response.Status.Code); + Assert.Equal(2, transport.CallCount); + Assert.NotNull(transport.Requests[0].Headers.Get("Authorization")); + Assert.Null(transport.Requests[1].Headers.Get("Authorization")); + Assert.Equal(1, credential.Calls); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BearerTokenAuthPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BearerTokenAuthPolicyTests.cs index 1ee54e6..cea160a 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BearerTokenAuthPolicyTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/BearerTokenAuthPolicyTests.cs @@ -5,9 +5,13 @@ using Dexpace.Sdk.Core.Configuration; using Dexpace.Sdk.Core.Http.Common; using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; using Dexpace.Sdk.Core.Pipeline; using Dexpace.Sdk.Core.Pipeline.Policies; +using Dexpace.Sdk.TestSupport.Diagnostics; +using Dexpace.Sdk.TestSupport.Streams; using Dexpace.Sdk.TestSupport.Transports; +using Microsoft.Extensions.Time.Testing; using Xunit; namespace Dexpace.Sdk.Core.Tests.Pipeline.Policies; @@ -206,9 +210,10 @@ public async Task ProcessAsync_CrossOriginRequest_StripsStaleAuthorizationHeader } [Fact] - public void Process_sync_stamps_through_the_documented_bridge() + public void Process_sync_stamps_through_the_real_sync_path() { - var policy = new BearerTokenAuthPolicy(new FakeTokenCredential("sync-bearer"), "scope"); + // Migrated from the documented bridge (P4c-13, closed): the sync path calls GetToken, never GetTokenAsync. + var policy = new BearerTokenAuthPolicy(new SyncOnlyCredential("sync-bearer"), "scope"); var context = TestContexts.For(); var sent = TestContexts.Sent(policy, context.SeedRequest, context); @@ -216,6 +221,203 @@ public void Process_sync_stamps_through_the_documented_bridge() Assert.Equal("Bearer sync-bearer", sent.Headers.Get("Authorization")); } + [Fact] + public async Task The_sync_and_async_paths_share_one_cache() + { + var credential = new FakeTokenCredential("shared"); + var policy = new BearerTokenAuthPolicy(credential, "scope"); + var context = TestContexts.For(); + + var viaAsync = await TestContexts.SentAsync(policy, context.SeedRequest, context); + var viaSync = TestContexts.Sent(policy, context.SeedRequest, context); + + Assert.Equal(viaAsync.Headers.Get("Authorization"), viaSync.Headers.Get("Authorization")); + Assert.Equal(1, credential.CallCount); + } + + private sealed class SyncOnlyCredential(string token) : TokenCredential + { + public override ValueTask GetTokenAsync(TokenRequestContext context, CancellationToken ct = default) => + throw new InvalidOperationException("The sync path must not call GetTokenAsync."); + + public override AccessToken GetToken(TokenRequestContext context, CancellationToken ct = default) => + new(token, s_farFuture); + } + + // ------------------------------------------------------------------------- + // The 401 hook (AUTH-30, AUTH-31, AUTH-36) + // ------------------------------------------------------------------------- + + /// A credential that hands out tok-1, tok-2, … (or a fixed token when asked to). + private sealed class SequenceCredential(bool repeatFirst = false) : TokenCredential + { + private int _calls; + + public int Calls => Volatile.Read(ref _calls); + + public override ValueTask GetTokenAsync(TokenRequestContext context, CancellationToken ct = default) + { + var n = Interlocked.Increment(ref _calls); + return new ValueTask(new AccessToken("tok-" + (repeatFirst ? 1 : n), s_farFuture)); + } + } + + private static HttpPipeline PipelineOver(BearerTokenAuthPolicy policy, ScriptedTransport transport) => + new PipelineBuilder().Add(policy).Build(transport, MakeOptions()); + + private static async Task SendAsync(HttpPipeline pipeline, Request request, bool async) => + async + ? await pipeline.SendAsync(request, RequestOptions.Empty, TestContext.Current.CancellationToken) + : pipeline.Send(request, RequestOptions.Empty, TestContext.Current.CancellationToken); + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task A_401_with_a_Bearer_challenge_evicts_fetches_fresh_and_retries_once(bool async) + { + var credential = new SequenceCredential(); + using var transport = new ScriptedTransport(TestResponses.Unauthorized("Bearer realm=\"r\""), TestResponses.Create(Status.Ok)); + + using var response = await SendAsync(PipelineOver(new BearerTokenAuthPolicy(credential, "s"), transport), MakeRequest(), async); + + Assert.Equal(200, response.Status.Code); + Assert.Equal(2, transport.CallCount); + Assert.Equal("Bearer tok-1", transport.Requests[0].Headers.Get("Authorization")); + Assert.Equal("Bearer tok-2", transport.Requests[1].Headers.Get("Authorization")); + Assert.Equal(2, credential.Calls); + } + + [Fact] + public async Task A_401_with_a_non_bearer_challenge_is_returned() + { + var credential = new SequenceCredential(); + using var transport = new ScriptedTransport(TestResponses.Unauthorized("Basic realm=\"r\"")); + + using var response = await SendAsync(PipelineOver(new BearerTokenAuthPolicy(credential, "s"), transport), MakeRequest(), async: true); + + Assert.Equal(401, response.Status.Code); + Assert.Equal(1, transport.CallCount); + Assert.Equal(1, credential.Calls); + } + + [Fact] + public async Task A_401_without_WWW_Authenticate_is_returned_without_a_retry() + { + using var transport = new ScriptedTransport(TestResponses.Create(Status.Unauthorized)); + + using var response = await SendAsync( + PipelineOver(new BearerTokenAuthPolicy(new SequenceCredential(), "s"), transport), MakeRequest(), async: true); + + Assert.Equal(401, response.Status.Code); + Assert.Equal(1, transport.CallCount); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task The_retry_is_method_agnostic(bool async) + { + using var transport = new ScriptedTransport(TestResponses.Unauthorized("Bearer realm=\"r\""), TestResponses.Create(Status.Ok)); + var post = Request.Post("https://api.example.com/v1/items", RequestBody.FromString("payload")); + + using var response = await SendAsync(PipelineOver(new BearerTokenAuthPolicy(new SequenceCredential(), "s"), transport), post, async); + + Assert.Equal(200, response.Status.Code); + Assert.Equal(2, transport.CallCount); + Assert.Equal(Method.Post, transport.Requests[1].Method); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task A_stream_body_is_not_retried_and_the_401_is_returned_undisposed(bool async) + { + var body = new TrackingResponseBody([], "401"); + using var transport = new ScriptedTransport( + TestResponses.Unauthorized(null, body, "Bearer realm=\"r\""), + TestResponses.Create(Status.Ok)); + var post = Request.Post("https://api.example.com/v1/items", RequestBody.FromStream(new ChunkedReadStream([1, 2, 3], 2))); + + using var response = await SendAsync(PipelineOver(new BearerTokenAuthPolicy(new SequenceCredential(), "s"), transport), post, async); + + Assert.Equal(401, response.Status.Code); + Assert.Equal(1, transport.CallCount); + Assert.Equal(0, body.DisposeCount); + } + + [Fact] + public async Task A_provider_that_hands_back_the_rejected_token_yields_no_retry() + { + var credential = new SequenceCredential(repeatFirst: true); + using var transport = new ScriptedTransport(TestResponses.Unauthorized("Bearer realm=\"r\""), TestResponses.Create(Status.Ok)); + + using var response = await SendAsync(PipelineOver(new BearerTokenAuthPolicy(credential, "s"), transport), MakeRequest(), async: true); + + Assert.Equal(401, response.Status.Code); + Assert.Equal(1, transport.CallCount); + } + + [Fact] + public async Task A_second_401_after_the_retry_is_returned_without_another_retry() + { + var credential = new SequenceCredential(); + using var transport = new ScriptedTransport( + TestResponses.Unauthorized("Bearer realm=\"r\""), + TestResponses.Unauthorized("Bearer realm=\"r\"")); + + using var response = await SendAsync(PipelineOver(new BearerTokenAuthPolicy(credential, "s"), transport), MakeRequest(), async: true); + + Assert.Equal(401, response.Status.Code); + Assert.Equal(2, transport.CallCount); + Assert.Equal(2, credential.Calls); + } + + [Fact] + public async Task Per_call_OAuth2_scopes_get_their_own_token_on_retry() + { + var recorder = new ScopeRecordingCredential(); + using var transport = new ScriptedTransport(TestResponses.Unauthorized("Bearer realm=\"r\""), TestResponses.Create(Status.Ok)); + var pipeline = PipelineOver(new BearerTokenAuthPolicy(recorder, "policy-scope"), transport); + var options = new RequestOptions { Auth = new AuthDescriptor(new AuthRequirement(AuthScheme.OAuth2) { Scopes = ["call-scope"] }) }; + + using var response = await pipeline.SendAsync(MakeRequest(), options, TestContext.Current.CancellationToken); + + Assert.Equal(["call-scope", "call-scope"], recorder.Scopes); + } + + [Fact] + public async Task Retry_uses_the_logger_of_the_call_for_background_failures() + { + // A pipeline built with a shared cache whose background refresh fails reports through the call's logger (event 160). + var time = new FakeTimeProvider(new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero)); + var calls = 0; + var cache = new AccessTokenCache( + new DelegateCredential(_ => + { + var n = ++calls; + return n == 1 ? new AccessToken("tok-1", time.GetUtcNow().AddSeconds(60)) : throw new InvalidOperationException("down"); + }), + time); + var logger = new RecordingLogger(); + var context = TestContexts.For(logger: logger); + var policy = new BearerTokenAuthPolicy(cache, "s"); + using var first = new ScriptedTransport(TestResponses.Create(Status.Ok), TestResponses.Create(Status.Ok)); + + using var r1 = await policy.ProcessAsync(context.SeedRequest, context, TestContexts.RunnerOver(first)); + time.Advance(TimeSpan.FromSeconds(40)); + using var r2 = await policy.ProcessAsync(context.SeedRequest, context, TestContexts.RunnerOver(first)); + await cache.PendingRefresh(new TokenRequestContext(["s"]))!; + + var entry = Assert.Single(logger.Entries); + Assert.Equal(160, entry.EventId.Id); + } + + private sealed class DelegateCredential(Func factory) : TokenCredential + { + public override ValueTask GetTokenAsync(TokenRequestContext context, CancellationToken ct = default) => + new(factory(context)); + } + // ------------------------------------------------------------------------- // Helper: captures scopes passed to GetTokenAsync // ------------------------------------------------------------------------- From fd987dd021cfca0a84adf8b08eea3e51276d4aa8 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 8 Oct 2026 20:52:06 +0300 Subject: [PATCH 06/13] feat: phase 6c Digest authentication (AUTH-15..AUTH-22, AUTH-24) --- CHANGELOG.md | 5 + src/Dexpace.Sdk.Core/Auth/DigestAlgorithm.cs | 27 + .../Auth/DigestAlgorithmNames.cs | 43 ++ .../Auth/DigestChallengeHandler.cs | 266 +++++++ .../Auth/DigestComputation.cs | 127 ++++ src/Dexpace.Sdk.Core/Auth/Md5Availability.cs | 43 ++ src/Dexpace.Sdk.Core/Auth/NonceCounter.cs | 16 + src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt | 10 + .../Auth/BasicChallengeHandlerTests.cs | 2 + .../Auth/DigestChallengeHandlerTests.cs | 368 ++++++++++ .../Auth/DigestComputationTests.cs | 143 ++++ .../Auth/Md5AvailabilityTests.cs | 73 ++ .../Auth/NonceCounterTests.cs | 102 +++ .../Policies/ChallengeAuthPolicyTests.cs | 56 ++ .../DigestWireTests.cs | 58 ++ tests/vectors/auth/digest.json | 657 ++++++++++++++++++ 16 files changed, 1996 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Auth/DigestAlgorithm.cs create mode 100644 src/Dexpace.Sdk.Core/Auth/DigestAlgorithmNames.cs create mode 100644 src/Dexpace.Sdk.Core/Auth/DigestChallengeHandler.cs create mode 100644 src/Dexpace.Sdk.Core/Auth/DigestComputation.cs create mode 100644 src/Dexpace.Sdk.Core/Auth/Md5Availability.cs create mode 100644 src/Dexpace.Sdk.Core/Auth/NonceCounter.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Auth/DigestChallengeHandlerTests.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Auth/DigestComputationTests.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Auth/Md5AvailabilityTests.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Auth/NonceCounterTests.cs create mode 100644 tests/Dexpace.Sdk.Http.SystemNet.Tests/DigestWireTests.cs create mode 100644 tests/vectors/auth/digest.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 72c36b0..2565f8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -531,5 +531,10 @@ Roadmap phase 1, defects S1–S9, each pinned by a `[Trait("Category", "Security - `AccessTokenCache.Get` (a real synchronous path), `AccessTokenCache.DefaultRefreshMargin` and `RefreshMargin`, `BearerTokenAuthPolicy(AccessTokenCache, params string[])`, `TokenProviderException`, and log event 160 `DexpaceLogEvents.TokenRefreshFailed` (`AUTH-35`, `AUTH-37`). +- RFC 7616 Digest authentication: `DigestChallengeHandler` (MD5, MD5-sess, SHA-256, SHA-256-sess, `qop=auth` or the legacy + no-qop form; a configurable algorithm preference, SHA-256 first by default; `username*` for a non-ASCII user name; a + per-nonce counter bounded to 1024 entries) and `DigestAlgorithm`. A host whose crypto provider refuses MD5 (FIPS) drops the + MD5 algorithms instead of failing, so an MD5-only challenge is declined and a server offering both gets SHA-256 + (`AUTH-15`..`AUTH-22`, `AUTH-24`). No **Breaking** change. [Unreleased]: https://github.com/dexpace/dotnet-sdk/commits/main diff --git a/src/Dexpace.Sdk.Core/Auth/DigestAlgorithm.cs b/src/Dexpace.Sdk.Core/Auth/DigestAlgorithm.cs new file mode 100644 index 0000000..43547ab --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/DigestAlgorithm.cs @@ -0,0 +1,27 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Auth; + +/// +/// The Digest algorithms the SDK implements (RFC 7616, AUTH-15). +/// +/// +/// The wire spellings are MD5, MD5-sess, SHA-256 and SHA-256-sess; the challenge's spelling is +/// matched case-insensitively and the response always uses the full RFC spelling (AUTH-22). The values are explicit and +/// stable. +/// +public enum DigestAlgorithm +{ + /// MD5, the default when a challenge names no algorithm. Unavailable on a host whose crypto provider refuses MD5. + Md5 = 0, + + /// MD5-sess. + Md5Sess = 1, + + /// SHA-256. + Sha256 = 2, + + /// SHA-256-sess. + Sha256Sess = 3, +} diff --git a/src/Dexpace.Sdk.Core/Auth/DigestAlgorithmNames.cs b/src/Dexpace.Sdk.Core/Auth/DigestAlgorithmNames.cs new file mode 100644 index 0000000..6e6fe76 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/DigestAlgorithmNames.cs @@ -0,0 +1,43 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Auth; + +/// The wire spellings of . +internal static class DigestAlgorithmNames +{ + internal static string Wire(DigestAlgorithm algorithm) => algorithm switch + { + DigestAlgorithm.Md5 => "MD5", + DigestAlgorithm.Md5Sess => "MD5-sess", + DigestAlgorithm.Sha256 => "SHA-256", + DigestAlgorithm.Sha256Sess => "SHA-256-sess", + _ => throw new ArgumentOutOfRangeException(nameof(algorithm), algorithm, "Not a supported Digest algorithm."), + }; + + // An absent algorithm means MD5 (AUTH-16); the token is matched case-insensitively with an ordinal compare. + internal static bool TryParse(string? token, out DigestAlgorithm algorithm) + { + if (token is null) + { + algorithm = DigestAlgorithm.Md5; + return true; + } + + foreach (var candidate in Enum.GetValues()) + { + if (string.Equals(token, Wire(candidate), StringComparison.OrdinalIgnoreCase)) + { + algorithm = candidate; + return true; + } + } + + algorithm = default; + return false; + } + + internal static bool IsSession(DigestAlgorithm algorithm) => algorithm is DigestAlgorithm.Md5Sess or DigestAlgorithm.Sha256Sess; + + internal static bool IsMd5(DigestAlgorithm algorithm) => algorithm is DigestAlgorithm.Md5 or DigestAlgorithm.Md5Sess; +} diff --git a/src/Dexpace.Sdk.Core/Auth/DigestChallengeHandler.cs b/src/Dexpace.Sdk.Core/Auth/DigestChallengeHandler.cs new file mode 100644 index 0000000..c67b18a --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/DigestChallengeHandler.cs @@ -0,0 +1,266 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Security.Cryptography; +using System.Text; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Internal; + +namespace Dexpace.Sdk.Core.Auth; + +/// +/// Answers a Digest challenge (RFC 7616; AUTH-15 to AUTH-22, AUTH-24). +/// +/// +/// +/// Algorithms. , , +/// and , with qop=auth or the legacy +/// RFC 2069 form when a challenge sends no qop. The handler walks its preference list, not the order the +/// challenges arrived in; the default is SHA-256, SHA-256-sess, MD5, MD5-sess (P6c-29). A caller passes any non-empty, +/// duplicate-free subset. A host whose crypto provider refuses MD5 (FIPS) drops the MD5 algorithms, so an MD5-only +/// challenge is declined and a server offering both falls through to SHA-256; construction never fails there (P6c-30). +/// +/// +/// Declined challenges. A challenge is declined, never answered wrongly, when it lacks realm or nonce, +/// offers only auth-int, names an unsupported algorithm, asks for a -sess algorithm without qop (its +/// response could not be verified, P6c-31), carries a realm, nonce or opaque that cannot be echoed as outbound header text, +/// or needs a credential character its charset cannot represent (P6c-32). A declined attempt consumes no nc. +/// +/// +/// Encodings. The credential is hashed as UTF-8 under charset=UTF-8 (any case), as ISO-8859-1 otherwise. A +/// username that is not printable ASCII is sent as username* (RFC 8187). The digest-uri is the escaped +/// request-target the transport writes. The cnonce is 128 bits from the operating system's CSPRNG (AUTH-20, +/// XCUT-21). rspauth in Authentication-Info is not verified. +/// +/// +/// The handler answers a challenge and remembers nothing but counts: a bounded map of 1024 nonces to atomic counters +/// (AUTH-19, AUTH-24). There is no preemptive Digest. +/// +/// +public sealed class DigestChallengeHandler : IChallengeHandler +{ + private const int NonceCapacity = 1024; + + private readonly DigestCredential _credential; + private readonly DigestAlgorithm[] _preference; + private readonly bool _md5Available; + private readonly Func _cnonceFactory; + private readonly BoundedMap _nonces = new(NonceCapacity); + + /// Initializes a handler with the . + /// The credential to answer with. + /// is . + public DigestChallengeHandler(DigestCredential credential) + : this(credential, DefaultPreference) + { + } + + /// Initializes a handler with an explicit algorithm preference. + /// The credential to answer with. + /// The algorithms to prefer, most preferred first: non-empty, without duplicates. + /// An argument is . + /// is empty, repeats an algorithm or holds an undefined value. + public DigestChallengeHandler(DigestCredential credential, IEnumerable preference) + : this(credential, preference, Md5Availability.IsAvailable, static () => RandomNumberGenerator.GetHexString(32, lowercase: true)) + { + } + + internal DigestChallengeHandler( + DigestCredential credential, + IEnumerable preference, + bool md5Available, + Func cnonceFactory) + { + ArgumentNullException.ThrowIfNull(credential); + ArgumentNullException.ThrowIfNull(preference); + ArgumentNullException.ThrowIfNull(cnonceFactory); + var configured = preference.ToArray(); + if (configured.Length == 0) + { + throw new ArgumentException("A preference needs at least one algorithm.", nameof(preference)); + } + + if (configured.Any(static a => !Enum.IsDefined(a)) || configured.Distinct().Count() != configured.Length) + { + throw new ArgumentException("A preference holds only defined algorithms, each once.", nameof(preference)); + } + + _credential = credential; + _md5Available = md5Available; + _cnonceFactory = cnonceFactory; + + // The effective preference: what was configured, less the MD5 algorithms a FIPS host refuses. + _preference = [.. configured.Where(a => md5Available || !DigestAlgorithmNames.IsMd5(a))]; + } + + /// The default preference: SHA-256, SHA-256-sess, MD5, MD5-sess (strongest first, RFC 7616 section 3.7). + public static IReadOnlyList DefaultPreference { get; } = + [DigestAlgorithm.Sha256, DigestAlgorithm.Sha256Sess, DigestAlgorithm.Md5, DigestAlgorithm.Md5Sess]; + + internal int NonceStoreCapacity => _nonces.Capacity; + + internal bool Md5Available => _md5Available; + + /// + public Request? Authorize(IReadOnlyList challenges, Request request, bool proxy) + { + ArgumentNullException.ThrowIfNull(challenges); + ArgumentNullException.ThrowIfNull(request); + foreach (var candidate in Candidates(challenges)) + { + if (TryRender(candidate, request) is { } value) + { + var header = proxy ? HttpHeaderName.WellKnown.ProxyAuthorization : HttpHeaderName.WellKnown.Authorization; + return request.WithHeaders(request.Headers.Set(header.Original, value)); + } + } + + return null; + } + + internal void SetNextCount(string nonce, uint next) => + _nonces.GetOrAdd(nonce, static () => new NonceCounter()).SetNext(next); + + // AUTH-16: the satisfiable challenges, walked in the handler's preference order (wire order within one algorithm). + private IEnumerable Candidates(IReadOnlyList challenges) + { + var satisfiable = new List(); + foreach (var challenge in challenges) + { + if (Satisfiable(challenge) is { } candidate) + { + satisfiable.Add(candidate); + } + } + + foreach (var algorithm in _preference) + { + foreach (var candidate in satisfiable) + { + if (candidate.Algorithm == algorithm) + { + yield return candidate; + } + } + } + } + + private static Candidate? Satisfiable(AuthenticationChallenge challenge) + { + if (!string.Equals(challenge.Scheme, "digest", StringComparison.Ordinal)) + { + return null; + } + + var parameters = challenge.Parameters; + if (!parameters.TryGetValue("realm", out var realm) || !parameters.TryGetValue("nonce", out var nonce)) + { + return null; + } + + parameters.TryGetValue("qop", out var qop); + var qopAuth = qop is not null && HasAuth(qop); + if (qop is not null && !qopAuth) + { + return null; + } + + parameters.TryGetValue("algorithm", out var algorithmToken); + if (!DigestAlgorithmNames.TryParse(algorithmToken, out var algorithm)) + { + return null; + } + + // P6c-31: a -sess response without qop could not be verified by the server (AUTH-16, AUTH-17 and AUTH-22 conflict). + if (DigestAlgorithmNames.IsSession(algorithm) && !qopAuth) + { + return null; + } + + parameters.TryGetValue("opaque", out var opaque); + parameters.TryGetValue("charset", out var charset); + return Echoable(realm) && Echoable(nonce) && (opaque is null || Echoable(opaque)) + ? new Candidate(algorithm, realm, nonce, qopAuth, opaque, string.Equals(charset, "utf-8", StringComparison.OrdinalIgnoreCase)) + : null; + } + + // qop is a comma-separated token list compared token-exact: "auth-int" does not contain "auth". + private static bool HasAuth(string qop) + { + foreach (var token in qop.Split(',', StringSplitOptions.TrimEntries)) + { + if (string.Equals(token, "auth", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + // XCUT-18: an echoed value must be valid outbound header text, or the challenge is declined. + private static bool Echoable(string value) => HeaderSyntax.IndexOfInvalidOutbound(value) < 0; + + // The credential is encoded first, the one step that can decline, and the nonce count taken after it, so a declined + // attempt consumes none (P6c-35). + private string? TryRender(Candidate candidate, Request request) + { + if (!DigestComputation.TryEncode(_credential.Username, candidate.Utf8, out var user) + || !DigestComputation.TryEncode(_credential.Password, candidate.Utf8, out var password) + || !DigestComputation.TryEncode(candidate.Realm, candidate.Utf8, out var realm)) + { + return null; + } + + var uri = DigestComputation.DigestUri(request.Url); + var cnonce = _cnonceFactory(); + var qop = candidate.QopAuth ? "auth" : null; + var nc = candidate.QopAuth ? DigestComputation.Nc(_nonces.GetOrAdd(candidate.Nonce, static () => new NonceCounter()).Next()) : string.Empty; + var response = DigestComputation.Compute( + candidate.Algorithm, + user, + realm, + password, + candidate.Nonce, + request.Method.ToString(), + uri, + qop, + nc, + cnonce); + return Render(candidate, user, uri, qop, nc, cnonce, response); + } + + // AUTH-22: username, realm, nonce, uri, response, cnonce and opaque quoted; qop, nc and algorithm bare, the algorithm in + // its full RFC spelling; cnonce, nc and qop only when qop was negotiated; opaque only when sent; never userhash. + private string Render(Candidate candidate, byte[] user, string uri, string? qop, string nc, string cnonce, string response) + { + var builder = new StringBuilder("Digest "); + builder.Append(UsernameField(user, candidate.Utf8)); + builder.Append(", realm=\"").Append(DigestComputation.Quote(candidate.Realm)).Append('"'); + builder.Append(", nonce=\"").Append(DigestComputation.Quote(candidate.Nonce)).Append('"'); + builder.Append(", uri=\"").Append(DigestComputation.Quote(uri)).Append('"'); + builder.Append(", algorithm=").Append(DigestAlgorithmNames.Wire(candidate.Algorithm)); + if (qop is not null) + { + builder.Append(", qop=").Append(qop).Append(", nc=").Append(nc); + builder.Append(", cnonce=\"").Append(DigestComputation.Quote(cnonce)).Append('"'); + } + + builder.Append(", response=\"").Append(response).Append('"'); + if (candidate.Opaque is not null) + { + builder.Append(", opaque=\"").Append(DigestComputation.Quote(candidate.Opaque)).Append('"'); + } + + return builder.ToString(); + } + + // P6c-33: a username that is not printable ASCII goes out as RFC 8187's extended form (the outbound grammar refuses it). + private string UsernameField(byte[] encodedUsername, bool utf8) => + DigestComputation.IsPrintableAscii(_credential.Username) + ? "username=\"" + DigestComputation.Quote(_credential.Username) + "\"" + : "username*=" + DigestComputation.Rfc8187Encode(encodedUsername, utf8); + + private sealed record Candidate(DigestAlgorithm Algorithm, string Realm, string Nonce, bool QopAuth, string? Opaque, bool Utf8); +} diff --git a/src/Dexpace.Sdk.Core/Auth/DigestComputation.cs b/src/Dexpace.Sdk.Core/Auth/DigestComputation.cs new file mode 100644 index 0000000..3ce9365 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/DigestComputation.cs @@ -0,0 +1,127 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace Dexpace.Sdk.Core.Auth; + +/// +/// The pure arithmetic and text forms of RFC 7616 Digest (AUTH-17, AUTH-20 to AUTH-22; design §6.3): hashing, the credential +/// encodings, quoting and the RFC 8187 form of a non-ASCII username. Stateless. +/// +internal static class DigestComputation +{ + // The charsets of AUTH-21. Both throw on a character they cannot represent, so a credential is never hashed with a + // silent '?' substituted (fact 6, P6c-32). + private static readonly Encoding s_utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + private static readonly Encoding s_latin1 = Encoding.GetEncoding("iso-8859-1", EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback); + + // Encodes text under the challenge's charset; false when a character has no code in it (AUTH-21). + internal static bool TryEncode(string text, bool utf8, out byte[] bytes) + { + try + { + bytes = (utf8 ? s_utf8 : s_latin1).GetBytes(text); + return true; + } + catch (EncoderFallbackException) + { + bytes = []; + return false; + } + } + + // The response value (AUTH-17): qop == null is the legacy RFC 2069 form, otherwise qop is "auth". + internal static string Compute( + DigestAlgorithm algorithm, + byte[] username, + byte[] realm, + byte[] password, + string nonce, + string method, + string digestUri, + string? qop, + string nc, + string cnonce) + { + var md5 = DigestAlgorithmNames.IsMd5(algorithm); + var ha1 = ComputeHa1(md5, DigestAlgorithmNames.IsSession(algorithm), username, realm, password, nonce, cnonce); + var ha2 = HashHex(md5, Encoding.UTF8.GetBytes($"{method}:{digestUri}")); + var input = qop is null ? $"{ha1}:{nonce}:{ha2}" : $"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}"; + return HashHex(md5, Encoding.UTF8.GetBytes(input)); + } + + // H(username:realm:password), keyed with the nonce and cnonce for a -sess algorithm. + internal static string ComputeHa1(bool md5, bool session, byte[] username, byte[] realm, byte[] password, string nonce, string cnonce) + { + var joined = new byte[username.Length + realm.Length + password.Length + 2]; + username.CopyTo(joined, 0); + joined[username.Length] = (byte)':'; + realm.CopyTo(joined, username.Length + 1); + joined[username.Length + 1 + realm.Length] = (byte)':'; + password.CopyTo(joined, username.Length + realm.Length + 2); + var ha1 = HashHex(md5, joined); + return session ? HashHex(md5, Encoding.UTF8.GetBytes($"{ha1}:{nonce}:{cnonce}")) : ha1; + } + + // Lower-case hex only through Convert.ToHexStringLower (AUTH-17, P6c-38). MD5 is the protocol's own hash (RFC 2617 and + // RFC 7616 define the response over it); it is offered only when a server asks for it and the host allows it (AUTH-15). +#pragma warning disable CA5351 + internal static string HashHex(bool md5, byte[] data) => + Convert.ToHexStringLower(md5 ? MD5.HashData(data) : SHA256.HashData(data)); +#pragma warning restore CA5351 + + // The request-target the transport writes (fact 9): the escaped path and query, "/" for an empty one (P6c-34). + internal static string DigestUri(Uri url) + { + var target = url.GetComponents(UriComponents.PathAndQuery, UriFormat.UriEscaped); + return target.Length == 0 ? "/" : target; + } + + // RFC 7616 section 3.4: quoted-string escaping of a quote and a backslash (AUTH-22). + internal static string Quote(string value) => + value.Contains('"', StringComparison.Ordinal) || value.Contains('\\', StringComparison.Ordinal) + ? value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal) + : value; + + internal static string Nc(uint count) => count.ToString("x8", CultureInfo.InvariantCulture); + + internal static bool IsPrintableAscii(string text) + { + foreach (var c in text) + { + if (c is < ' ' or > '~') + { + return false; + } + } + + return true; + } + + // RFC 8187 section 3.2.1 (R14): percent-encode every byte outside attr-char, upper-case hex, with the charset prefix. + internal static string Rfc8187Encode(byte[] bytes, bool utf8) + { + var builder = new StringBuilder(utf8 ? "UTF-8''" : "ISO-8859-1''"); + foreach (var b in bytes) + { + if (IsAttrChar(b)) + { + builder.Append((char)b); + } + else + { + builder.Append('%').Append(b.ToString("X2", CultureInfo.InvariantCulture)); + } + } + + return builder.ToString(); + } + + private static bool IsAttrChar(byte b) => + b is (>= (byte)'a' and <= (byte)'z') or (>= (byte)'A' and <= (byte)'Z') or (>= (byte)'0' and <= (byte)'9') + or (byte)'!' or (byte)'#' or (byte)'$' or (byte)'&' or (byte)'+' or (byte)'-' or (byte)'.' or (byte)'^' + or (byte)'_' or (byte)'`' or (byte)'|' or (byte)'~'; +} diff --git a/src/Dexpace.Sdk.Core/Auth/Md5Availability.cs b/src/Dexpace.Sdk.Core/Auth/Md5Availability.cs new file mode 100644 index 0000000..7e95ce7 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/Md5Availability.cs @@ -0,0 +1,43 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Security.Cryptography; + +namespace Dexpace.Sdk.Core.Auth; + +/// +/// Whether this host's crypto provider will compute MD5 (AUTH-15, P6c-30; design §10 entry 16). +/// +/// +/// A FIPS-enabled OpenSSL refuses MD5. net10.0 exposes no MD5.IsSupported (verified), so the answer is found +/// by trying a hash once per process. When MD5 is unavailable the Digest handler drops the MD5 algorithms from its +/// effective preference, so an MD5-only challenge is declined and a server offering both falls through to SHA-256. +/// +internal static class Md5Availability +{ + // MD5 is only tried, never relied on for security: the Digest protocol defines the response over it (RFC 7616). +#pragma warning disable CA5351 + private static readonly Lazy s_available = new(static () => Probe(static () => MD5.HashData(ReadOnlySpan.Empty))); +#pragma warning restore CA5351 + + internal static bool IsAvailable => s_available.Value; + + // Any other exception is a defect and propagates. + internal static bool Probe(Func hash) + { + ArgumentNullException.ThrowIfNull(hash); + try + { + _ = hash(); + return true; + } + catch (CryptographicException) + { + return false; + } + catch (PlatformNotSupportedException) + { + return false; + } + } +} diff --git a/src/Dexpace.Sdk.Core/Auth/NonceCounter.cs b/src/Dexpace.Sdk.Core/Auth/NonceCounter.cs new file mode 100644 index 0000000..69aaad2 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Auth/NonceCounter.cs @@ -0,0 +1,16 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +namespace Dexpace.Sdk.Core.Auth; + +/// The count of responses sent for one server nonce (AUTH-18): atomic, so concurrent answers never repeat a count. +internal sealed class NonceCounter +{ + private int _value; + + /// The next nc: 1 for the first use of the nonce. + internal uint Next() => (uint)Interlocked.Increment(ref _value); + + /// Test hook: positions the counter so the next use yields . + internal void SetNext(uint next) => Volatile.Write(ref _value, unchecked((int)(next - 1))); +} diff --git a/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt b/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt index 2c324fd..1b73711 100644 --- a/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt +++ b/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt @@ -131,6 +131,15 @@ Dexpace.Sdk.Core.Auth.BasicCredential.Username.get -> string! Dexpace.Sdk.Core.Auth.CompositeChallengeHandler Dexpace.Sdk.Core.Auth.CompositeChallengeHandler.Authorize(System.Collections.Generic.IReadOnlyList! challenges, Dexpace.Sdk.Core.Http.Request.Request! request, bool proxy) -> Dexpace.Sdk.Core.Http.Request.Request? Dexpace.Sdk.Core.Auth.CompositeChallengeHandler.CompositeChallengeHandler(params System.Collections.Generic.IEnumerable! handlers) -> void +Dexpace.Sdk.Core.Auth.DigestAlgorithm +Dexpace.Sdk.Core.Auth.DigestAlgorithm.Md5 = 0 -> Dexpace.Sdk.Core.Auth.DigestAlgorithm +Dexpace.Sdk.Core.Auth.DigestAlgorithm.Md5Sess = 1 -> Dexpace.Sdk.Core.Auth.DigestAlgorithm +Dexpace.Sdk.Core.Auth.DigestAlgorithm.Sha256 = 2 -> Dexpace.Sdk.Core.Auth.DigestAlgorithm +Dexpace.Sdk.Core.Auth.DigestAlgorithm.Sha256Sess = 3 -> Dexpace.Sdk.Core.Auth.DigestAlgorithm +Dexpace.Sdk.Core.Auth.DigestChallengeHandler +Dexpace.Sdk.Core.Auth.DigestChallengeHandler.Authorize(System.Collections.Generic.IReadOnlyList! challenges, Dexpace.Sdk.Core.Http.Request.Request! request, bool proxy) -> Dexpace.Sdk.Core.Http.Request.Request? +Dexpace.Sdk.Core.Auth.DigestChallengeHandler.DigestChallengeHandler(Dexpace.Sdk.Core.Auth.DigestCredential! credential) -> void +Dexpace.Sdk.Core.Auth.DigestChallengeHandler.DigestChallengeHandler(Dexpace.Sdk.Core.Auth.DigestCredential! credential, System.Collections.Generic.IEnumerable! preference) -> void Dexpace.Sdk.Core.Auth.DigestCredential Dexpace.Sdk.Core.Auth.DigestCredential.DigestCredential(string! username, string! password) -> void Dexpace.Sdk.Core.Auth.DigestCredential.Password.get -> string! @@ -725,6 +734,7 @@ static Dexpace.Sdk.Core.Auth.AuthRequirement.operator ==(Dexpace.Sdk.Core.Auth.A static Dexpace.Sdk.Core.Auth.AuthResolver.Resolve(Dexpace.Sdk.Core.Auth.AuthDescriptor? perCall, Dexpace.Sdk.Core.Auth.AuthDescriptor? operation, Dexpace.Sdk.Core.Auth.AuthDescriptor? client, System.Collections.Generic.IReadOnlyCollection! availableSchemes) -> Dexpace.Sdk.Core.Auth.AuthRequirement! static Dexpace.Sdk.Core.Auth.AuthenticationChallenge.Parse(System.ReadOnlySpan value) -> System.Collections.Generic.IReadOnlyList! static Dexpace.Sdk.Core.Auth.AuthenticationChallenge.Parse(string? value) -> System.Collections.Generic.IReadOnlyList! +static Dexpace.Sdk.Core.Auth.DigestChallengeHandler.DefaultPreference.get -> System.Collections.Generic.IReadOnlyList! static Dexpace.Sdk.Core.Client.DelegateHttpClient.Create(System.Func!>! send) -> Dexpace.Sdk.Core.Client.IAsyncHttpClient! static Dexpace.Sdk.Core.Client.DelegateHttpClient.CreateBlocking(System.Func! send) -> Dexpace.Sdk.Core.Client.IHttpClient! static Dexpace.Sdk.Core.Client.HttpClientExtensions.AsAsync(this Dexpace.Sdk.Core.Client.IHttpClient! client, System.Threading.Tasks.TaskScheduler! scheduler) -> Dexpace.Sdk.Core.Client.IAsyncHttpClient! diff --git a/tests/Dexpace.Sdk.Core.Tests/Auth/BasicChallengeHandlerTests.cs b/tests/Dexpace.Sdk.Core.Tests/Auth/BasicChallengeHandlerTests.cs index de83b2f..a51addf 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Auth/BasicChallengeHandlerTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Auth/BasicChallengeHandlerTests.cs @@ -238,6 +238,8 @@ public sealed class ChallengeHandlerContractTests ChallengeFixtures.Basic, new CompositeChallengeHandler(new BasicChallengeHandler(new BasicCredential("u", "p"))) }, + { "digest", ChallengeFixtures.DigestMd5, new DigestChallengeHandler(new DigestCredential("u", "p")) }, + { "digest-sha256-sess", ChallengeFixtures.DigestSha256Sess, new DigestChallengeHandler(new DigestCredential("u", "p")) }, }; [Theory] diff --git a/tests/Dexpace.Sdk.Core.Tests/Auth/DigestChallengeHandlerTests.cs b/tests/Dexpace.Sdk.Core.Tests/Auth/DigestChallengeHandlerTests.cs new file mode 100644 index 0000000..fdcb2fd --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Auth/DigestChallengeHandlerTests.cs @@ -0,0 +1,368 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Globalization; +using System.Reflection; +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.TestSupport.Vectors; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Auth; + +/// AUTH-15 to AUTH-22 and AUTH-24: the Digest challenge handler, over the shared vectors and by rule. +[Trait("Category", "Unit")] +public sealed class DigestChallengeHandlerTests +{ + private const string Mufasa = "Digest realm=\"testrealm@host.com\", qop=\"auth,auth-int\", nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""; + + public static TheoryData VectorNames + { + get + { + var data = new TheoryData(); + foreach (var vector in VectorFile.Load("auth/digest.json")) + { + data.Add(vector.Name); + } + + return data; + } + } + + [Theory] + [MemberData(nameof(VectorNames))] + public void Every_vector_row_produces_its_expected_Authorization_or_null(string name) + { + var vector = VectorFile.Load("auth/digest.json").Single(v => v.Name == name); + var handler = HandlerFor(vector); + var request = new Request(Method.Of(vector.Method), new Uri(vector.Url)); + var challenges = vector.Challenges.SelectMany(AuthenticationChallenge.Parse).ToList(); + + var first = handler.Authorize(challenges, request, proxy: false); + Assert.Equal(vector.Expected?.Authorization, first?.Headers.Get("Authorization")); + if (vector.Expected is not null || vector.Second is not null) + { + Assert.Null(first?.Headers.Get("Proxy-Authorization")); + } + + if (vector.Second is not null) + { + var second = handler.Authorize(challenges, request, proxy: false); + Assert.Equal(vector.Second.Authorization, second?.Headers.Get("Authorization")); + } + } + + [Fact] + public void Every_vector_has_a_source() + { + Assert.All(VectorFile.Load("auth/digest.json"), v => Assert.False(string.IsNullOrWhiteSpace(v.Source), v.Name)); + } + + // ---- Selection (AUTH-16) ------------------------------------------------- + + private static DigestChallengeHandler Handler( + IEnumerable? preference = null, + bool md5 = true, + string cnonce = "cn", + string user = "u", + string password = "p") => + new(new DigestCredential(user, password), preference ?? DigestChallengeHandler.DefaultPreference, md5, () => cnonce); + + private static Request Get(string url = "https://host/x") => Request.Get(url); + + private static string? Answer(DigestChallengeHandler handler, params string[] headers) => + handler.Authorize([.. headers.SelectMany(AuthenticationChallenge.Parse)], Get(), proxy: false)?.Headers.Get("Authorization"); + + private static Dictionary Fields(string header) => + AuthenticationChallenge.Parse(header).Single().Parameters.ToDictionary(p => p.Key, p => p.Value); + + [Fact] + public void Satisfiable_requires_digest_realm_nonce() + { + var handler = Handler(); + + Assert.NotNull(Answer(handler, "Digest realm=\"r\", nonce=\"n\"")); + Assert.Null(Answer(handler, "Digest realm=\"r\"")); + Assert.Null(Answer(handler, "Digest nonce=\"n\"")); + Assert.Null(Answer(handler, "Basic realm=\"r\"")); + } + + [Theory] + [InlineData("", true)] + [InlineData(", qop=\"auth\"", true)] + [InlineData(", qop=\"auth-int,auth\"", true)] + [InlineData(", qop=\"AUTH\"", true)] + [InlineData(", qop=\"auth-int\"", false)] + public void Qop_absent_or_a_list_containing_auth_is_accepted(string qop, bool accepted) + { + var answer = Answer(Handler(), "Digest realm=\"r\", nonce=\"n\"" + qop); + + Assert.Equal(accepted, answer is not null); + } + + [Fact] + public void Algorithm_absent_means_MD5() + { + var answer = Answer(Handler(), "Digest realm=\"r\", nonce=\"n\"")!; + + Assert.Equal("MD5", Fields(answer)["algorithm"]); + } + + [Fact] + public void Chosen_by_configured_preference_not_wire_order() + { + var md5First = new[] { "Digest realm=\"r\", nonce=\"a\", algorithm=MD5", "Digest realm=\"r\", nonce=\"b\", algorithm=SHA-256" }; + var sha = Fields(Answer(Handler(), md5First)!); + var md5 = Fields(Answer(Handler([DigestAlgorithm.Md5, DigestAlgorithm.Sha256]), md5First)!); + + Assert.Equal("SHA-256", sha["algorithm"]); + Assert.Equal("b", sha["nonce"]); + Assert.Equal("MD5", md5["algorithm"]); + Assert.Equal("a", md5["nonce"]); + } + + [Fact] + public void Default_preference_is_SHA256_SHA256sess_MD5_MD5sess() + { + Assert.Equal( + [DigestAlgorithm.Sha256, DigestAlgorithm.Sha256Sess, DigestAlgorithm.Md5, DigestAlgorithm.Md5Sess], + DigestChallengeHandler.DefaultPreference); + } + + [Fact] + public void A_caller_preference_is_any_non_empty_duplicate_free_subset() + { + var credential = new DigestCredential("u", "p"); + + Assert.Throws(() => new DigestChallengeHandler(credential, Array.Empty())); + Assert.Throws(() => new DigestChallengeHandler(credential, [DigestAlgorithm.Md5, DigestAlgorithm.Md5])); + Assert.Throws(() => new DigestChallengeHandler(credential, [(DigestAlgorithm)42])); + Assert.Throws(() => new DigestChallengeHandler(null!)); + Assert.NotNull(new DigestChallengeHandler(credential, [DigestAlgorithm.Sha256Sess])); + } + + [Fact] + public void A_challenge_among_several_is_picked_by_scheme() + { + var answer = Answer(Handler(), "Bearer realm=\"b\"", "Digest realm=\"r\", nonce=\"n\""); + + Assert.NotNull(answer); + Assert.StartsWith("Digest ", answer, StringComparison.Ordinal); + } + + [Fact] + public void Sess_without_qop_is_declined() + { + Assert.Null(Answer(Handler(), "Digest realm=\"r\", nonce=\"n\", algorithm=SHA-256-sess")); + Assert.Null(Answer(Handler(), "Digest realm=\"r\", nonce=\"n\", algorithm=MD5-sess")); + Assert.NotNull(Answer(Handler(), "Digest realm=\"r\", nonce=\"n\", qop=\"auth\", algorithm=MD5-sess")); + } + + // ---- Wire form (AUTH-22) --------------------------------------------------- + + [Fact] + public void Username_realm_nonce_uri_response_cnonce_opaque_are_quoted_qop_nc_algorithm_are_bare() + { + var header = Answer(Handler(cnonce: "cn"), Mufasa)!; + + foreach (var name in new[] { "username", "realm", "nonce", "uri", "response", "cnonce", "opaque" }) + { + Assert.Contains(name + "=\"", header, StringComparison.Ordinal); + } + + Assert.Contains(", qop=auth,", header, StringComparison.Ordinal); + Assert.Contains(", nc=00000001,", header, StringComparison.Ordinal); + Assert.Contains(", algorithm=MD5,", header, StringComparison.Ordinal); + Assert.DoesNotContain("userhash", header, StringComparison.Ordinal); + } + + [Theory] + [InlineData("SHA-256", "SHA-256")] + [InlineData("sha-256-sess", "SHA-256-sess")] + [InlineData("md5", "MD5")] + [InlineData("MD5-SESS", "MD5-sess")] + public void Algorithm_is_always_emitted_in_full_spelling(string challenge, string expected) + { + var header = Answer(Handler(), $"Digest realm=\"r\", nonce=\"n\", qop=\"auth\", algorithm={challenge}")!; + + Assert.Equal(expected, Fields(header)["algorithm"]); + } + + [Fact] + public void Qop_nc_cnonce_only_when_qop_is_negotiated() + { + var legacy = Fields(Answer(Handler(), "Digest realm=\"r\", nonce=\"n\"")!); + var negotiated = Fields(Answer(Handler(), "Digest realm=\"r\", nonce=\"n\", qop=\"auth\"")!); + + Assert.DoesNotContain("qop", legacy.Keys); + Assert.DoesNotContain("nc", legacy.Keys); + Assert.DoesNotContain("cnonce", legacy.Keys); + Assert.Equal("auth", negotiated["qop"]); + Assert.Equal("00000001", negotiated["nc"]); + Assert.Equal("cn", negotiated["cnonce"]); + } + + [Fact] + public void Opaque_only_when_sent() + { + Assert.DoesNotContain("opaque", Fields(Answer(Handler(), "Digest realm=\"r\", nonce=\"n\"")!).Keys); + Assert.Equal("o", Fields(Answer(Handler(), "Digest realm=\"r\", nonce=\"n\", opaque=\"o\"")!)["opaque"]); + } + + [Fact] + public void A_username_with_non_ascii_goes_out_as_username_star() + { + var utf8 = Answer(Handler(user: "Jäsøn Doe"), "Digest realm=\"r\", nonce=\"n\", charset=UTF-8")!; + var latin1 = Answer(Handler(user: "José"), "Digest realm=\"r\", nonce=\"n\"")!; + + Assert.Contains("username*=UTF-8''J%C3%A4s%C3%B8n%20Doe,", utf8, StringComparison.Ordinal); + Assert.DoesNotContain("username=\"", utf8, StringComparison.Ordinal); + Assert.Contains("username*=ISO-8859-1''Jos%E9,", latin1, StringComparison.Ordinal); + } + + [Fact] + public void An_unechoable_realm_nonce_or_opaque_is_declined() + { + Assert.Null(Answer(Handler(), "Digest realm=\"café\", nonce=\"n\"")); + Assert.Null(Answer(Handler(), "Digest realm=\"r\", nonce=\"né\"")); + Assert.Null(Answer(Handler(), "Digest realm=\"r\", nonce=\"n\", opaque=\"oé\"")); + } + + [Fact] + public void The_header_value_passes_the_outbound_grammar() + { + var request = Get(); + var header = Answer(Handler(user: "a\"b", password: "p"), Mufasa)!; + + var set = request.Headers.Set("Authorization", header); + + Assert.Equal(header, set.Get("Authorization")); + } + + [Fact] + public void Proxy_true_sets_Proxy_Authorization_only() + { + var challenges = AuthenticationChallenge.Parse(Mufasa); + + var answer = Handler().Authorize(challenges, Get(), proxy: true)!; + + Assert.StartsWith("Digest ", answer.Headers.Get("Proxy-Authorization"), StringComparison.Ordinal); + Assert.Null(answer.Headers.Get("Authorization")); + } + + // ---- FIPS (AUTH-15, P6c-30) -------------------------------------------------- + + [Fact] + public void With_MD5_unavailable_an_MD5_only_challenge_is_declined() + { + Assert.Null(Answer(Handler(md5: false), "Digest realm=\"r\", nonce=\"n\"")); + Assert.Null(Answer(Handler(md5: false), "Digest realm=\"r\", nonce=\"n\", qop=\"auth\", algorithm=MD5-sess")); + } + + [Fact] + public void With_MD5_unavailable_a_challenge_offering_both_falls_through_to_SHA256() + { + var answer = Answer( + Handler([DigestAlgorithm.Md5, DigestAlgorithm.Sha256], md5: false), + "Digest realm=\"r\", nonce=\"a\", algorithm=MD5", + "Digest realm=\"r\", nonce=\"b\", algorithm=SHA-256")!; + + Assert.Equal("SHA-256", Fields(answer)["algorithm"]); + } + + [Fact] + public void Construction_never_fails_on_a_host_without_MD5_even_with_an_MD5_only_preference() + { + var handler = Handler([DigestAlgorithm.Md5, DigestAlgorithm.Md5Sess], md5: false); + + Assert.Null(Answer(handler, Mufasa)); + Assert.False(handler.Md5Available); + } + + // ---- Not built on purpose, counters ------------------------------------------ + + [Fact] + public void No_rspauth_verification() + { + var members = typeof(DigestChallengeHandler).GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Select(m => m.Name) + .Where(n => n.Contains("Info", StringComparison.OrdinalIgnoreCase) || n.Contains("rspauth", StringComparison.OrdinalIgnoreCase) || n.Contains("Verify", StringComparison.OrdinalIgnoreCase)); + + Assert.Empty(members); + } + + [Fact] + public void A_declined_attempt_does_not_advance_nc() + { + // The password has no ISO-8859-1 code: the Latin-1 challenge is declined and must not consume a count. + var handler = Handler(password: "日本"); + Assert.Null(Answer(handler, "Digest realm=\"r\", nonce=\"same\", qop=\"auth\"")); + + var utf8 = Answer(handler, "Digest realm=\"r\", nonce=\"same\", qop=\"auth\", charset=UTF-8")!; + + Assert.Equal("00000001", Fields(utf8)["nc"]); + } + + [Fact] + public void The_cnonce_default_is_32_lower_case_hex_characters_and_fresh_each_time() + { + var handler = new DigestChallengeHandler(new DigestCredential("u", "p")); + var challenges = AuthenticationChallenge.Parse("Digest realm=\"r\", nonce=\"n\", qop=\"auth\", algorithm=SHA-256"); + + var cnonces = Enumerable.Range(0, 5) + .Select(_ => Fields(handler.Authorize(challenges, Get(), proxy: false)!.Headers.Get("Authorization")!)["cnonce"]) + .ToList(); + + Assert.All(cnonces, c => Assert.Matches("^[0-9a-f]{32}$", c)); + Assert.Equal(5, cnonces.Distinct().Count()); + } + + private static DigestChallengeHandler HandlerFor(DigestCase vector) + { + var preference = vector.Preference?.Select(Enum.Parse).ToList() ?? [.. DigestChallengeHandler.DefaultPreference]; + return new DigestChallengeHandler( + new DigestCredential(vector.Credential.User, vector.Credential.Password), + preference, + vector.Md5Available ?? true, + () => vector.Cnonce); + } + + public sealed class DigestCase + { + public string Name { get; set; } = string.Empty; + + public List Challenges { get; set; } = []; + + public DigestCredentialCase Credential { get; set; } = new(); + + public string Method { get; set; } = "GET"; + + public string Url { get; set; } = string.Empty; + + public string Cnonce { get; set; } = string.Empty; + + public List? Preference { get; set; } + + public bool? Md5Available { get; set; } + + public ExpectedHeader? Expected { get; set; } + + public ExpectedHeader? Second { get; set; } + + public string Source { get; set; } = string.Empty; + } + + public sealed class DigestCredentialCase + { + public string User { get; set; } = string.Empty; + + public string Password { get; set; } = string.Empty; + } + + public sealed class ExpectedHeader + { + public string Authorization { get; set; } = string.Empty; + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Auth/DigestComputationTests.cs b/tests/Dexpace.Sdk.Core.Tests/Auth/DigestComputationTests.cs new file mode 100644 index 0000000..e42fd67 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Auth/DigestComputationTests.cs @@ -0,0 +1,143 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Text; +using Dexpace.Sdk.Core.Auth; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Auth; + +/// AUTH-17, AUTH-20 to AUTH-22: the pure Digest arithmetic and text forms. +[Trait("Category", "Unit")] +public sealed class DigestComputationTests +{ + private static byte[] B(string text) => Encoding.UTF8.GetBytes(text); + + private static string Mufasa(DigestAlgorithm algorithm, string? qop = "auth") => + DigestComputation.Compute( + algorithm, + B("Mufasa"), + B("testrealm@host.com"), + B("Circle Of Life"), + "dcd98b7102dd2f0e8b11d0f600bfb0c093", + "GET", + "/dir/index.html", + qop, + "00000001", + "0a4f113b"); + + [Fact] + public void The_RFC_2617_vectors_reproduce() + { + Assert.Equal("6629fae49393a05397450978507c4ef1", Mufasa(DigestAlgorithm.Md5)); + Assert.Equal("670fd8c2df070c60b045671b8b24ff02", Mufasa(DigestAlgorithm.Md5, qop: null)); + } + + [Fact] + public void The_RFC_7616_inputs_give_the_re_derived_SHA256_values() + { + string Jason(DigestAlgorithm algorithm) => DigestComputation.Compute( + algorithm, + B("Jäsøn Doe"), + B("http-auth@example.org"), + B("Secret, or not?"), + "7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", + "GET", + "/doe.json", + "auth", + "00000001", + "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ"); + + Assert.Equal("9fbf3e2223549127935ba79d47a0299af1f57eae1240ead830c0b47ad60346e1", Jason(DigestAlgorithm.Sha256)); + Assert.Equal("a0316f893cdcbd706441a5392ef9e690688b447acf4015a2b9ce520e6b551a5c", Jason(DigestAlgorithm.Sha256Sess)); + } + + [Fact] + public void HA1_sess_folds_nonce_and_cnonce() + { + var plain = DigestComputation.ComputeHa1(md5: true, session: false, B("u"), B("r"), B("p"), "n", "c"); + var sess = DigestComputation.ComputeHa1(md5: true, session: true, B("u"), B("r"), B("p"), "n", "c"); + + Assert.Equal(DigestComputation.HashHex(true, B("u:r:p")), plain); + Assert.Equal(DigestComputation.HashHex(true, B($"{plain}:n:c")), sess); + } + + [Fact] + public void Hex_is_lower_case() + { + var response = Mufasa(DigestAlgorithm.Md5); + + Assert.Equal(response.ToLowerInvariant(), response); + Assert.Equal(32, response.Length); + Assert.Equal(64, Mufasa(DigestAlgorithm.Sha256).Length); + Assert.Equal("00000a0b", DigestComputation.Nc(0xA0B)); + } + + [Fact] + public void Encoding_is_UTF8_under_charset_UTF8_else_Latin1() + { + Assert.True(DigestComputation.TryEncode("café", utf8: true, out var utf8)); + Assert.True(DigestComputation.TryEncode("café", utf8: false, out var latin1)); + + Assert.Equal([0x63, 0x61, 0x66, 0xC3, 0xA9], utf8); + Assert.Equal([0x63, 0x61, 0x66, 0xE9], latin1); + } + + [Fact] + public void A_Latin1_unrepresentable_credential_is_reported_as_not_encodable_not_hashed_with_question_marks() + { + Assert.False(DigestComputation.TryEncode("é€", utf8: false, out _)); + Assert.True(DigestComputation.TryEncode("é€", utf8: true, out _)); + Assert.False(DigestComputation.TryEncode("\ud800", utf8: true, out _)); + } + + [Fact] + public void Rfc8187_encoding_of_a_username() + { + Assert.Equal("UTF-8''J%C3%A4s%C3%B8n%20Doe", DigestComputation.Rfc8187Encode(B("Jäsøn Doe"), utf8: true)); + Assert.Equal("ISO-8859-1''Jos%E9", DigestComputation.Rfc8187Encode([0x4A, 0x6F, 0x73, 0xE9], utf8: false)); + Assert.Equal("UTF-8''a-b.c_d~e%27f%2A", DigestComputation.Rfc8187Encode(B("a-b.c_d~e'f*"), utf8: true)); + } + + [Fact] + public void Quoted_string_escaping() + { + Assert.Equal("plain", DigestComputation.Quote("plain")); + Assert.Equal("a\\\"b\\\\c", DigestComputation.Quote("a\"b\\c")); + } + + [Theory] + [InlineData("https://h/a%2Fb?x=%26y", "/a%2Fb?x=%26y")] + [InlineData("https://h", "/")] + [InlineData("https://h/", "/")] + [InlineData("https://h?q=1", "/?q=1")] + [InlineData("https://h/p%20q?a=b%20c", "/p%20q?a=b%20c")] + public void Digest_uri_is_the_escaped_PathAndQuery_or_slash(string url, string expected) + { + Assert.Equal(expected, DigestComputation.DigestUri(new Uri(url))); + } + + [Fact] + public void The_cnonce_source_is_RandomNumberGenerator() + { + var source = File.ReadAllText(Path.Combine(SourceRoot(), "Auth", "DigestChallengeHandler.cs")); + + Assert.Contains("RandomNumberGenerator.GetHexString(32, lowercase: true)", source, StringComparison.Ordinal); + Assert.DoesNotContain("Random.Shared", source, StringComparison.Ordinal); + Assert.DoesNotContain("new Random", source, StringComparison.Ordinal); + } + + private static string SourceRoot() + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) + { + var candidate = Path.Combine(directory.FullName, "src", "Dexpace.Sdk.Core"); + if (Directory.Exists(candidate)) + { + return candidate; + } + } + + throw new DirectoryNotFoundException("The src/Dexpace.Sdk.Core tree was not found above the test output."); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Auth/Md5AvailabilityTests.cs b/tests/Dexpace.Sdk.Core.Tests/Auth/Md5AvailabilityTests.cs new file mode 100644 index 0000000..f5424a2 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Auth/Md5AvailabilityTests.cs @@ -0,0 +1,73 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Security.Cryptography; +using Dexpace.Sdk.Core.Auth; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Auth; + +/// AUTH-15, P6c-30: the MD5 probe and the algorithm enum. +[Trait("Category", "Unit")] +public sealed class Md5AvailabilityTests +{ + [Fact] + public void Probe_succeeds_when_the_hash_succeeds() + { + Assert.True(Md5Availability.Probe(() => [1])); + } + + [Fact] + public void Probe_is_false_on_CryptographicException_and_PlatformNotSupportedException() + { + Assert.False(Md5Availability.Probe(() => throw new CryptographicException("FIPS"))); + Assert.False(Md5Availability.Probe(() => throw new PlatformNotSupportedException())); + } + + [Fact] + public void Probe_does_not_swallow_other_exceptions() + { + Assert.Throws(() => Md5Availability.Probe(() => throw new InvalidOperationException("defect"))); + } + + [Fact] + public void IsAvailable_is_computed_once_and_true_on_this_host() + { + Assert.Equal(Md5Availability.IsAvailable, Md5Availability.IsAvailable); + Assert.True(Md5Availability.IsAvailable); + } +} + +/// AUTH-15: the algorithm enum and its wire names. +[Trait("Category", "Unit")] +public sealed class DigestAlgorithmTests +{ + [Fact] + public void Values_are_explicit() + { + Assert.Equal(0, (int)DigestAlgorithm.Md5); + Assert.Equal(1, (int)DigestAlgorithm.Md5Sess); + Assert.Equal(2, (int)DigestAlgorithm.Sha256); + Assert.Equal(3, (int)DigestAlgorithm.Sha256Sess); + Assert.Equal(4, Enum.GetValues().Length); + } + + [Fact] + public void Wire_names_are_MD5_MD5_sess_SHA_256_SHA_256_sess() + { + Assert.Equal("MD5", DigestAlgorithmNames.Wire(DigestAlgorithm.Md5)); + Assert.Equal("MD5-sess", DigestAlgorithmNames.Wire(DigestAlgorithm.Md5Sess)); + Assert.Equal("SHA-256", DigestAlgorithmNames.Wire(DigestAlgorithm.Sha256)); + Assert.Equal("SHA-256-sess", DigestAlgorithmNames.Wire(DigestAlgorithm.Sha256Sess)); + } + + [Fact] + public void Parsing_is_case_insensitive_and_an_absent_token_means_MD5() + { + Assert.True(DigestAlgorithmNames.TryParse("sha-256-SESS", out var sess)); + Assert.Equal(DigestAlgorithm.Sha256Sess, sess); + Assert.True(DigestAlgorithmNames.TryParse(null, out var none)); + Assert.Equal(DigestAlgorithm.Md5, none); + Assert.False(DigestAlgorithmNames.TryParse("SHA-512-256", out _)); + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Auth/NonceCounterTests.cs b/tests/Dexpace.Sdk.Core.Tests/Auth/NonceCounterTests.cs new file mode 100644 index 0000000..bb1a5c4 --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Auth/NonceCounterTests.cs @@ -0,0 +1,102 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using System.Reflection; +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Internal; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Auth; + +/// AUTH-18, AUTH-19, AUTH-24: the per-nonce counter, through the handler. +[Trait("Category", "Unit")] +public sealed class NonceCounterTests +{ + private static DigestChallengeHandler NewHandler() => + new(new DigestCredential("u", "p"), DigestChallengeHandler.DefaultPreference, md5Available: true, () => "cn"); + + private static string Nc(DigestChallengeHandler handler, string nonce) + { + var challenges = AuthenticationChallenge.Parse($"Digest realm=\"r\", nonce=\"{nonce}\", qop=\"auth\""); + var header = handler.Authorize(challenges, Request.Get("https://h/x"), proxy: false)!.Headers.Get("Authorization")!; + return AuthenticationChallenge.Parse(header).Single().Parameters["nc"]; + } + + [Fact] + public void First_use_of_a_nonce_is_nc_00000001() + { + Assert.Equal("00000001", Nc(NewHandler(), "n")); + } + + [Fact] + public void Reuse_of_the_same_nonce_increments() + { + var handler = NewHandler(); + + Assert.Equal("00000001", Nc(handler, "n")); + Assert.Equal("00000002", Nc(handler, "n")); + Assert.Equal("00000003", Nc(handler, "n")); + } + + [Fact] + public void A_different_nonce_starts_at_one() + { + var handler = NewHandler(); + _ = Nc(handler, "a"); + + Assert.Equal("00000001", Nc(handler, "b")); + } + + [Fact] + public void Nc_is_eight_lower_case_hex_digits_of_the_low_32_bits() + { + var handler = NewHandler(); + handler.SetNextCount("n", 0xABCDEF); + Assert.Equal("00abcdef", Nc(handler, "n")); + + handler.SetNextCount("w", uint.MaxValue); + Assert.Equal("ffffffff", Nc(handler, "w")); + Assert.Equal("00000000", Nc(handler, "w")); + } + + [Fact] + public void The_store_is_a_BoundedMap_of_1024() + { + Assert.Equal(1024, NewHandler().NonceStoreCapacity); + Assert.Equal(1024, new BoundedMap(1024).Capacity); + } + + [Fact] + public async Task Sixty_four_parallel_Authorize_calls_on_one_nonce_yield_nc_1_to_64_without_duplicates() + { + var handler = NewHandler(); + + var counts = await Task.WhenAll(Enumerable.Range(0, 64).Select( + _ => Task.Run(() => Convert.ToInt32(Nc(handler, "shared"), 16), TestContext.Current.CancellationToken))); + + Assert.Equal(Enumerable.Range(1, 64), counts.Order()); + } + + [Fact] + public void Handlers_hold_no_state_except_the_counters() + { + var fields = typeof(DigestChallengeHandler).GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) + .Select(f => f.Name) + .Order(StringComparer.Ordinal); + + Assert.Equal(["_cnonceFactory", "_credential", "_md5Available", "_nonces", "_preference"], fields); + } + + [Fact] + public async Task The_composite_and_basic_handlers_are_stateless_too() + { + foreach (var type in new[] { typeof(CompositeChallengeHandler), typeof(BasicChallengeHandler) }) + { + var mutable = type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public).Where(f => !f.IsInitOnly); + Assert.Empty(mutable); + } + + await Task.CompletedTask; + } +} diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ChallengeAuthPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ChallengeAuthPolicyTests.cs index dea6944..eb412f6 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ChallengeAuthPolicyTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/ChallengeAuthPolicyTests.cs @@ -8,6 +8,7 @@ using Dexpace.Sdk.Core.Http.Response; using Dexpace.Sdk.Core.Pipeline; using Dexpace.Sdk.Core.Pipeline.Policies; +using Dexpace.Sdk.Core.Tests.Auth; using Dexpace.Sdk.TestSupport.Transports; using Xunit; @@ -95,6 +96,61 @@ public async Task A_composite_falls_back_from_a_declined_handler() Assert.NotNull(transport.Requests[1].Headers.Get("Authorization")); } + [Fact] + public async Task A_digest_401_is_answered_once_end_to_end() + { + var handler = new DigestChallengeHandler( + new DigestCredential("Mufasa", "Circle Of Life"), + [DigestAlgorithm.Md5], + md5Available: true, + () => "0a4f113b"); + using var transport = new ScriptedTransport( + TestResponses.Unauthorized(ChallengeFixtures.DigestMd5), + TestResponses.Unauthorized(ChallengeFixtures.DigestMd5)); + + using var response = await SendAsync(Pipeline(handler, transport), "https://host/dir/index.html", async: true); + + // The replay is answered once; a second 401 is returned as it is (AUTH-30). + Assert.Equal(401, response.Status.Code); + Assert.Equal(2, transport.CallCount); + Assert.Null(transport.Requests[0].Headers.Get("Authorization")); + var header = transport.Requests[1].Headers.Get("Authorization")!; + Assert.Contains("response=\"6629fae49393a05397450978507c4ef1\"", header, StringComparison.Ordinal); + Assert.Contains("uri=\"/dir/index.html\"", header, StringComparison.Ordinal); + Assert.Contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"", header, StringComparison.Ordinal); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task A_composite_digest_then_basic_falls_back_to_basic_when_digest_declines(bool async) + { + var composite = new CompositeChallengeHandler( + new DigestChallengeHandler(new DigestCredential("alice", "s3cr3t")), + BasicHandler()); + using var transport = new ScriptedTransport(TestResponses.Unauthorized("Basic realm=\"r\""), TestResponses.Create(Status.Ok)); + + using var response = await SendAsync(Pipeline(composite, transport), Url, async); + + Assert.Equal(200, response.Status.Code); + Assert.Equal("Basic YWxpY2U6czNjcjN0", transport.Requests[1].Headers.Get("Authorization")); + } + + [Fact] + public async Task A_composite_prefers_digest_when_the_server_offers_both() + { + var composite = new CompositeChallengeHandler( + new DigestChallengeHandler(new DigestCredential("alice", "s3cr3t")), + BasicHandler()); + using var transport = new ScriptedTransport( + TestResponses.Unauthorized("Basic realm=\"r\"", "Digest realm=\"r\", nonce=\"n\", qop=\"auth\""), + TestResponses.Create(Status.Ok)); + + using var response = await SendAsync(Pipeline(composite, transport), Url, async: true); + + Assert.StartsWith("Digest ", transport.Requests[1].Headers.Get("Authorization"), StringComparison.Ordinal); + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/tests/Dexpace.Sdk.Http.SystemNet.Tests/DigestWireTests.cs b/tests/Dexpace.Sdk.Http.SystemNet.Tests/DigestWireTests.cs new file mode 100644 index 0000000..a528983 --- /dev/null +++ b/tests/Dexpace.Sdk.Http.SystemNet.Tests/DigestWireTests.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Http.SystemNet.Tests.Loopback; +using Xunit; +using SystemHttpClient = System.Net.Http.HttpClient; + +namespace Dexpace.Sdk.Http.SystemNet.Tests; + +/// +/// AUTH-16, AUTH-22, AUTH-30 at the wire (design fact 9, P6c-34): the Digest uri parameter equals the request-target +/// the transport writes, for an escaped path, an escaped query and an empty path. +/// +/// +/// The loopback server speaks plain http and the auth guard has no loopback exemption (design §11 item 25), so the test +/// does not go through an auth policy: it answers a parsed challenge with the handler and sends the stamped request straight +/// through the transport. +/// +[Trait("Category", "Integration")] +public sealed class DigestWireTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static SystemHttpClient DirectClient() => + new(new SocketsHttpHandler { UseProxy = false, AllowAutoRedirect = false }); + + [Theory] + [InlineData("a%2Fb?x=%26y")] + [InlineData("p%20q/r?a=b%20c")] + [InlineData("")] + [InlineData("?q=1")] + public async Task The_uri_parameter_equals_the_request_target_the_server_received(string pathAndQuery) + { + await using var server = LoopbackServer.Start( + LoopbackResponse.Status(401, "Unauthorized", [new("WWW-Authenticate", "Digest realm=\"r\", nonce=\"n\", qop=\"auth\", algorithm=SHA-256")]), + LoopbackResponse.Ok("ok")); + using var client = DirectClient(); + await using var transport = new SystemNetHttpClient(client); + var handler = new DigestChallengeHandler(new DigestCredential("u", "p")); + var url = server.BaseUri.GetLeftPart(UriPartial.Authority) + (pathAndQuery.StartsWith('?') ? string.Empty : "/") + pathAndQuery; + var request = Request.Get(url); + + await using var challenge = await transport.ExecuteAsync(request, Ct); + var challenges = challenge.Headers.GetAll("WWW-Authenticate").SelectMany(AuthenticationChallenge.Parse).ToList(); + var answered = handler.Authorize(challenges, request, proxy: false); + Assert.NotNull(answered); + await using var second = await transport.ExecuteAsync(answered, Ct); + + Assert.Equal(2, server.Requests.Count); + var recorded = server.Requests[1]; + var authorization = Assert.Single(recorded.HeaderLines, line => line.StartsWith("Authorization:", StringComparison.OrdinalIgnoreCase)); + var uri = AuthenticationChallenge.Parse(authorization["Authorization:".Length..].Trim()).Single().Parameters["uri"]; + Assert.Equal(recorded.Target, uri); + Assert.Equal(200, second.Status.Code); + } +} diff --git a/tests/vectors/auth/digest.json b/tests/vectors/auth/digest.json new file mode 100644 index 0000000..761b8bc --- /dev/null +++ b/tests/vectors/auth/digest.json @@ -0,0 +1,657 @@ +{ + "source": "RFC 2617 s3.5, RFC 7616 s3.9.1; ruby-sdk digest_handler_test.rb; nodejs-sdk@54aeed4 digest.test.ts", + "note": "Each case builds a DigestChallengeHandler (internal constructor: fixed cnonce, md5Available from the case, preference from the case or the default), parses every string in `challenges` with AuthenticationChallenge.Parse, and calls Authorize(method, url). `expected` is null when the handler must decline. The expected Authorization values were generated by an independent Python reference implementation (hashlib), not by the C# code. `second` is the result of a second call with the same challenges (the nonce counter). Wire order of the parameters is this port's own.", + "cases": [ + { + "name": "rfc2617_mufasa_md5_qop_auth", + "challenges": [ + "Digest realm=\"testrealm@host.com\", qop=\"auth,auth-int\", nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"" + ], + "credential": { + "user": "Mufasa", + "password": "Circle Of Life" + }, + "method": "GET", + "url": "https://host/dir/index.html", + "cnonce": "0a4f113b", + "expected": { + "authorization": "Digest username=\"Mufasa\", realm=\"testrealm@host.com\", nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", uri=\"/dir/index.html\", algorithm=MD5, qop=auth, nc=00000001, cnonce=\"0a4f113b\", response=\"6629fae49393a05397450978507c4ef1\", opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"" + }, + "source": "RFC 2617 s3.5; ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb 'RFC 2617 s3.5 MD5 qop=auth vector'" + }, + { + "name": "legacy_no_qop_md5", + "challenges": [ + "Digest realm=\"testrealm@host.com\", nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"" + ], + "credential": { + "user": "Mufasa", + "password": "Circle Of Life" + }, + "method": "GET", + "url": "https://host/dir/index.html", + "cnonce": "0a4f113b", + "expected": { + "authorization": "Digest username=\"Mufasa\", realm=\"testrealm@host.com\", nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", uri=\"/dir/index.html\", algorithm=MD5, response=\"670fd8c2df070c60b045671b8b24ff02\", opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"" + }, + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb 'the legacy RFC 2069 no-qop branch'" + }, + { + "name": "rfc7616_sha256_qop_auth", + "challenges": [ + "Digest realm=\"http-auth@example.org\", qop=\"auth\", algorithm=SHA-256, nonce=\"7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v\", opaque=\"FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS\", charset=UTF-8, userhash=false" + ], + "credential": { + "user": "Jäsøn Doe", + "password": "Secret, or not?" + }, + "method": "GET", + "url": "https://host/doe.json", + "cnonce": "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ", + "expected": { + "authorization": "Digest username*=UTF-8''J%C3%A4s%C3%B8n%20Doe, realm=\"http-auth@example.org\", nonce=\"7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v\", uri=\"/doe.json\", algorithm=SHA-256, qop=auth, nc=00000001, cnonce=\"f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ\", response=\"9fbf3e2223549127935ba79d47a0299af1f57eae1240ead830c0b47ad60346e1\", opaque=\"FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS\"" + }, + "source": "RFC 7616 s3.9.1; ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb", + "note": "The RFC prints a 63-digit response for this input, an erratum; the value here is re-derived (design fact 13). The username is not ASCII, so it goes out as username* (RFC 8187, P6c-33)." + }, + { + "name": "rfc7616_sha256_sess_qop_auth", + "challenges": [ + "Digest realm=\"http-auth@example.org\", qop=\"auth\", algorithm=SHA-256-sess, nonce=\"7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v\", opaque=\"FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS\", charset=UTF-8, userhash=false" + ], + "credential": { + "user": "Jäsøn Doe", + "password": "Secret, or not?" + }, + "method": "GET", + "url": "https://host/doe.json", + "cnonce": "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ", + "expected": { + "authorization": "Digest username*=UTF-8''J%C3%A4s%C3%B8n%20Doe, realm=\"http-auth@example.org\", nonce=\"7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v\", uri=\"/doe.json\", algorithm=SHA-256-sess, qop=auth, nc=00000001, cnonce=\"f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ\", response=\"a0316f893cdcbd706441a5392ef9e690688b447acf4015a2b9ce520e6b551a5c\", opaque=\"FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS\"" + }, + "source": "RFC 7616 s3.9.1; ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb", + "note": "Re-derived value (design fact 13)." + }, + { + "name": "md5_sess_qop_auth", + "challenges": [ + "Digest realm=\"testrealm@host.com\", qop=\"auth\", algorithm=MD5-sess, nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\"" + ], + "credential": { + "user": "Mufasa", + "password": "Circle Of Life" + }, + "method": "GET", + "url": "https://host/dir/index.html", + "cnonce": "0a4f113b", + "expected": { + "authorization": "Digest username=\"Mufasa\", realm=\"testrealm@host.com\", nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", uri=\"/dir/index.html\", algorithm=MD5-sess, qop=auth, nc=00000001, cnonce=\"0a4f113b\", response=\"8e3825c57e897f5a0dec6c2d4e5059d0\"" + }, + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb 'MD5-sess follows the same session rule'" + }, + { + "name": "sha256_default_preference_chosen_over_md5", + "challenges": [ + "Digest realm=\"r\", nonce=\"n-md5\", qop=\"auth\", algorithm=MD5", + "Digest realm=\"r\", nonce=\"n-sha\", qop=\"auth\", algorithm=SHA-256" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "cn", + "expected": { + "authorization": "Digest username=\"u\", realm=\"r\", nonce=\"n-sha\", uri=\"/x\", algorithm=SHA-256, qop=auth, nc=00000001, cnonce=\"cn\", response=\"6dc87ebc3cf77cb7c52c82c99078659e5c4c4f2bf79927a57e5dce81064e1364\"" + }, + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb selection; RFC 7616 s3.7", + "note": "P6c-29: the default preference is SHA-256 first; Ruby's default is MD5 first." + }, + { + "name": "md5_preferred_when_preference_says_so", + "challenges": [ + "Digest realm=\"r\", nonce=\"n-md5\", qop=\"auth\", algorithm=MD5", + "Digest realm=\"r\", nonce=\"n-sha\", qop=\"auth\", algorithm=SHA-256" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "cn", + "preference": [ + "Md5", + "Sha256" + ], + "expected": { + "authorization": "Digest username=\"u\", realm=\"r\", nonce=\"n-md5\", uri=\"/x\", algorithm=MD5, qop=auth, nc=00000001, cnonce=\"cn\", response=\"bdfba515ce32743c8673eea0396f306a\"" + }, + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb selection" + }, + { + "name": "preference_not_wire_order_picks_sha256_sess", + "challenges": [ + "Digest realm=\"r\", nonce=\"n-md5\", qop=\"auth\", algorithm=MD5", + "Digest realm=\"r\", nonce=\"n-sess\", qop=\"auth\", algorithm=SHA-256-sess" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "cn", + "expected": { + "authorization": "Digest username=\"u\", realm=\"r\", nonce=\"n-sess\", uri=\"/x\", algorithm=SHA-256-sess, qop=auth, nc=00000001, cnonce=\"cn\", response=\"efb217d89e85ed0785ac3081b3b99ed94d97e55a256bcb2d389919de25b14604\"" + }, + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb 'independent of the order challenges arrived in'" + }, + { + "name": "unsupported_algorithm_only", + "challenges": [ + "Digest realm=\"r\", nonce=\"n\", qop=\"auth\", algorithm=SHA-512-256" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "cn", + "expected": null, + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb AUTH-16" + }, + { + "name": "auth_int_only_declined", + "challenges": [ + "Digest realm=\"r\", nonce=\"n\", qop=\"auth-int\"" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "cn", + "expected": null, + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb 'token-exact qop'" + }, + { + "name": "qop_list_auth_int_comma_auth", + "challenges": [ + "Digest realm=\"r\", nonce=\"n\", qop=\"auth-int,auth\"" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "cn", + "expected": { + "authorization": "Digest username=\"u\", realm=\"r\", nonce=\"n\", uri=\"/x\", algorithm=MD5, qop=auth, nc=00000001, cnonce=\"cn\", response=\"1c729cad1930723970960c3c5078487c\"" + }, + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb 'token-exact qop'" + }, + { + "name": "missing_realm_declined", + "challenges": [ + "Digest nonce=\"n\", qop=\"auth\"" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "cn", + "expected": null, + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb AUTH-16" + }, + { + "name": "missing_nonce_declined", + "challenges": [ + "Digest realm=\"r\", qop=\"auth\"" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "cn", + "expected": null, + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb AUTH-16" + }, + { + "name": "non_digest_scheme_declined", + "challenges": [ + "Basic realm=\"r\"" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "cn", + "expected": null, + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb AUTH-16" + }, + { + "name": "sess_without_qop_declined", + "challenges": [ + "Digest realm=\"r\", nonce=\"n\", algorithm=SHA-256-sess" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "cn", + "expected": null, + "source": "AUTH-16/17/22 contradiction", + "note": "P6c-31: a -sess response needs the cnonce in HA1 but AUTH-22 forbids sending it without qop, so the server could not verify it; declined. Node sends the cnonce anyway; Ruby follows AUTH-22 literally." + }, + { + "name": "opaque_absent_is_not_sent", + "challenges": [ + "Digest realm=\"r\", nonce=\"n\", qop=\"auth\"" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "cn", + "expected": { + "authorization": "Digest username=\"u\", realm=\"r\", nonce=\"n\", uri=\"/x\", algorithm=MD5, qop=auth, nc=00000001, cnonce=\"cn\", response=\"1c729cad1930723970960c3c5078487c\"" + }, + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb AUTH-22" + }, + { + "name": "quoting_escapes_quote_and_backslash", + "challenges": [ + "Digest realm=\"a\\\"b\\\\c\", nonce=\"n\", qop=\"auth\", opaque=\"o\\\"p\"" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "cn", + "expected": { + "authorization": "Digest username=\"u\", realm=\"a\\\"b\\\\c\", nonce=\"n\", uri=\"/x\", algorithm=MD5, qop=auth, nc=00000001, cnonce=\"cn\", response=\"ddd577bd4eee7e6f7b2b45681630762c\", opaque=\"o\\\"p\"" + }, + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb AUTH-22 quoting" + }, + { + "name": "utf8_charset_non_ascii_password", + "challenges": [ + "Digest realm=\"r\", nonce=\"n\", qop=\"auth\", charset=UTF-8" + ], + "credential": { + "user": "u", + "password": "päss日" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "cn", + "expected": { + "authorization": "Digest username=\"u\", realm=\"r\", nonce=\"n\", uri=\"/x\", algorithm=MD5, qop=auth, nc=00000001, cnonce=\"cn\", response=\"fb227fa1add30f64c4ce2fa09ec21972\"" + }, + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb 'charset=UTF-8 hashes the UTF-8 bytes'" + }, + { + "name": "utf8_charset_in_any_case", + "challenges": [ + "Digest realm=\"r\", nonce=\"n\", qop=\"auth\", charset=utf-8" + ], + "credential": { + "user": "u", + "password": "päss" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "cn", + "expected": { + "authorization": "Digest username=\"u\", realm=\"r\", nonce=\"n\", uri=\"/x\", algorithm=MD5, qop=auth, nc=00000001, cnonce=\"cn\", response=\"e0f88b3bd00c5c806478f6dc40830c3d\"" + }, + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb 'in any case'" + }, + { + "name": "latin1_default_charset_latin1_password", + "challenges": [ + "Digest realm=\"r\", nonce=\"n\", qop=\"auth\"" + ], + "credential": { + "user": "a", + "password": "café" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "c", + "expected": { + "authorization": "Digest username=\"a\", realm=\"r\", nonce=\"n\", uri=\"/x\", algorithm=MD5, qop=auth, nc=00000001, cnonce=\"c\", response=\"d3ed4295ad5597948599de2d118c4096\"" + }, + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb 'no charset hashes ISO-8859-1 bytes'" + }, + { + "name": "latin1_credential_not_representable_declined", + "challenges": [ + "Digest realm=\"r\", nonce=\"n\", qop=\"auth\"" + ], + "credential": { + "user": "a", + "password": "日本" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "c", + "expected": null, + "source": "design fact 6", + "note": "P6c-32: declined rather than hashed with '?' substituted; Ruby raises a typed error instead." + }, + { + "name": "latin1_unrepresentable_falls_to_a_utf8_challenge", + "challenges": [ + "Digest realm=\"r\", nonce=\"n1\", qop=\"auth\", algorithm=SHA-256", + "Digest realm=\"r\", nonce=\"n2\", qop=\"auth\", algorithm=MD5, charset=UTF-8" + ], + "credential": { + "user": "a", + "password": "日本" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "c", + "expected": { + "authorization": "Digest username=\"a\", realm=\"r\", nonce=\"n2\", uri=\"/x\", algorithm=MD5, qop=auth, nc=00000001, cnonce=\"c\", response=\"577622329e5920722f2eeafe243db926\"" + }, + "source": "P6c-32", + "note": "The preferred SHA-256 challenge is Latin-1 and cannot carry the password; the UTF-8 MD5 challenge can, so it is answered." + }, + { + "name": "non_ascii_username_goes_out_as_username_star_utf8", + "challenges": [ + "Digest realm=\"r\", nonce=\"n\", qop=\"auth\", charset=UTF-8" + ], + "credential": { + "user": "Jäsøn Doe", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "c", + "expected": { + "authorization": "Digest username*=UTF-8''J%C3%A4s%C3%B8n%20Doe, realm=\"r\", nonce=\"n\", uri=\"/x\", algorithm=MD5, qop=auth, nc=00000001, cnonce=\"c\", response=\"5fbca1c01cf6bb59e2f0c194ced5bf17\"" + }, + "source": "P6c-33" + }, + { + "name": "non_ascii_username_goes_out_as_username_star_latin1", + "challenges": [ + "Digest realm=\"r\", nonce=\"n\", qop=\"auth\"" + ], + "credential": { + "user": "José", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "c", + "expected": { + "authorization": "Digest username*=ISO-8859-1''Jos%E9, realm=\"r\", nonce=\"n\", uri=\"/x\", algorithm=MD5, qop=auth, nc=00000001, cnonce=\"c\", response=\"9d85a2b2cd29cb75d060894540050731\"" + }, + "source": "P6c-33" + }, + { + "name": "nonce_not_echoable_declined", + "challenges": [ + "Digest realm=\"r\", nonce=\"café\", qop=\"auth\"" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "c", + "expected": null, + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb 'echoable'", + "note": "XCUT-18: the outbound grammar refuses non-ASCII header text; RFC 7616 defines no encoded form for a nonce." + }, + { + "name": "realm_not_echoable_declined", + "challenges": [ + "Digest realm=\"café\", nonce=\"n\", qop=\"auth\"" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "c", + "expected": null, + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb 'echoable'" + }, + { + "name": "digest_uri_is_escaped_request_target", + "challenges": [ + "Digest realm=\"r\", nonce=\"n\", qop=\"auth\"" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/a%2Fb?x=%26y", + "cnonce": "c", + "expected": { + "authorization": "Digest username=\"u\", realm=\"r\", nonce=\"n\", uri=\"/a%2Fb?x=%26y\", algorithm=MD5, qop=auth, nc=00000001, cnonce=\"c\", response=\"0071d20c94fd476782a9d75a8c6411ae\"" + }, + "source": "design fact 9 (verified on the wire, task 0.1)" + }, + { + "name": "empty_path_digest_uri_is_slash", + "challenges": [ + "Digest realm=\"r\", nonce=\"n\", qop=\"auth\"" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host", + "cnonce": "c", + "expected": { + "authorization": "Digest username=\"u\", realm=\"r\", nonce=\"n\", uri=\"/\", algorithm=MD5, qop=auth, nc=00000001, cnonce=\"c\", response=\"490ab2d682267b3630d3509d981b982d\"" + }, + "source": "design fact 9" + }, + { + "name": "empty_path_with_query_digest_uri", + "challenges": [ + "Digest realm=\"r\", nonce=\"n\", qop=\"auth\"" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host?q=1", + "cnonce": "c", + "expected": { + "authorization": "Digest username=\"u\", realm=\"r\", nonce=\"n\", uri=\"/?q=1\", algorithm=MD5, qop=auth, nc=00000001, cnonce=\"c\", response=\"7f56b97a71775a0fc64fc97de53e89f2\"" + }, + "source": "design fact 9" + }, + { + "name": "fips_md5_unavailable_falls_through_to_sha256", + "challenges": [ + "Digest realm=\"r\", nonce=\"n-md5\", qop=\"auth\", algorithm=MD5", + "Digest realm=\"r\", nonce=\"n-sha\", qop=\"auth\", algorithm=SHA-256" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "c", + "preference": [ + "Md5", + "Sha256" + ], + "md5Available": false, + "expected": { + "authorization": "Digest username=\"u\", realm=\"r\", nonce=\"n-sha\", uri=\"/x\", algorithm=SHA-256, qop=auth, nc=00000001, cnonce=\"c\", response=\"65f2e7fd3f28cd85fd85a6ec807ea222d059c19a68b51e44223c57aa3da66d78\"" + }, + "source": "P6c-30" + }, + { + "name": "fips_md5_only_challenge_declined", + "challenges": [ + "Digest realm=\"r\", nonce=\"n\", qop=\"auth\", algorithm=MD5" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "c", + "md5Available": false, + "expected": null, + "source": "P6c-30" + }, + { + "name": "fips_md5_sess_is_dropped_too", + "challenges": [ + "Digest realm=\"r\", nonce=\"n\", qop=\"auth\", algorithm=MD5-sess" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "c", + "md5Available": false, + "expected": null, + "source": "P6c-30" + }, + { + "name": "algorithm_case_insensitive_in_challenge_spelled_in_full_in_response", + "challenges": [ + "Digest realm=\"r\", nonce=\"n\", qop=\"auth\", algorithm=sha-256-SESS" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "c", + "expected": { + "authorization": "Digest username=\"u\", realm=\"r\", nonce=\"n\", uri=\"/x\", algorithm=SHA-256-sess, qop=auth, nc=00000001, cnonce=\"c\", response=\"203435f0e3f59800bcc823a3cc777e26d10adbdb6e4ebef111fe48f2fdcdc0cf\"" + }, + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb AUTH-22" + }, + { + "name": "userhash_never_sent", + "challenges": [ + "Digest realm=\"r\", nonce=\"n\", qop=\"auth\", userhash=true" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "c", + "expected": { + "authorization": "Digest username=\"u\", realm=\"r\", nonce=\"n\", uri=\"/x\", algorithm=MD5, qop=auth, nc=00000001, cnonce=\"c\", response=\"3d31a5d3d39bc1ba61a81bc46e9f4853\"" + }, + "source": "RFC 7616 s3.4.4" + }, + { + "name": "nc_increments_on_nonce_reuse", + "challenges": [ + "Digest realm=\"r\", nonce=\"same\", qop=\"auth\"" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "c", + "expected": { + "authorization": "Digest username=\"u\", realm=\"r\", nonce=\"same\", uri=\"/x\", algorithm=MD5, qop=auth, nc=00000001, cnonce=\"c\", response=\"c3b494fb66b57b19ce7365bea4ac6305\"" + }, + "second": { + "authorization": "Digest username=\"u\", realm=\"r\", nonce=\"same\", uri=\"/x\", algorithm=MD5, qop=auth, nc=00000002, cnonce=\"c\", response=\"bd1fff2fdc5c5beae41f6ebab71fc3d8\"" + }, + "source": "ruby-sdk gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb AUTH-18" + }, + { + "name": "post_method_enters_the_hash", + "challenges": [ + "Digest realm=\"r\", nonce=\"n\", qop=\"auth\"" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "POST", + "url": "https://host/x", + "cnonce": "c", + "expected": { + "authorization": "Digest username=\"u\", realm=\"r\", nonce=\"n\", uri=\"/x\", algorithm=MD5, qop=auth, nc=00000001, cnonce=\"c\", response=\"5fa004f487039d8a57b97d87e96c09cd\"" + }, + "source": "AUTH-17" + }, + { + "name": "two_digest_challenges_same_algorithm_first_wins", + "challenges": [ + "Digest realm=\"r\", nonce=\"first\", qop=\"auth\"", + "Digest realm=\"r\", nonce=\"second\", qop=\"auth\"" + ], + "credential": { + "user": "u", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "c", + "expected": { + "authorization": "Digest username=\"u\", realm=\"r\", nonce=\"first\", uri=\"/x\", algorithm=MD5, qop=auth, nc=00000001, cnonce=\"c\", response=\"c46479f4dbefdb697edd0dba60787f8d\"" + }, + "source": "AUTH-16" + }, + { + "name": "quote_in_username_is_escaped", + "challenges": [ + "Digest realm=\"r\", nonce=\"n\", qop=\"auth\"" + ], + "credential": { + "user": "a\"b", + "password": "p" + }, + "method": "GET", + "url": "https://host/x", + "cnonce": "c", + "expected": { + "authorization": "Digest username=\"a\\\"b\", realm=\"r\", nonce=\"n\", uri=\"/x\", algorithm=MD5, qop=auth, nc=00000001, cnonce=\"c\", response=\"aa987f03e7eb54c6f41fa577b8f43e8b\"" + }, + "source": "AUTH-22 quoting" + } + ] +} \ No newline at end of file From 167a3781c0a5dce018cf52edbe72d972488e8f7e Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 8 Oct 2026 20:53:36 +0300 Subject: [PATCH 07/13] chore: share the per-scheme stamping logic between the auth policies (phase 6c) --- .../Pipeline/Policies/ApiKeyAuthPolicy.cs | 6 +- .../Pipeline/Policies/BasicAuthPolicy.cs | 6 +- .../Policies/BearerTokenAuthPolicy.cs | 76 +++------------ .../Pipeline/Policies/CredentialStampers.cs | 95 +++++++++++++++++++ 4 files changed, 116 insertions(+), 67 deletions(-) create mode 100644 src/Dexpace.Sdk.Core/Pipeline/Policies/CredentialStampers.cs diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/ApiKeyAuthPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/ApiKeyAuthPolicy.cs index 6135a9d..6784cfa 100644 --- a/src/Dexpace.Sdk.Core/Pipeline/Policies/ApiKeyAuthPolicy.cs +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/ApiKeyAuthPolicy.cs @@ -34,7 +34,7 @@ namespace Dexpace.Sdk.Core.Pipeline.Policies; /// public sealed class ApiKeyAuthPolicy : AuthorizationPolicy { - private readonly ApiKeyCredential _credential; + private readonly ApiKeyStamper _stamper; private readonly HttpHeaderName[] _withheld; /// @@ -46,7 +46,7 @@ public ApiKeyAuthPolicy(ApiKeyCredential credential) : base(new AuthDescriptor(new AuthRequirement(AuthScheme.ApiKey)), [AuthScheme.ApiKey]) { ArgumentNullException.ThrowIfNull(credential); - _credential = credential; + _stamper = new ApiKeyStamper(credential); _withheld = [credential.HeaderName]; } @@ -65,5 +65,5 @@ protected override (string HeaderName, string HeaderValue)? GetCredential( AuthRequirement requirement, Request request, PipelineContext context) => - (_credential.HeaderName.Original, _credential.HeaderValue); + _stamper.Stamp(); } diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/BasicAuthPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/BasicAuthPolicy.cs index f2099b7..05b8de1 100644 --- a/src/Dexpace.Sdk.Core/Pipeline/Policies/BasicAuthPolicy.cs +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/BasicAuthPolicy.cs @@ -24,7 +24,7 @@ namespace Dexpace.Sdk.Core.Pipeline.Policies; public sealed class BasicAuthPolicy : AuthorizationPolicy { // The header value is computed once by the credential and shared with BasicChallengeHandler (AUTH-14). - private readonly string _headerValue; + private readonly BasicStamper _stamper; /// /// Initializes a with the given credential. @@ -35,7 +35,7 @@ public BasicAuthPolicy(BasicCredential credential) : base(new AuthDescriptor(new AuthRequirement(AuthScheme.Basic)), [AuthScheme.Basic]) { ArgumentNullException.ThrowIfNull(credential); - _headerValue = credential.HeaderValue; + _stamper = new BasicStamper(credential); } /// @@ -53,5 +53,5 @@ protected override (string HeaderName, string HeaderValue)? GetCredential( AuthRequirement requirement, Request request, PipelineContext context) => - (HttpHeaderName.WellKnown.Authorization.Original, _headerValue); + _stamper.Stamp(); } diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/BearerTokenAuthPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/BearerTokenAuthPolicy.cs index 7664bfe..b91c212 100644 --- a/src/Dexpace.Sdk.Core/Pipeline/Policies/BearerTokenAuthPolicy.cs +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/BearerTokenAuthPolicy.cs @@ -43,8 +43,7 @@ namespace Dexpace.Sdk.Core.Pipeline.Policies; /// public sealed class BearerTokenAuthPolicy : AuthorizationPolicy { - private readonly AccessTokenCache _cache; - private readonly string[] _scopes; + private readonly BearerStamper _stamper; /// /// Initializes a with the given credential and scopes. @@ -77,85 +76,38 @@ public BearerTokenAuthPolicy(AccessTokenCache cache, params string[] scopes) : base(ClientDescriptor(scopes), [AuthScheme.OAuth2]) { ArgumentNullException.ThrowIfNull(cache); - _cache = cache; - _scopes = [.. scopes]; + _stamper = new BearerStamper(cache, [.. scopes]); } /// protected override IReadOnlyList WithheldHeaderNames { get; } = [HttpHeaderName.WellKnown.Authorization]; /// - protected override async ValueTask<(string HeaderName, string HeaderValue)?> GetCredentialAsync( + protected override ValueTask<(string HeaderName, string HeaderValue)?> GetCredentialAsync( AuthRequirement requirement, Request request, - PipelineContext context) - { - var token = await _cache - .GetAsync(TokenContextFor(requirement), context.State.Logger, context.CancellationToken) - .ConfigureAwait(false); - - return (HttpHeaderName.WellKnown.Authorization.Original, Bearer(token)); - } + PipelineContext context) => + _stamper.StampAsync(requirement, context); /// protected override (string HeaderName, string HeaderValue)? GetCredential( AuthRequirement requirement, Request request, - PipelineContext context) - { - var token = _cache.Get(TokenContextFor(requirement), context.State.Logger, context.CancellationToken); - return (HttpHeaderName.WellKnown.Authorization.Original, Bearer(token)); - } + PipelineContext context) => + _stamper.Stamp(requirement, context); /// - protected override async ValueTask OnChallengeAsync(AuthChallengeContext challenge) + protected override ValueTask OnChallengeAsync(AuthChallengeContext challenge) { ArgumentNullException.ThrowIfNull(challenge); - if (RejectedHeader(challenge) is not { } rejected) - { - return null; - } - - var token = await _cache - .GetAfterRejectionAsync(TokenContextFor(challenge.Requirement), rejected, challenge.Context.CancellationToken) - .ConfigureAwait(false); - return Replacement(challenge.Request, rejected, token); + return _stamper.OnChallengeAsync(challenge); } /// protected override Request? OnChallenge(AuthChallengeContext challenge) { ArgumentNullException.ThrowIfNull(challenge); - if (RejectedHeader(challenge) is not { } rejected) - { - return null; - } - - var token = _cache.GetAfterRejection(TokenContextFor(challenge.Requirement), rejected, challenge.Context.CancellationToken); - return Replacement(challenge.Request, rejected, token); - } - - private static AccessTokenCache CacheOver(TokenCredential credential) - { - ArgumentNullException.ThrowIfNull(credential); - return new AccessTokenCache(credential); - } - - private static string Bearer(AccessToken token) => "Bearer " + token.Token; - - // Only a Bearer challenge to a request that carried an Authorization header is answered (AUTH-36). - private static string? RejectedHeader(AuthChallengeContext challenge) => - challenge.Challenges.Any(static c => string.Equals(c.Scheme, "bearer", StringComparison.Ordinal)) - ? challenge.Request.Headers.Get(HttpHeaderName.WellKnown.Authorization) - : null; - - // A provider that hands back the rejected token yields no retry (P6c-27): the 401 surfaces. - private static Request? Replacement(Request sent, string rejected, AccessToken token) - { - var value = Bearer(token); - return string.Equals(value, rejected, StringComparison.Ordinal) - ? null - : sent.WithHeaders(sent.Headers.Set(HttpHeaderName.WellKnown.Authorization, value)); + return _stamper.OnChallenge(challenge); } private static AuthDescriptor ClientDescriptor(string[] scopes) @@ -164,7 +116,9 @@ private static AuthDescriptor ClientDescriptor(string[] scopes) return new AuthDescriptor(new AuthRequirement(AuthScheme.OAuth2) { Scopes = scopes }); } - // The resolved requirement's scopes win (a per-call tier may ask for others); the policy's own are the fallback. - private TokenRequestContext TokenContextFor(AuthRequirement requirement) => - new(requirement.Scopes.Count > 0 ? requirement.Scopes : _scopes); + private static AccessTokenCache CacheOver(TokenCredential credential) + { + ArgumentNullException.ThrowIfNull(credential); + return new AccessTokenCache(credential); + } } diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/CredentialStampers.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/CredentialStampers.cs new file mode 100644 index 0000000..79e8e1d --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/CredentialStampers.cs @@ -0,0 +1,95 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; + +namespace Dexpace.Sdk.Core.Pipeline.Policies; + +// The per-scheme logic shared by the single-scheme policies and MultiSchemeAuthPolicy (P6c-36), so none of it is written +// twice. Each stamper is immutable after construction; the bearer stamper's cache is the only state. + +/// Stamps an API key in its own header (AUTH-26). +internal sealed class ApiKeyStamper(ApiKeyCredential credential) +{ + internal HttpHeaderName HeaderName => credential.HeaderName; + + internal (string HeaderName, string HeaderValue) Stamp() => (credential.HeaderName.Original, credential.HeaderValue); +} + +/// Stamps preemptive Basic (AUTH-14). +internal sealed class BasicStamper(BasicCredential credential) +{ + internal (string HeaderName, string HeaderValue) Stamp() => (HttpHeaderName.WellKnown.Authorization.Original, credential.HeaderValue); +} + +/// Answers a Digest challenge reactively; nothing is stamped on the first pass. +internal sealed class DigestStamper(IChallengeHandler handler) +{ + internal Request? OnChallenge(AuthChallengeContext challenge) => + handler.Authorize(challenge.Challenges, challenge.Request, proxy: false); +} + +/// Stamps a bearer token from the cache and re-fetches once on a Bearer challenge (AUTH-34 to AUTH-37). +internal sealed class BearerStamper(AccessTokenCache cache, string[] fallbackScopes) +{ + // The resolved requirement's scopes win (a per-call tier may ask for others); the policy's own are the fallback. + internal TokenRequestContext ContextFor(AuthRequirement requirement) => + new(requirement.Scopes.Count > 0 ? requirement.Scopes : fallbackScopes); + + internal async ValueTask<(string HeaderName, string HeaderValue)?> StampAsync(AuthRequirement requirement, PipelineContext context) + { + var token = await cache + .GetAsync(ContextFor(requirement), context.State.Logger, context.CancellationToken) + .ConfigureAwait(false); + return (HttpHeaderName.WellKnown.Authorization.Original, Bearer(token)); + } + + internal (string HeaderName, string HeaderValue)? Stamp(AuthRequirement requirement, PipelineContext context) + { + var token = cache.Get(ContextFor(requirement), context.State.Logger, context.CancellationToken); + return (HttpHeaderName.WellKnown.Authorization.Original, Bearer(token)); + } + + internal async ValueTask OnChallengeAsync(AuthChallengeContext challenge) + { + if (RejectedHeader(challenge) is not { } rejected) + { + return null; + } + + var token = await cache + .GetAfterRejectionAsync(ContextFor(challenge.Requirement), rejected, challenge.Context.CancellationToken) + .ConfigureAwait(false); + return Replacement(challenge.Request, rejected, token); + } + + internal Request? OnChallenge(AuthChallengeContext challenge) + { + if (RejectedHeader(challenge) is not { } rejected) + { + return null; + } + + var token = cache.GetAfterRejection(ContextFor(challenge.Requirement), rejected, challenge.Context.CancellationToken); + return Replacement(challenge.Request, rejected, token); + } + + private static string Bearer(AccessToken token) => "Bearer " + token.Token; + + // Only a Bearer challenge to a request that carried an Authorization header is answered (AUTH-36). + private static string? RejectedHeader(AuthChallengeContext challenge) => + challenge.Challenges.Any(static c => string.Equals(c.Scheme, "bearer", StringComparison.Ordinal)) + ? challenge.Request.Headers.Get(HttpHeaderName.WellKnown.Authorization) + : null; + + // A provider that hands back the rejected token yields no retry (P6c-27): the 401 surfaces. + private static Request? Replacement(Request sent, string rejected, AccessToken token) + { + var value = Bearer(token); + return string.Equals(value, rejected, StringComparison.Ordinal) + ? null + : sent.WithHeaders(sent.Headers.Set(HttpHeaderName.WellKnown.Authorization, value)); + } +} From e1b2b684913807eb6022085b3149923294cc21e0 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 8 Oct 2026 20:56:24 +0300 Subject: [PATCH 08/13] feat: phase 6c multi-scheme authentication policy --- CHANGELOG.md | 3 + .../Policies/MultiSchemeAuthPolicy.cs | 140 ++++++++++ src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt | 2 + .../Policies/MultiSchemeAuthPolicyTests.cs | 256 ++++++++++++++++++ 4 files changed, 401 insertions(+) create mode 100644 src/Dexpace.Sdk.Core/Pipeline/Policies/MultiSchemeAuthPolicy.cs create mode 100644 tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/MultiSchemeAuthPolicyTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2565f8e..2e246f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -536,5 +536,8 @@ Roadmap phase 1, defects S1–S9, each pinned by a `[Trait("Category", "Security per-nonce counter bounded to 1024 entries) and `DigestAlgorithm`. A host whose crypto provider refuses MD5 (FIPS) drops the MD5 algorithms instead of failing, so an MD5-only challenge is declined and a server offering both gets SHA-256 (`AUTH-15`..`AUTH-22`, `AUTH-24`). No **Breaking** change. +- `MultiSchemeAuthPolicy`: the descriptor-driven, multi-credential auth step (`AuthCredentials` for OAuth2, API key, Basic and + Digest) for APIs that declare several security schemes, sharing its stamping logic with the single-scheme policies; + the OpenAPI mapping table is in `docs/sdk-documentation/auth.md` (`AUTH-4`, `AUTH-5`). [Unreleased]: https://github.com/dexpace/dotnet-sdk/commits/main diff --git a/src/Dexpace.Sdk.Core/Pipeline/Policies/MultiSchemeAuthPolicy.cs b/src/Dexpace.Sdk.Core/Pipeline/Policies/MultiSchemeAuthPolicy.cs new file mode 100644 index 0000000..0c21b66 --- /dev/null +++ b/src/Dexpace.Sdk.Core/Pipeline/Policies/MultiSchemeAuthPolicy.cs @@ -0,0 +1,140 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; + +namespace Dexpace.Sdk.Core.Pipeline.Policies; + +/// +/// The descriptor-driven, multi-credential auth step a generated SDK installs when its API declares several security schemes +/// (AUTH-4, AUTH-5; P6c-36). +/// +/// +/// +/// For each call the policy resolves the per-call, operation and client tiers against the schemes +/// configures, then stamps through the same cache and 401 logic as +/// , and preemptively, and +/// answers reactively through a with the default +/// preference. The single-scheme policies and this one share internal stampers, so no logic is written twice. +/// +/// +/// A per-call or operation RequestOptions.Auth / OperationAuth descriptor overrides the client descriptor; a +/// descriptor naming only schemes with no credential throws before anything is +/// sent. The OpenAPI mapping is in docs/sdk-documentation/auth.md. +/// +/// +/// The constructor rejects a client descriptor that can never be served: one that does not allow anonymous calls and names no +/// scheme configures (a pinned reading, flagged for review). +/// +/// +public sealed class MultiSchemeAuthPolicy : AuthorizationPolicy +{ + private readonly ApiKeyStamper? _apiKey; + private readonly BasicStamper? _basic; + private readonly BearerStamper? _bearer; + private readonly DigestStamper? _digest; + + /// Initializes the policy. + /// The client tier: the preference order when a call names no tier of its own. + /// The credentials the policy can serve; each member is optional. + /// The clock of the token cache; defaults to . + /// or is . + /// The client descriptor can never be served by . + public MultiSchemeAuthPolicy(AuthDescriptor clientDescriptor, AuthCredentials credentials, TimeProvider? timeProvider = null) + : base(RequireServable(clientDescriptor, credentials), AvailableOf(credentials)) + { + _bearer = credentials.Token is { } token + ? new BearerStamper(new AccessTokenCache(token, timeProvider) { RefreshMargin = credentials.TokenRefreshMargin }, []) + : null; + _apiKey = credentials.ApiKey is { } apiKey ? new ApiKeyStamper(apiKey) : null; + _basic = credentials.Basic is { } basic ? new BasicStamper(basic) : null; + _digest = credentials.Digest is { } digest ? new DigestStamper(new DigestChallengeHandler(digest)) : null; + WithheldHeaderNames = Withheld(credentials); + } + + /// + protected override IReadOnlyList WithheldHeaderNames { get; } + + /// + protected override async ValueTask<(string HeaderName, string HeaderValue)?> GetCredentialAsync( + AuthRequirement requirement, + Request request, + PipelineContext context) + { + if (requirement.Scheme == AuthScheme.OAuth2 && _bearer is not null) + { + return await _bearer.StampAsync(requirement, context).ConfigureAwait(false); + } + + return GetCredential(requirement, request, context); + } + + /// + protected override (string HeaderName, string HeaderValue)? GetCredential( + AuthRequirement requirement, + Request request, + PipelineContext context) => + requirement.Scheme switch + { + AuthScheme.OAuth2 => _bearer?.Stamp(requirement, context), + AuthScheme.ApiKey => _apiKey?.Stamp(), + AuthScheme.Basic => _basic?.Stamp(), + _ => null, // Digest is reactive: nothing is attached until the server challenges. + }; + + /// + protected override async ValueTask OnChallengeAsync(AuthChallengeContext challenge) + { + ArgumentNullException.ThrowIfNull(challenge); + return challenge.Requirement.Scheme == AuthScheme.OAuth2 && _bearer is not null + ? await _bearer.OnChallengeAsync(challenge).ConfigureAwait(false) + : OnChallenge(challenge); + } + + /// + protected override Request? OnChallenge(AuthChallengeContext challenge) + { + ArgumentNullException.ThrowIfNull(challenge); + return challenge.Requirement.Scheme switch + { + AuthScheme.OAuth2 => _bearer?.OnChallenge(challenge), + AuthScheme.Digest => _digest?.OnChallenge(challenge), + _ => null, + }; + } + + private static AuthDescriptor RequireServable(AuthDescriptor clientDescriptor, AuthCredentials credentials) + { + ArgumentNullException.ThrowIfNull(clientDescriptor); + ArgumentNullException.ThrowIfNull(credentials); + var available = credentials.Available; + if (!clientDescriptor.AllowsAnonymous && !clientDescriptor.Requirements.Any(r => available.Contains(r.Scheme))) + { + throw new ArgumentException( + "The client descriptor names no authentication scheme that the credentials configure.", + nameof(credentials)); + } + + return clientDescriptor; + } + + private static IReadOnlyList AvailableOf(AuthCredentials credentials) + { + ArgumentNullException.ThrowIfNull(credentials); + return credentials.Available; + } + + // Authorization (bearer, Basic, Digest) plus the API key's own header, when it differs. + private static HttpHeaderName[] Withheld(AuthCredentials credentials) + { + var names = new List { HttpHeaderName.WellKnown.Authorization }; + if (credentials.ApiKey is { } apiKey && !names.Contains(apiKey.HeaderName)) + { + names.Add(apiKey.HeaderName); + } + + return [.. names]; + } +} diff --git a/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt b/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt index 1b73711..e44f750 100644 --- a/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt +++ b/src/Dexpace.Sdk.Core/PublicAPI.Unshipped.txt @@ -1111,6 +1111,8 @@ Dexpace.Sdk.Core.Pipeline.Policies.IdempotencyPolicy.IdempotencyPolicy(Dexpace.S Dexpace.Sdk.Core.Pipeline.Policies.IdempotencyPolicy.IdempotencyPolicy() -> void Dexpace.Sdk.Core.Pipeline.Policies.ErrorMappingPolicy Dexpace.Sdk.Core.Pipeline.Policies.ErrorMappingPolicy.ErrorMappingPolicy() -> void +Dexpace.Sdk.Core.Pipeline.Policies.MultiSchemeAuthPolicy +Dexpace.Sdk.Core.Pipeline.Policies.MultiSchemeAuthPolicy.MultiSchemeAuthPolicy(Dexpace.Sdk.Core.Auth.AuthDescriptor! clientDescriptor, Dexpace.Sdk.Core.Auth.AuthCredentials! credentials, System.TimeProvider? timeProvider = null) -> void Dexpace.Sdk.Core.Pipeline.Policies.OperationPolicy.OperationPolicy(System.TimeProvider? timeProvider = null) -> void Dexpace.Sdk.Core.Pipeline.Policies.RetryAttemptContext Dexpace.Sdk.Core.Pipeline.Policies.RetryAttemptContext.Attempt.get -> int diff --git a/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/MultiSchemeAuthPolicyTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/MultiSchemeAuthPolicyTests.cs new file mode 100644 index 0000000..9e45ead --- /dev/null +++ b/tests/Dexpace.Sdk.Core.Tests/Pipeline/Policies/MultiSchemeAuthPolicyTests.cs @@ -0,0 +1,256 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +using Dexpace.Sdk.Core.Auth; +using Dexpace.Sdk.Core.Configuration; +using Dexpace.Sdk.Core.Errors; +using Dexpace.Sdk.Core.Http.Common; +using Dexpace.Sdk.Core.Http.Request; +using Dexpace.Sdk.Core.Http.Response; +using Dexpace.Sdk.Core.Pipeline; +using Dexpace.Sdk.Core.Pipeline.Policies; +using Dexpace.Sdk.TestSupport.Transports; +using Xunit; + +namespace Dexpace.Sdk.Core.Tests.Pipeline.Policies; + +/// AUTH-4 and AUTH-5 end to end (P6c-7, P6c-36): the descriptor-driven, multi-credential policy. +[Trait("Category", "Unit")] +public sealed class MultiSchemeAuthPolicyTests +{ + private const string Url = "https://api.example.com/v1/items"; + + private static readonly DexpaceClientOptions s_options = new(); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static AuthDescriptor Of(params AuthScheme[] schemes) => new(schemes.Select(static s => new AuthRequirement(s))); + + private static AuthCredentials AllCredentials(SequenceTokens? tokens = null) => new() + { + Token = tokens ?? new SequenceTokens(), + ApiKey = new ApiKeyCredential("key-1", HttpHeaderName.Of("X-Api-Key")), + Basic = new BasicCredential("alice", "s3cr3t"), + Digest = new DigestCredential("alice", "s3cr3t"), + }; + + private static HttpPipeline Pipeline(MultiSchemeAuthPolicy policy, ScriptedTransport transport) => + new PipelineBuilder().Add(policy).Build(transport, s_options); + + private static async Task SendAsync(HttpPipeline pipeline, RequestOptions? options = null, bool async = true) => + async + ? await pipeline.SendAsync(Request.Get(Url), options ?? RequestOptions.Empty, Ct) + : pipeline.Send(Request.Get(Url), options ?? RequestOptions.Empty, Ct); + + [Fact] + public async Task Available_schemes_are_the_configured_credentials() + { + var policy = new MultiSchemeAuthPolicy(Of(AuthScheme.Digest, AuthScheme.Basic), new AuthCredentials { Basic = new BasicCredential("u", "p") }); + using var transport = new ScriptedTransport(TestResponses.Create(Status.Ok)); + + using var response = await SendAsync(Pipeline(policy, transport)); + + Assert.StartsWith("Basic ", transport.LastRequest!.Headers.Get("Authorization"), StringComparison.Ordinal); + } + + [Fact] + public async Task The_client_descriptor_order_decides() + { + var policy = new MultiSchemeAuthPolicy(Of(AuthScheme.OAuth2, AuthScheme.Basic), AllCredentials()); + using var transport = new ScriptedTransport(TestResponses.Create(Status.Ok)); + + using var response = await SendAsync(Pipeline(policy, transport)); + + Assert.Equal("Bearer tok-1", transport.LastRequest!.Headers.Get("Authorization")); + } + + [Fact] + public async Task A_per_call_descriptor_overrides_the_client_one() + { + var policy = new MultiSchemeAuthPolicy(Of(AuthScheme.OAuth2), AllCredentials()); + using var transport = new ScriptedTransport(TestResponses.Create(Status.Ok)); + var options = new RequestOptions { Auth = Of(AuthScheme.Basic) }; + + using var response = await SendAsync(Pipeline(policy, transport), options); + + Assert.StartsWith("Basic ", transport.LastRequest!.Headers.Get("Authorization"), StringComparison.Ordinal); + } + + [Fact] + public async Task An_operation_descriptor_is_used_when_no_per_call_one_is_set() + { + var policy = new MultiSchemeAuthPolicy(Of(AuthScheme.OAuth2), AllCredentials()); + using var transport = new ScriptedTransport(TestResponses.Create(Status.Ok)); + var options = new RequestOptions { OperationAuth = Of(AuthScheme.ApiKey) }; + + using var response = await SendAsync(Pipeline(policy, transport), options); + + Assert.Equal("key-1", transport.LastRequest!.Headers.Get("X-Api-Key")); + Assert.Null(transport.LastRequest.Headers.Get("Authorization")); + } + + [Fact] + public async Task A_per_call_NoAuth_sends_anonymously() + { + var policy = new MultiSchemeAuthPolicy(Of(AuthScheme.OAuth2), AllCredentials()); + using var transport = new ScriptedTransport(TestResponses.Create(Status.Ok)); + var options = new RequestOptions { Auth = new AuthDescriptor(AuthRequirement.NoAuth) }; + + using var response = await SendAsync(Pipeline(policy, transport), options); + + Assert.Null(transport.LastRequest!.Headers.Get("Authorization")); + Assert.Null(transport.LastRequest.Headers.Get("X-Api-Key")); + } + + [Fact] + public async Task A_descriptor_naming_only_unconfigured_schemes_throws_AuthResolutionException_before_sending() + { + var policy = new MultiSchemeAuthPolicy(Of(AuthScheme.Basic), new AuthCredentials { Basic = new BasicCredential("u", "p") }); + using var transport = new ScriptedTransport(TestResponses.Create(Status.Ok)); + var options = new RequestOptions { Auth = Of(AuthScheme.Digest) }; + + await Assert.ThrowsAsync(() => SendAsync(Pipeline(policy, transport), options)); + + Assert.Equal(0, transport.CallCount); + } + + [Fact] + public async Task ApiKey_is_stamped_in_its_own_header_and_withheld_cross_origin() + { + var policy = new MultiSchemeAuthPolicy(Of(AuthScheme.ApiKey), AllCredentials()); + var context = TestContexts.For(Request.Get("https://a.example.com/")); + var foreign = Request.Get("https://b.example.com/") + .WithHeaders(Headers.Empty.Set("X-Api-Key", "stale").Set("Authorization", "stale")); + + var sent = await TestContexts.SentAsync(policy, foreign, context); + + Assert.Null(sent.Headers.Get("X-Api-Key")); + Assert.Null(sent.Headers.Get("Authorization")); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Basic_is_preemptive(bool async) + { + var policy = new MultiSchemeAuthPolicy(Of(AuthScheme.Basic), AllCredentials()); + using var transport = new ScriptedTransport(TestResponses.Create(Status.Ok)); + + using var response = await SendAsync(Pipeline(policy, transport), async: async); + + Assert.Equal("Basic YWxpY2U6czNjcjN0", transport.LastRequest!.Headers.Get("Authorization")); + Assert.Equal(1, transport.CallCount); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Digest_is_reactive_and_answers_one_401(bool async) + { + var policy = new MultiSchemeAuthPolicy(Of(AuthScheme.Digest), AllCredentials()); + using var transport = new ScriptedTransport( + TestResponses.Unauthorized("Digest realm=\"r\", nonce=\"n\", qop=\"auth\""), + TestResponses.Create(Status.Ok)); + + using var response = await SendAsync(Pipeline(policy, transport), async: async); + + Assert.Equal(200, response.Status.Code); + Assert.Null(transport.Requests[0].Headers.Get("Authorization")); + Assert.StartsWith("Digest ", transport.Requests[1].Headers.Get("Authorization"), StringComparison.Ordinal); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task OAuth2_is_stamped_and_retried_once_on_a_Bearer_401(bool async) + { + var tokens = new SequenceTokens(); + var policy = new MultiSchemeAuthPolicy(Of(AuthScheme.OAuth2), AllCredentials(tokens)); + using var transport = new ScriptedTransport(TestResponses.Unauthorized("Bearer realm=\"r\""), TestResponses.Create(Status.Ok)); + + using var response = await SendAsync(Pipeline(policy, transport), async: async); + + Assert.Equal(200, response.Status.Code); + Assert.Equal("Bearer tok-1", transport.Requests[0].Headers.Get("Authorization")); + Assert.Equal("Bearer tok-2", transport.Requests[1].Headers.Get("Authorization")); + Assert.Equal(2, tokens.Calls); + } + + [Fact] + public async Task Per_call_OAuth2_scopes_reach_the_token_provider() + { + var tokens = new SequenceTokens(); + var policy = new MultiSchemeAuthPolicy(Of(AuthScheme.OAuth2), AllCredentials(tokens)); + using var transport = new ScriptedTransport(TestResponses.Create(Status.Ok)); + var options = new RequestOptions { Auth = new AuthDescriptor(new AuthRequirement(AuthScheme.OAuth2) { Scopes = ["a", "b"] }) }; + + using var response = await SendAsync(Pipeline(policy, transport), options); + + Assert.Equal(["a", "b"], tokens.LastScopes); + } + + [Fact] + public async Task Two_calls_with_different_tiers_use_different_schemes_through_one_policy_instance() + { + var policy = new MultiSchemeAuthPolicy(Of(AuthScheme.OAuth2, AuthScheme.Basic), AllCredentials()); + using var transport = new ScriptedTransport(TestResponses.Create(Status.Ok), TestResponses.Create(Status.Ok)); + var pipeline = Pipeline(policy, transport); + + using var first = await SendAsync(pipeline); + using var second = await SendAsync(pipeline, new RequestOptions { Auth = Of(AuthScheme.ApiKey) }); + + Assert.StartsWith("Bearer ", transport.Requests[0].Headers.Get("Authorization"), StringComparison.Ordinal); + Assert.Equal("key-1", transport.Requests[1].Headers.Get("X-Api-Key")); + Assert.Null(transport.Requests[1].Headers.Get("Authorization")); + } + + [Fact] + public async Task The_policy_is_safe_under_concurrent_calls() + { + var policy = new MultiSchemeAuthPolicy(Of(AuthScheme.Basic), AllCredentials()); + using var transport = new ScriptedTransport(Enumerable.Range(0, 32).Select(_ => (object)(Func)(() => TestResponses.Create(Status.Ok)))); + var pipeline = Pipeline(policy, transport); + + var responses = await Task.WhenAll(Enumerable.Range(0, 32).Select(_ => Task.Run(() => SendAsync(pipeline), Ct))); + + Assert.All(responses, r => r.Dispose()); + Assert.All(transport.Requests, r => Assert.StartsWith("Basic ", r.Headers.Get("Authorization"), StringComparison.Ordinal)); + } + + [Fact] + public void Constructor_rejects_null_and_a_descriptor_no_credential_can_serve() + { + Assert.Throws(() => new MultiSchemeAuthPolicy(null!, new AuthCredentials())); + Assert.Throws(() => new MultiSchemeAuthPolicy(Of(AuthScheme.Basic), null!)); + Assert.Throws(() => new MultiSchemeAuthPolicy(Of(AuthScheme.Basic), new AuthCredentials())); + Assert.NotNull(new MultiSchemeAuthPolicy(new AuthDescriptor(AuthRequirement.NoAuth), new AuthCredentials())); + } + + [Fact] + public async Task The_guard_refuses_http() + { + var policy = new MultiSchemeAuthPolicy(Of(AuthScheme.Basic), AllCredentials()); + using var transport = new ScriptedTransport(TestResponses.Create(Status.Ok)); + var pipeline = Pipeline(policy, transport); + + await Assert.ThrowsAsync( + () => pipeline.SendAsync(Request.Get("http://api.example.com/"), RequestOptions.Empty, Ct).AsTask()); + + Assert.Equal(0, transport.CallCount); + } + + private sealed class SequenceTokens : TokenCredential + { + private int _calls; + + public int Calls => Volatile.Read(ref _calls); + + public IReadOnlyList LastScopes { get; private set; } = []; + + public override ValueTask GetTokenAsync(TokenRequestContext context, CancellationToken ct = default) + { + LastScopes = context.Scopes; + return new ValueTask(new AccessToken("tok-" + Interlocked.Increment(ref _calls))); + } + } +} From 366adb66c778ad4f89df2addc9516ba6b0b60879 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 8 Oct 2026 20:57:52 +0300 Subject: [PATCH 09/13] test: NativeAOT smoke over the authentication surface --- tests/Dexpace.Sdk.AotSmoke/SmokeChecks.cs | 66 +++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/Dexpace.Sdk.AotSmoke/SmokeChecks.cs b/tests/Dexpace.Sdk.AotSmoke/SmokeChecks.cs index c3b8efb..1a66ddb 100644 --- a/tests/Dexpace.Sdk.AotSmoke/SmokeChecks.cs +++ b/tests/Dexpace.Sdk.AotSmoke/SmokeChecks.cs @@ -9,6 +9,7 @@ using System.IO; using System.Net; using System.Text; +using Dexpace.Sdk.Core.Auth; using Dexpace.Sdk.Core.Client; using Dexpace.Sdk.Core.Configuration; using Dexpace.Sdk.Core.Diagnostics; @@ -60,6 +61,7 @@ public static async Task RunAllAsync() await CheckTracingAndMetricsAsync(); await CheckPhase6aRetryAsync(); await CheckPhase6bRedirectAsync(); + await CheckPhase6cAuthAsync(); } catch (SmokeFailureException failure) { @@ -763,6 +765,70 @@ private static async Task CheckPhase6bRedirectAsync() "RedirectSchemeDowngradeException names the redacted URLs"); } + // Phase 6c: the challenge parser, the resolver, Digest (BCL hash and CSPRNG cnonce) and the bearer cache with its + // background refresh, all without reflection. + private static async Task CheckPhase6cAuthAsync() + { + var challenges = AuthenticationChallenge.Parse("Basic realm=\"a\", Digest realm=\"b\", nonce=\"n\", qop=\"auth\""); + Expect( + challenges.Count == 2 && challenges[0].Scheme == "basic" && challenges[1].Parameters["nonce"] == "n", + "AuthenticationChallenge.Parse splits two challenges"); + Expect(AuthenticationChallenge.Parse("Digest realm=\"unterminated").Count == 1, "The challenge parser is lenient"); + + var resolved = AuthResolver.Resolve(null, new AuthDescriptor(new AuthRequirement(AuthScheme.Digest), AuthRequirement.NoAuth), null, []); + Expect(resolved.Scheme == AuthScheme.NoAuth, "AuthResolver falls through to NoAuth"); + + var digest = new DigestChallengeHandler(new DigestCredential("Mufasa", "Circle Of Life"), [DigestAlgorithm.Sha256]); + var answered = digest.Authorize( + AuthenticationChallenge.Parse("Digest realm=\"r\", nonce=\"n\", qop=\"auth\", algorithm=SHA-256"), + Request.Get("https://smoke.example.test/dir"), + proxy: false); + var header = answered?.Headers.Get("Authorization") ?? string.Empty; + var response = AuthenticationChallenge.Parse(header).Count == 1 ? AuthenticationChallenge.Parse(header)[0].Parameters["response"] : string.Empty; + Expect( + header.Contains("algorithm=SHA-256", StringComparison.Ordinal) && response.Length == 64, + "DigestChallengeHandler answers a SHA-256 challenge"); + + Expect( + new BasicCredential("u", "s3cr3t").ToString().Contains("***", StringComparison.Ordinal) + && !new BasicCredential("u", "s3cr3t").ToString().Contains("s3cr3t", StringComparison.Ordinal) + && new AccessToken("tok-secret").ToString().Contains("***", StringComparison.Ordinal) + && !new AuthCredentials { Basic = new BasicCredential("u", "s3cr3t") }.ToString().Contains("s3cr3t", StringComparison.Ordinal), + "Credentials redact their secrets"); + + // A token inside the refresh margin is stamped now and refreshed in the background (cache, BoundedMap, SemaphoreSlim, + // BackgroundWork under AOT). + var credential = new SmokeTokenCredential(); + var seen = new List(); + await using var transport = DelegateHttpClient.Create((request, _, _) => + { + seen.Add(request.Headers.Get("Authorization")); + return Task.FromResult(new Response(request, Status.Ok, Protocol.Http11)); + }); + var pipeline = new PipelineBuilder().Add(new BearerTokenAuthPolicy(credential, "smoke")).Build(transport); + using var first = await pipeline.SendAsync(Request.Get("https://smoke.example.test/"), CancellationToken.None); + using var second = await pipeline.SendAsync(Request.Get("https://smoke.example.test/"), CancellationToken.None); + for (var i = 0; i < 500 && credential.Calls < 2; i++) + { + await Task.Delay(10); + } + + Expect( + seen is ["Bearer smoke-1", "Bearer smoke-1"] && credential.Calls >= 2, + "BearerTokenAuthPolicy stamps the valid token and refreshes in the background"); + } + + /// A token that is always inside the 30 s refresh margin, so every request after the first starts a refresh. + private sealed class SmokeTokenCredential : TokenCredential + { + private int _calls; + + public int Calls => Volatile.Read(ref _calls); + + public override ValueTask GetTokenAsync(TokenRequestContext context, CancellationToken ct = default) => + new(new AccessToken("smoke-" + Interlocked.Increment(ref _calls), DateTimeOffset.UtcNow.AddSeconds(20))); + } + private static void Expect(bool condition, string what) { if (!condition) From 1260f6ca033a580303dc244c55f5b8114dfe0d76 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 8 Oct 2026 21:05:15 +0300 Subject: [PATCH 10/13] docs: phase 6c checklist, user page and dated corrections --- CHANGELOG.md | 1 + CLAUDE.md | 15 +- docs/README.md | 2 +- docs/first-release.md | 7 + docs/knowledge/notes/authentication.md | 13 ++ .../05-pipeline-architecture.md | 4 + .../06-retry-redirect-and-authentication.md | 16 ++ .../08-instrumentation-and-configuration.md | 2 +- ...-deviations-from-the-reference-contract.md | 3 + ...guities-and-how-this-port-resolves-them.md | 15 ++ .../12-appendix-requirement-coverage-index.md | 3 + docs/sdk-documentation/auth.md | 184 ++++++++++++++++ ...2026-09-27-dotnet-sdk-v1-roadmap-design.md | 22 +- ...6-09-28-phase1-security-fixes-checklist.md | 6 + .../2026-10-03-phase3b-bodies-checklist.md | 6 + .../2026-10-07-phase4a-context-checklist.md | 2 + .../2026-10-07-phase4c-pipeline-checklist.md | 5 + .../2026-10-08-phase6c-auth-checklist.md | 207 ++++++++++++++++++ src/Dexpace.Sdk.Core/README.md | 24 +- 19 files changed, 527 insertions(+), 10 deletions(-) create mode 100644 docs/knowledge/notes/authentication.md create mode 100644 docs/sdk-documentation/auth.md create mode 100644 docs/work/mvp/phase6/phase6c/2026-10-08-phase6c-auth-checklist.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e246f8..cfd6225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -539,5 +539,6 @@ Roadmap phase 1, defects S1–S9, each pinned by a `[Trait("Category", "Security - `MultiSchemeAuthPolicy`: the descriptor-driven, multi-credential auth step (`AuthCredentials` for OAuth2, API key, Basic and Digest) for APIs that declare several security schemes, sharing its stamping logic with the single-scheme policies; the OpenAPI mapping table is in `docs/sdk-documentation/auth.md` (`AUTH-4`, `AUTH-5`). +- `docs/sdk-documentation/auth.md`; the AOT smoke covers the challenge parser, the resolver, Digest (SHA-256 and the CSPRNG cnonce), the redacting credentials and the bearer policy with its background refresh. [Unreleased]: https://github.com/dexpace/dotnet-sdk/commits/main diff --git a/CLAUDE.md b/CLAUDE.md index cffba15..9ea300b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,11 +121,13 @@ dotnet-sdk/ │ │ ├── Resilience/ # internal: RetryFacts (classifier, re-send gate), RetryBackoff, RetryPacing, RetryBudget, RetryEngine (the one retry loop) │ │ ├── Pipeline/ # HttpPipeline (also a transport), PipelineBuilder, HttpPipelinePolicy, │ │ │ └── Policies/ # PipelineContext, PipelineRunner, DexpacePipeline; operation, redirect, retry, -│ │ │ # idempotency, set-date, client-identity, instrumentation, auth, error-mapping policies; +│ │ │ # idempotency, set-date, client-identity, instrumentation, error-mapping policies; the auth base +│ │ │ # AuthorizationPolicy + ApiKey, Basic, BearerToken, Challenge and MultiScheme policies; │ │ │ # Redirect/ holds the internal decider, chain, location resolver and reissue behind RedirectPolicy │ │ ├── Recovery/ # Outcome, the step contracts, request/response recovery chains, RecoveryDispatcher, RetryRecovery, │ │ │ # ErrorMappingStep, IdempotencyKeyStep, ClientIdentityStep -│ │ ├── Auth/ # TokenCredential, AccessTokenCache, ApiKeyCredential, BasicCredential +│ │ ├── Auth/ # AuthScheme, AuthRequirement, AuthDescriptor, AuthResolver, AuthCredentials, TokenCredential, AccessToken, AccessTokenCache, +│ │ │ # ApiKey/Basic/DigestCredential, AuthenticationChallenge, IChallengeHandler + Basic/Digest/Composite handlers │ │ ├── Pagination/ # AsyncPageable, Page, Pageable, PaginationStrategies │ │ ├── Configuration/ # DexpaceClientOptions, RetryOptions, RedirectOptions, HttpLoggingOptions (sealed records), HttpLogLevel, ProxyOptions, TimeProviderWaits, BuildInfo │ │ ├── Diagnostics/ # DexpaceDiagnostics (ActivitySource + Meter), UrlRedactor, DexpaceLogEvents/Keys, HttpLogEmitter, @@ -264,10 +266,11 @@ the `http.request`/`http.response` events, header and URL redaction, the emissio `docs/sdk-documentation/logging-and-redaction.md`; and phase 5c's tracing and metrics — the operation span, the attempt span rework, the `dexpace.*` span events, the two instruments with their stable attribute sets and `SystemNetHttpClient`'s `traceparent` rule — see `docs/sdk-documentation/tracing-and-metrics.md`; and phase 6a's retry — the classifier and `IRetryableError`, the re-send gate, `RetryOptions`' validation, the one `RetryEngine` under `RetryPolicy` and `RetryRecovery`, the pacing -headers, the suppressed trail, `OperationTimeoutException` and the enforced `AttemptTimeout` — see `docs/sdk-documentation/retry.md`; and phase 6b's redirect — +headers, the suppressed trail, `OperationTimeoutException` and the enforced `AttemptTimeout` — see `docs/sdk-documentation/retry.md`; phase 6b's redirect — the pure decider behind `RedirectPolicy`, `RedirectOptions` (`AllowedMethods`, `FollowSeeOther`, `Predicate`), the `RedirectException` family, loop detection, the five -`http.redirect.*` events and the end-to-end credential-leak tests — see `docs/sdk-documentation/redirect.md`): -the auth resolver with RFC 7235 -challenges and Digest (6c), tri-state PATCH, SSE and the remaining pagination surface (7), the transport +`http.redirect.*` events and the end-to-end credential-leak tests — see `docs/sdk-documentation/redirect.md`; and phase 6c's authentication — the +descriptor and resolver with their `RequestOptions` tiers, the redacting credentials, the lenient challenge parser, the `401` lifecycle in `AuthorizationPolicy`, the +bounded token cache with its background refresh, Digest with the MD5 probe, and `MultiSchemeAuthPolicy` — see `docs/sdk-documentation/auth.md`): +tri-state PATCH, SSE and the remaining pagination surface (7), the transport conformance kit (8), the DI package `Dexpace.Sdk.Extensions.DependencyInjection` (9), and the release path (12). diff --git a/docs/README.md b/docs/README.md index b98df96..5b8ac0e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,7 +17,7 @@ only describes what already exists is a table that gets edited after the fact. | [`superpowers/`](./superpowers/README.md) | Nothing, for long. The **inbox** the Superpowers skills write into | `brainstorming`, `writing-plans` | Yes — it drains it | | [`first-release.md`](./first-release.md) | Release-readiness register: package versions, blockers before first publish, the release path, what v1 ships without, post-release triggers | A human, updated as blockers close | No | | `deviations.md` **(planned)** | As-built audit of `sdk-design-dotnet/10`'s deviation ledger, plus deviations found outside a phase. Created by roadmap phase 11 | A human, following a phase or review | No — judgment, not a mechanical append | -| [`sdk-documentation/`](./sdk-documentation/http.md) | **As-built** user documentation — how the packages compose, worked examples — one page per phase that ships a layer, mirroring `ruby-sdk/docs/sdk-documentation/`. Today: [`http.md`](./sdk-documentation/http.md) (phase 2a), [`seams.md`](./sdk-documentation/seams.md) (phase 2b), [`io.md`](./sdk-documentation/io.md) (phase 3a), [`bodies.md`](./sdk-documentation/bodies.md) (phase 3b), [`execution-context.md`](./sdk-documentation/execution-context.md) (phase 4a), [`recovery.md`](./sdk-documentation/recovery.md) (phase 4b), [`pipelines.md`](./sdk-documentation/pipelines.md) (phase 4c), [`configuration.md`](./sdk-documentation/configuration.md) (phase 5a), [`logging-and-redaction.md`](./sdk-documentation/logging-and-redaction.md) (phase 5b), [`tracing-and-metrics.md`](./sdk-documentation/tracing-and-metrics.md) (phase 5c), [`retry.md`](./sdk-documentation/retry.md) (phase 6a), [`redirect.md`](./sdk-documentation/redirect.md) (phase 6b) | A human, or a skill on request | Yes | +| [`sdk-documentation/`](./sdk-documentation/http.md) | **As-built** user documentation — how the packages compose, worked examples — one page per phase that ships a layer, mirroring `ruby-sdk/docs/sdk-documentation/`. Today: [`http.md`](./sdk-documentation/http.md) (phase 2a), [`seams.md`](./sdk-documentation/seams.md) (phase 2b), [`io.md`](./sdk-documentation/io.md) (phase 3a), [`bodies.md`](./sdk-documentation/bodies.md) (phase 3b), [`execution-context.md`](./sdk-documentation/execution-context.md) (phase 4a), [`recovery.md`](./sdk-documentation/recovery.md) (phase 4b), [`pipelines.md`](./sdk-documentation/pipelines.md) (phase 4c), [`configuration.md`](./sdk-documentation/configuration.md) (phase 5a), [`logging-and-redaction.md`](./sdk-documentation/logging-and-redaction.md) (phase 5b), [`tracing-and-metrics.md`](./sdk-documentation/tracing-and-metrics.md) (phase 5c), [`retry.md`](./sdk-documentation/retry.md) (phase 6a), [`redirect.md`](./sdk-documentation/redirect.md) (phase 6b), [`auth.md`](./sdk-documentation/auth.md) (phase 6c) | A human, or a skill on request | Yes | | [`architecture.md`](./architecture.md) | The original foundation-slice architecture note, marked superseded by `sdk-design-dotnet/01-overview.md`; kept until roadmap phase 12 writes `sdk-documentation/architecture.md` | A human | Yes | | [`README.md`](./README.md) | This index | A human | Yes | diff --git a/docs/first-release.md b/docs/first-release.md index 03088d9..be3e0db 100644 --- a/docs/first-release.md +++ b/docs/first-release.md @@ -99,6 +99,13 @@ Each phase adds an entry for each requirement it declines, with the requirement - **`REDIR-27` (MAY): the redirect target header is not configurable.** The header is `Location`; a `LocationHeader` option has no consumer and no trigger is named. Phase 6b (2026-10-08); design §12's deferred list and P6b-21. +- **Authentication features outside the requirements (phase 6c, P6c-13, P6c-19, P6c-35, P6c-40).** Not built, and not planned for v1: **claims challenges** + (`WWW-Authenticate: Bearer … claims="…"`, continuous access evaluation; the bearer hook re-fetches with the same `TokenRequestContext`), an **`apiKey` in a query + string or a cookie** (`AUTH-26` is header-only; no policy stamps either), a **preemptive Digest** (the handler answers a challenge and remembers only counts), + **`407` proxy challenges in the pipeline** (a forward proxy's `407` is answered by the transport from `ProxyOptions.ChallengeCredentials`, phase 8b), and a + **`NamedKeyCredential`** (a prefixed key is `new ApiKeyCredential(key, scheme: "SharedAccessKey")`). Digest's MD5 algorithms are unavailable on a host whose crypto + provider refuses MD5 (design §10 entry 16). + ### Behavioural asymmetries a consumer must know - **`async-redirect-pillar` (design §10 entry 14; `PIPE-32`, `REDIR-25`).** The reference contract has the asynchronous standard pipeline diff --git a/docs/knowledge/notes/authentication.md b/docs/knowledge/notes/authentication.md new file mode 100644 index 0000000..2b269dd --- /dev/null +++ b/docs/knowledge/notes/authentication.md @@ -0,0 +1,13 @@ +# authentication — notes + +Hand-written. `../harvested/authentication.md` is what the documents say; this is what the port decided, and it wins. + +## Superseded +- **Hex is lower-cased with `Convert.ToHexStringLower`, because the port targets `net10.0` only.** Supersedes `authentication/af09917c`: design §6.3's "lower-cases hex output manually … the port targets net8.0" predates roadmap decision D1, which set every project to `net10.0`; the Digest code calls `Convert.ToHexStringLower` (verified to return `ab0f` for `[0xAB, 0x0F]`), and `.editorconfig`'s CA1308 relaxation is unrelated to it. + review · `docs/work/mvp/phase6/phase6c/2026-10-08-phase6c-auth-checklist.md` · high · sha:manual-p6c-hex-lower + +## Conclusions +- **`net10.0` has no `MD5.IsSupported`, so the Digest handler finds out whether MD5 is available by trying one hash, once per process.** Verified on 10.0.401 (reflection finds no such member, `MD5.HashData([])` succeeds); a `CryptographicException` or `PlatformNotSupportedException` means a FIPS-refusing provider, which drops the MD5 algorithms from the handler's effective preference. + review · `docs/work/mvp/phase6/phase6c/2026-10-08-phase6c-auth-checklist.md` · high · sha:manual-p6c-md5-probe +- **`Uri.GetComponents(UriComponents.PathAndQuery, UriFormat.UriEscaped)` equals the request-target `SocketsHttpHandler` writes, so it is the Digest `uri`.** Verified on the wire for `/a%2Fb?x=%26y`, an empty path (`/`), an empty path with a query (`/?q=1`) and `/p%20q/r?a=b%20c&d=%7E` (both write `d=~`); `DigestWireTests` pins it. + review · `docs/work/mvp/phase6/phase6c/2026-10-08-phase6c-auth-checklist.md` · high · sha:manual-p6c-digest-uri diff --git a/docs/sdk-design-dotnet/05-pipeline-architecture.md b/docs/sdk-design-dotnet/05-pipeline-architecture.md index 84e4c9a..45e58ec 100644 --- a/docs/sdk-design-dotnet/05-pipeline-architecture.md +++ b/docs/sdk-design-dotnet/05-pipeline-architecture.md @@ -420,6 +420,10 @@ because it is the runtime's own and every OpenTelemetry collector reads it (P14) runs under `ExecutionContext.SuppressFlow()` — verified: a task started under suppression observes no `AsyncLocal` value. +*Correction 2026-10-08 (phase 6c, P6c-25):* the one background-launch helper is `Internal/BackgroundWork.cs` (`BackgroundWork.Run(Func)`), the only +source file that mentions `SuppressFlow`; a source scan (`BannedSymbolsSiteTests`) pins it. The token cache's refresh is its first caller. It skips the +suppression when flow is already suppressed. + **The promotion chain** (**CTX-1**–**CTX-3**) is three sealed records — `DispatchContext`, `RequestContext`, `ExchangeContext` — not one type with a stage field, so "the exchange type exposes no method promoting back" (**CTX-1**) is the absence of a method. Each `Promote` returns a new instance carrying the same key and bundle and diff --git a/docs/sdk-design-dotnet/06-retry-redirect-and-authentication.md b/docs/sdk-design-dotnet/06-retry-redirect-and-authentication.md index 5dfa8f4..b453251 100644 --- a/docs/sdk-design-dotnet/06-retry-redirect-and-authentication.md +++ b/docs/sdk-design-dotnet/06-retry-redirect-and-authentication.md @@ -428,4 +428,20 @@ wait-free, single-flight cache; missing the HTTPS guard, descriptor/resolver, ch 401 handling, refresh margin, expired-at-fetch rejection, background refresh, bounded cache, optional expiry and non-blank validation. +**As built (2026-10-08, phase 6c):** built. The text above stands as written, with six *dated corrections*: + +- **The tiers ride `RequestOptions` (P6c-6, P6c-7).** `AuthScheme`, `AuthRequirement`, `AuthDescriptor` and `AuthResolver` are built; the per-call and + operation tiers are `RequestOptions.Auth` / `OperationAuth` and the client tier is the auth policy's own descriptor. Every auth policy resolves, so a + per-call `NoAuth` sends anonymously and an unservable scheme throws `AuthResolutionException` before anything is sent. +- **Credentials redact by an explicit `ToString` (P6c-20)**, pinned by the `Security` class `CredentialRedactionTests`. The named-key credential of + **AUTH-8**/**AUTH-9** is not built (P6c-19): `new ApiKeyCredential(key, scheme: "SharedAccessKey")` is its one stamping use. +- **The `401` hook lives in `AuthorizationPolicy` (P6c-9 to P6c-12).** Cross-origin first (no guard, no hook, the `401` returned as it is), then resolve, the + HTTPS guard (`HttpsRequiredException`, also on a reactive scheme's outbound pass, P6c-8), stamp, drive, challenge, the same-origin and replayability checks, + one replay. Sync and async are one body. +- **The cache is built as designed (P6c-23 to P6c-28)**: fresh, expiring and expired zones; a per-key semaphore with a fetch generation; one background refresh + per key from `Internal/BackgroundWork.cs`; event 160; a real `Get`; a `BoundedMap` of 1024 keys. +- **Digest is built (P6c-29 to P6c-35)** with the default preference SHA-256 first, a once-per-process MD5 probe, `-sess` without `qop` declined (new §11 item 62), + `username*` for a non-ASCII user name, and the escaped request-target as `digest-uri` (verified against the wire). +- **Hex.** "on the `net8.0` target the port lower-cases" reads: `Convert.ToHexStringLower`, since the port targets `net10.0` only (roadmap D1, P6c-38). + --- diff --git a/docs/sdk-design-dotnet/08-instrumentation-and-configuration.md b/docs/sdk-design-dotnet/08-instrumentation-and-configuration.md index 614529d..653fa4b 100644 --- a/docs/sdk-design-dotnet/08-instrumentation-and-configuration.md +++ b/docs/sdk-design-dotnet/08-instrumentation-and-configuration.md @@ -116,7 +116,7 @@ contract) — **ASYNC-9**'s save/install/restore is the runtime's behaviour. The two APIs opt out: `ExecutionContext.SuppressFlow()` and `ThreadPool.UnsafeQueueUserWorkItem` each delivered `null` for a set `AsyncLocal` (verified), so both are banned in SDK code by §9.1's banned-API gate — with one sanctioned exception, the internal helper that launches SDK-owned background work (the token cache's refresh), which suppresses -flow on purpose so that work does not pin the triggering call's context (§5.4, §6.3). The span-model +flow on purpose so that work does not pin the triggering call's context (§5.4, §6.3). *(Dated correction, 2026-10-08, phase 6c: the helper is `Internal/BackgroundWork.cs`, §5.4.)* The span-model residuals — the `null` no-op, the key names, the host-owned allow-list default — are recorded together as §10 entry 23. diff --git a/docs/sdk-design-dotnet/10-deliberate-deviations-from-the-reference-contract.md b/docs/sdk-design-dotnet/10-deliberate-deviations-from-the-reference-contract.md index 4952f48..9e12cac 100644 --- a/docs/sdk-design-dotnet/10-deliberate-deviations-from-the-reference-contract.md +++ b/docs/sdk-design-dotnet/10-deliberate-deviations-from-the-reference-contract.md @@ -182,6 +182,9 @@ line and in the roadmap. does not vendor one: it probes once, and where MD5 is refused the Digest handler declines MD5 and MD5-sess challenges as unsupported, falling through to SHA-256 when offered, instead of throwing. **AUTH-15**'s "MUST support exactly" all four holds only on hosts whose crypto provides MD5. §6.3. + *Correction 2026-10-08 (phase 6c, P6c-30):* the probe runs once per process (`Md5Availability`): `net10.0` has no `MD5.IsSupported`, so it tries + `MD5.HashData` and treats `CryptographicException` and `PlatformNotSupportedException` as "refused". Construction never fails on such a host, even with an + MD5-only preference; the handler declines everything it cannot answer. 17. **Pages are materialized values; no page owns a live response.** *Touches* **PAGE-3**, **PAGE-12**, **PAGE-15** (its two-page case), and the "live response" of the page view described in the 12.1 preamble that **PAGE-1** rests on. *Judged* (P10). An async iterator's `finally` runs only when the consumer disposes the enumerator; a diff --git a/docs/sdk-design-dotnet/11-appendix-reference-spec-ambiguities-and-how-this-port-resolves-them.md b/docs/sdk-design-dotnet/11-appendix-reference-spec-ambiguities-and-how-this-port-resolves-them.md index 88c8cc1..f0cc8f1 100644 --- a/docs/sdk-design-dotnet/11-appendix-reference-spec-ambiguities-and-how-this-port-resolves-them.md +++ b/docs/sdk-design-dotnet/11-appendix-reference-spec-ambiguities-and-how-this-port-resolves-them.md @@ -313,6 +313,21 @@ the pull request (#3–#9) that built it. No decision recorded here changed. 61. **`REDIR-28` says the malformed-`Location` event logs the raw string.** *Added by dated correction, 2026-10-08 (phase 6b, P6b-20).* *Resolved* (§6.2): the value goes through `UrlRedactor.RedactHeaderValue`, which is total over unparseable text; stricter than the letter, and the letter's own caveat about credential-bearing values is the reason. +62. **`AUTH-16`, `AUTH-17` and `AUTH-22` cannot all hold for a `-sess` challenge without `qop`.** *Added by dated correction, 2026-10-08 (phase 6c, P6c-31).* + *Resolved* (§6.3): `AUTH-16` calls it satisfiable and `AUTH-17` folds the cnonce into HA1, but `AUTH-22` forbids sending the cnonce without `qop`, so the server could not + verify the response (RFC 2617 has the same contradiction; RFC 7616 has no no-`qop` form). The handler declines it, which avoids a guaranteed `401` round trip and lets a + composite fall through. Node sends the cnonce anyway; Ruby follows `AUTH-22` literally. +63. **`AUTH-28` says "on any request path where a credential will be attached"; a reactive scheme attaches one only after the first `401`.** *Added by dated correction, + 2026-10-08 (phase 6c, P6c-8).* *Resolved* (§6.3, §11 item 25): the guard also runs on the outbound pass of a reactive scheme (Digest, Basic on challenge), so a request + is never sent in the clear to draw a challenge it may not answer. A Digest-configured call over `http://` to a server that never challenges now fails; that is a + misconfiguration surfaced early, in keeping with item 25's no-exemption stance. +64. **`AUTH-8` and `AUTH-9` name a named-key credential this port never had.** *Added by dated correction, 2026-10-08 (phase 6c, P6c-19).* *Resolved* (§6.3): the two named-key + clauses are vacuous. A second key type that stamps identically to `ApiKeyCredential` would duplicate it for no requirement; a consuming SDK that needs a name for signing owns + that type. +65. **`AUTH-36` surfaces a `401` unchanged when the rejected request carried no `Authorization` header (cross-origin suppression); the same hazard exists for every scheme.** + *Added by dated correction, 2026-10-08 (phase 6c, P6c-12).* *Resolved* (§6.3, §10 entry 15): a `401` on a cross-origin hop is returned without challenge handling for any scheme, + so a foreign server cannot draw the caller's credential by answering `401`. + **Correction 2026-10-07 (phase 5c, P5c-10).** Item 38 as built: `http.client.request.duration` (histogram, `s`, OpenTelemetry's bucket advice `0.005`, `0.01`, `0.025`, `0.05`, `0.075`, `0.1`, `0.25`, `0.5`, `0.75`, `1`, `2.5`, `5`, `7.5`, `10`, through `InstrumentAdvice`, which carries no `[Experimental]` diagnostic on SDK 10.0.401) with `http.request.method` (`_OTHER` for an unknown method), `server.address`, `server.port`, diff --git a/docs/sdk-design-dotnet/12-appendix-requirement-coverage-index.md b/docs/sdk-design-dotnet/12-appendix-requirement-coverage-index.md index 65a61d6..db943eb 100644 --- a/docs/sdk-design-dotnet/12-appendix-requirement-coverage-index.md +++ b/docs/sdk-design-dotnet/12-appendix-requirement-coverage-index.md @@ -90,3 +90,6 @@ covered by a §10 entry that argues its guarantee is kept. **Correction 2026-10-08 (phase 6a).** The `RETRY` row is now 45 of 45: **RETRY-29**, **RETRY-38** and **RETRY-43** are built (the `ShouldRetry` hook, `RetryOptions.AttemptHeaderName`, `RetryOptions.FixedDelay`), and nothing in `RETRY` is deferred. The `RECOV` rows **RECOV-17**–**RECOV-31** and **RECOV-34** are built (**RECOV-31** with **RETRY-38**, one feature under two IDs); see the 6a checklist's carried-rows table. + +**Correction 2026-10-08 (phase 6c).** The `AUTH` row is now 38 of 38: **AUTH-26** is addressed (the key policy and credential stamp and validate the value once, P6c-19) and every other +`AUTH` row is built (the 6c checklist). The notes gain **AUTH-16**'s `-sess`-without-`qop` decline (§11 item 62) and the vacuous named-key clauses of **AUTH-8**/**AUTH-9** (§11 item 64). diff --git a/docs/sdk-documentation/auth.md b/docs/sdk-documentation/auth.md new file mode 100644 index 0000000..2d10c00 --- /dev/null +++ b/docs/sdk-documentation/auth.md @@ -0,0 +1,184 @@ +# Authentication + +**As built by phase 6c, written against source on 2026-10-08** (branch `74-phase-6c-auth`). This page describes how +`Dexpace.Sdk.Core` authenticates a call after phase 6c: the descriptor and its three tiers, the credential types, the +five shipped policies, the `401` lifecycle in the authorization base, the token cache, Digest, and the lenient challenge +parser. The requirement IDs it cites (`AUTH-1` to `AUTH-38`) are the normative text +(`docs/product-spec/`, appendix C); the decisions behind each type are in the +[phase 6c design](../work/mvp/phase6/phase6c/2026-10-08-phase6c-auth-design.md) and in +[design §6.3](../sdk-design-dotnet/06-retry-redirect-and-authentication.md). Neither is restated here. + +Ruby's `gems/dexpace-core/lib/dexpace/auth/` and Node's `packages/core/src/auth/` (`nodejs-sdk@54aeed4`) were read for the +vectors; this page is written from this repository's source. + +## Which scheme a call uses + +`AuthScheme` is `OAuth2`, `ApiKey`, `Basic`, `Digest` or `NoAuth` (`AUTH-1`). An `AuthRequirement` is a scheme with optional +scopes and parameters; an `AuthDescriptor` is an ordered, non-empty list of alternatives, strongest preference first +(`AUTH-2`, `AUTH-3`). `AuthResolver.Resolve` picks the first requirement that is `NoAuth` or whose scheme the policy can +serve, from the first tier that is present (`AUTH-4` to `AUTH-7`): + +| Tier | Where it rides | Set by | +|---|---|---| +| per call | `RequestOptions.Auth` | the caller of one operation | +| operation | `RequestOptions.OperationAuth` | generated code, from the operation's security list | +| client | the policy's own descriptor | the policy's constructor | + +A tier that is present and cannot be satisfied does **not** fall through to a lower one: it throws +`AuthResolutionException` (required and available schemes, no scopes) before anything is sent. `default(AuthScheme)` is +`OAuth2`, never the permissive `NoAuth`. A per-call `new AuthDescriptor(AuthRequirement.NoAuth)` sends the call anonymously +through any auth policy, skips the HTTPS guard, and returns a `401` unchanged. + +```csharp +var options = new RequestOptions { Auth = new AuthDescriptor(new AuthRequirement(AuthScheme.OAuth2) { Scopes = ["files.read"] }) }; +using var response = await pipeline.SendAsync(request, options, ct); +``` + +## The credentials + +| Type | Rules | `ToString` | +|---|---|---| +| `AccessToken` (struct) | token non-blank; `ExpiresOn` is `DateTimeOffset?` (`null` never expires, `AUTH-10`); equality over token, expiry and refresh hint | `AccessToken { Token = ***, … }` | +| `ApiKeyCredential` | key non-blank; `Scheme` null or non-blank without whitespace; the stamped value is checked against the outbound header grammar once, at construction (`AUTH-26`, `XCUT-18`) | key redacted | +| `BasicCredential` | username and password non-empty (whitespace allowed, `AUTH-14`); no `:` in the username (RFC 7617 section 2) | password redacted | +| `DigestCredential` | username and password non-empty; no encoding check until a challenge names a charset | password redacted | +| `AuthCredentials` (record) | optional `Token`, `ApiKey`, `Basic`, `Digest`, and a `TokenRefreshMargin` (30 s) | lists the configured schemes only | + +Every credential overrides `ToString` by hand, so a log line or an interpolated string never carries a secret (`AUTH-8`, +`XCUT-19`(d)). The `Security` class `CredentialRedactionTests` pins it for all of them. There is no `NamedKeyCredential`: a +prefixed key is `new ApiKeyCredential(key, scheme: "SharedAccessKey")` (P6c-19), so the named-key clauses of `AUTH-8` and +`AUTH-9` are vacuous in this port. + +## The policies + +All derive from `AuthorizationPolicy`, which owns the lifecycle and is the only thing that can sit at `PipelineStage.Auth` +(`AUTH-27`). The stage order is Redirect, Retry, Auth. + +| Policy | Client descriptor | Behaviour | +|---|---|---| +| `ApiKeyAuthPolicy` | `[ApiKey]` | stamps the key in its own header, preemptively | +| `BasicAuthPolicy` | `[Basic]` | stamps `Authorization: Basic …` preemptively, no `401` round trip | +| `BearerTokenAuthPolicy` | `[OAuth2 { Scopes }]` | stamps `Bearer ` from an `AccessTokenCache`; retries once on a `Bearer` `401` | +| `ChallengeAuthPolicy` | `[Digest, Basic]` | sends nothing first; answers a `401` through an `IChallengeHandler` | +| `MultiSchemeAuthPolicy` | the constructor's | several credentials at once, chosen per call by the descriptor tiers | + +What a generated SDK installs, per OpenAPI security scheme (design G): + +| OpenAPI scheme | Policy | +|---|---| +| `http`/`bearer`, `oauth2`, `openIdConnect` | `new BearerTokenAuthPolicy(credential, scopes)` | +| `http`/`basic` | `new BasicAuthPolicy(new BasicCredential(user, password))` | +| `http`/`digest` | `new ChallengeAuthPolicy(new DigestChallengeHandler(new DigestCredential(user, password)))` | +| `apiKey` in `header` | `new ApiKeyAuthPolicy(new ApiKeyCredential(key, HttpHeaderName.Of("X-Api-Key")))` | +| several of the above | `new MultiSchemeAuthPolicy(clientDescriptor, new AuthCredentials { … })`, with `RequestOptions.OperationAuth` per operation | +| `apiKey` in `query` or `cookie` | not expressible; `AUTH-26` is header-only (recorded in `docs/first-release.md`) | + +`MultiSchemeAuthPolicy` rejects, at construction, a client descriptor that does not allow anonymous calls and names no scheme +its `AuthCredentials` configures (a pinned reading; a descriptor that is unservable only at call time throws +`AuthResolutionException` instead). + +## The `401` lifecycle + +For each drive the base class does this, in order, on both the asynchronous and the synchronous path from one body: + +1. **Cross-origin first** (`AUTH-29`). If a redirect moved the request off the origin of the seed request, every withheld + credential header is removed, the request is driven once and the response returned **without** challenge handling, so a + foreign server cannot draw your credential by answering `401`. No guard and no hook run, and no marker is set on the + context. +2. **Resolve** the requirement (above). 3. **`NoAuth`**: drive and return. 4. **HTTPS guard** (`AUTH-28`): a non-`https` URL + (case-insensitive) throws `HttpsRequiredException` before any credential is resolved, also for a reactive scheme (Digest) + whose credential would be attached on the first `401`. There is no loopback exemption. 5. **Stamp** the credential, if the + policy has one for this pass. 6. **Drive**. +7. **A `401` with a `WWW-Authenticate` field** (`AUTH-33`; any other status or a `401` without the field returns as it is): + every field is parsed on its own and the policy's hook (`OnChallenge` / `OnChallengeAsync`) is called with an + `AuthChallengeContext` (`AUTH-30`). If the hook throws, the `401` is disposed first and the exception propagates; a failing + dispose is attached to it (`AUTH-32`). A `null` replacement returns the `401`. +8. **Checks.** The replacement must be same-origin (otherwise `InvalidOperationException` after disposing the `401`) and its + body, when present, replayable; a non-replayable body is **not sent** and the original `401` is returned undisposed + (`AUTH-31`). +9. **Replay.** The `401` is disposed and the replacement driven once through the same continuation; whatever comes back is + returned, with no second hook call. + +`ProcessAsync` reports every failure (resolver, guard, provider, hook) through the returned task, never by throwing +(`AUTH-38`). `HttpsRequiredException`, `AuthResolutionException` and `TokenProviderException` are `SdkException`s that are +never retryable. A subclass overrides `WithheldHeaderNames`, `GetCredential(Async)` (return `null` for "nothing to attach on +this pass") and, optionally, the hooks. + +## The token cache + +`AccessTokenCache` fronts a `TokenCredential` per `TokenRequestContext.CacheKey` (scopes plus claims), in a bounded map of +1024 keys (`XCUT-14`). For a token and the margin (30 s, `RefreshMargin`): + +- **fresh** (not within the margin of its expiry, not past its `RefreshOn`): returned at once, no lock. +- **expiring** (still valid): returned at once, and one background refresh per key is started. The refresh captures no + ambient state (`BackgroundWork`, the one `ExecutionContext.SuppressFlow` site), runs under no cancellation token, and a failure + is logged as event 160 `dexpace.auth.token_refresh_failed` at `Warning` with `error.type` and the exception, failing nothing + (`AUTH-37`). +- **expired or missing**: a foreground fetch, coalesced per key with a semaphore and a fetch generation. N requests at expiry + cost one fetch; during an outage the N waiters fail together after one attempt (they share one exception instance); a + request that arrives after the failure fetches again, so no failure is cached; a fetcher's own cancellation is never + adopted (`AUTH-34`). + +A default or already-expired token from the provider is a `TokenProviderException` (never containing the token, `AUTH-35`); a +provider exception propagates unwrapped (`AUTH-11`). `Get` is the real synchronous path (it calls `TokenCredential.GetToken`), +so no thread blocks on a task. The bearer `401` evicts the rejected token only if it is still the cached one, fetches a fresh +one under the same lock, and retries once; if the provider hands back the rejected token, the `401` surfaces (`AUTH-36`). +Claims challenges (continuous access evaluation) are not built: the retry re-fetches with the same `TokenRequestContext`. + +## Digest + +`DigestChallengeHandler` answers `Digest` challenges (RFC 7616): `MD5`, `MD5-sess`, `SHA-256`, `SHA-256-sess`, with `qop=auth` +or the legacy no-`qop` form (`AUTH-15` to `AUTH-17`). + +- **Preference.** The handler walks its preference, not wire order. The default is `SHA-256, SHA-256-sess, MD5, MD5-sess` + (`DigestChallengeHandler.DefaultPreference`); pass any non-empty, duplicate-free subset. +- **FIPS.** MD5 availability is probed once per process (`net10.0` has no `MD5.IsSupported`, so it tries a hash). On a host that + refuses MD5 the MD5 algorithms are dropped: an MD5-only challenge is declined, a server offering both gets SHA-256, and + construction never fails. +- **Declined, never answered wrongly:** no `realm` or `nonce`; only `auth-int`; an unsupported algorithm; a `-sess` algorithm + without `qop` (its response could not be verified, P6c-31); a realm, nonce or opaque that is not valid outbound header text; + a credential character the challenge's charset cannot represent. A declined attempt consumes no `nc`. +- **Encodings.** `charset=UTF-8` (any case) hashes UTF-8, otherwise ISO-8859-1. A username that is not printable ASCII goes out + as `username*=UTF-8''…` (or `ISO-8859-1''…`, RFC 8187). `digest-uri` is the escaped request-target the transport writes + (verified against the wire). `userhash` is never sent; `rspauth` is not verified. +- **Counters.** `nc` is the atomic count of responses for one nonce, 8 lower-case hex digits, in a bounded 1024-entry map + (`AUTH-18`, `AUTH-19`, `AUTH-24`); `cnonce` is 128 bits from `RandomNumberGenerator` (`AUTH-20`). + +```csharp +var policy = new ChallengeAuthPolicy(new CompositeChallengeHandler( + new DigestChallengeHandler(new DigestCredential(user, password)), + new BasicChallengeHandler(new BasicCredential(user, password)))); +``` + +A forward proxy's `407` never reaches the pipeline (the transport answers it, phase 8b); the handlers keep a `proxy` flag so a +caller composing its own proxy handling gets `Proxy-Authorization` (`AUTH-25`). + +## The challenge parser + +`AuthenticationChallenge.Parse(string?)` (or a `ReadOnlySpan`) reads the RFC 7235 grammar in one linear pass with no +regular expression and never throws (`AUTH-12`, `AUTH-13`). Scheme and parameter names fold to ASCII lower case (never by the +current culture), values are verbatim with quotes stripped and escapes resolved, a `token68` is under the key `token68`, and a +duplicate parameter keeps the **first** value (so a hostile later duplicate cannot overwrite a realm). Malformed text abandons +only the current element: it resumes after the next unquoted comma and keeps what was parsed; an unterminated quote ends at the +end of the input; a trailing backslash is dropped. After a comma, a token followed by `=` and then a character that is not `=` +is a parameter; anything else starts a new challenge. + +## What is not built + +Claims challenges (continuous access evaluation), `apiKey` in a query string or a cookie, preemptive Digest, a +`NamedKeyCredential`, and `407` handling in the pipeline (P6c-13, 19, 35, 40). They are recorded in +[`first-release.md`](../first-release.md). + +## Migrating from the pre-6c surface + +| # | Change | Rewrite | +|---|---|---| +| 1 | `AccessToken.ExpiresOn` is `DateTimeOffset?`; the token must be non-blank; equality and `==`; `ToString` redacts | read `ExpiresOn` with `.Value` or a pattern; pass `null` for a token that never expires | +| 2 | `ApiKeyCredential` rejects a whitespace-only key and a bad scheme or value at construction | fix the key; the failure is now at startup | +| 3 | `BasicCredential` rejects an empty username or password and a `:` in the username | encode such a credential differently | +| 4 | `TokenRequestContext.Scopes` is a copy | none | +| 5 | `AccessTokenCache` refreshes 30 s early in the background, logs failures, rejects bad tokens, is bounded | set `RefreshMargin` for the old behaviour (`TimeSpan.Zero` refreshes at expiry) | +| 6 | `AuthorizationPolicy`'s protected surface | derive with the new constructor, `WithheldHeaderNames` and the requirement-taking `GetCredential(Async)` | +| 7 | the HTTPS refusal is `HttpsRequiredException` | catch it (still an `SdkException`) | +| 8 | every policy honours the tiers and answers a `401`; the bearer policy retries once | pass `NoAuth` per call to opt out | +| 9 | `RequestOptions` equality includes the two descriptors | none | diff --git a/docs/work/mvp/2026-09-27-dotnet-sdk-v1-roadmap-design.md b/docs/work/mvp/2026-09-27-dotnet-sdk-v1-roadmap-design.md index f76232e..9cb0aab 100644 --- a/docs/work/mvp/2026-09-27-dotnet-sdk-v1-roadmap-design.md +++ b/docs/work/mvp/2026-09-27-dotnet-sdk-v1-roadmap-design.md @@ -267,7 +267,7 @@ ID in the clause is stated in that chapter. | 3 | I/O and Body Lifecycle (3a I/O · 3b bodies) | `Dexpace.Sdk.Core` | ch.05 — `IO-1`–`IO-42` (42); ch.06 — `BODY-1`–`BODY-37` (37), plus `HTTP-36`–`HTTP-45`, `HTTP-51`, `HTTP-52` (12), which are numbered jointly into that chapter; the work for `HTTP-44`/`HTTP-45` lands in 7a | §3.1, §3.7, §4.5; [3a design](phase3/phase3a/2026-10-02-phase3a-io-design.md) | | 4 | Execution Context, Recovery Chain and Pipeline Rework (4a context · 4b recovery · 4c pipeline) | `Dexpace.Sdk.Core` | ch.07 — `CTX-1`–`CTX-20` (20); ch.08 §8.2 and appendix C — `RECOV-1`–`RECOV-34` (34); ch.08 §8.1 — `PIPE-1`–`PIPE-40` (40) | §5.1–§5.4; [4a design](phase4/phase4a/2026-10-05-phase4a-context-design.md), [4b design](phase4/phase4b/2026-10-05-phase4b-recovery-design.md), [4c design](phase4/phase4c/2026-10-05-phase4c-pipeline-design.md) | | 5 | Configuration Model and Observability (5a configuration · 5b logging and redaction · 5c tracing and metrics) | `Dexpace.Sdk.Core`, and `Dexpace.Sdk.Http.SystemNet` for `traceparent` handling | ch.16 — `CFG-8`, `CFG-9`, `CFG-12`, `CFG-13`, `CFG-15`–`CFG-36` (the binding tier travels to phase 9); ch.15 — `OBS-1`–`OBS-40` (40) | §3.8, §8.1–§8.3; [5a design](phase5/phase5a/2026-10-07-phase5a-configuration-design.md), [5b design](phase5/phase5b/2026-10-07-phase5b-logging-design.md), [5c design](phase5/phase5c/2026-10-07-phase5c-tracing-design.md) | -| 6 | Retry, Redirect and Authentication Completion (6a retry · 6b redirect · 6c auth) | `Dexpace.Sdk.Core` | ch.09 — `RETRY-1`–`RETRY-45` (45); ch.10 — `REDIR-1`–`REDIR-28` (28); ch.11 — `AUTH-1`–`AUTH-38` (38); plus the work for the recovery-stack IDs `RECOV-17`–`RECOV-30` and `RECOV-34`, whose rows stay in phase 4 as ⏳ | §6.1–§6.3, §8.3; [6a design](phase6/phase6a/2026-10-08-phase6a-retry-design.md), [6a plan](phase6/phase6a/2026-10-08-phase6a-retry.md), [6b design](phase6/phase6b/2026-10-08-phase6b-redirect-design.md), [6b plan](phase6/phase6b/2026-10-08-phase6b-redirect.md), [6c design](phase6/phase6c/2026-10-08-phase6c-auth-design.md), [6c plan](phase6/phase6c/2026-10-08-phase6c-auth.md), [6a checklist](phase6/phase6a/2026-10-08-phase6a-retry-checklist.md), [6b checklist](phase6/phase6b/2026-10-08-phase6b-redirect-checklist.md) | +| 6 | Retry, Redirect and Authentication Completion (6a retry · 6b redirect · 6c auth) | `Dexpace.Sdk.Core` | ch.09 — `RETRY-1`–`RETRY-45` (45); ch.10 — `REDIR-1`–`REDIR-28` (28); ch.11 — `AUTH-1`–`AUTH-38` (38); plus the work for the recovery-stack IDs `RECOV-17`–`RECOV-30` and `RECOV-34`, whose rows stay in phase 4 as ⏳ | §6.1–§6.3, §8.3; [6a design](phase6/phase6a/2026-10-08-phase6a-retry-design.md), [6a plan](phase6/phase6a/2026-10-08-phase6a-retry.md), [6b design](phase6/phase6b/2026-10-08-phase6b-redirect-design.md), [6b plan](phase6/phase6b/2026-10-08-phase6b-redirect.md), [6c design](phase6/phase6c/2026-10-08-phase6c-auth-design.md), [6c plan](phase6/phase6c/2026-10-08-phase6c-auth.md), [6a checklist](phase6/phase6a/2026-10-08-phase6a-retry-checklist.md), [6b checklist](phase6/phase6b/2026-10-08-phase6b-redirect-checklist.md), [6c checklist](phase6/phase6c/2026-10-08-phase6c-auth-checklist.md) | | 7 | Serde, SSE and Pagination (7a serde · 7b SSE · 7c pagination) | `Dexpace.Sdk.Core`, `Dexpace.Sdk.Serialization.SystemTextJson` | ch.14 — `SERDE-1`–`SERDE-30` (30); ch.13 — `SSE-1`–`SSE-41` (41); ch.12 — `PAGE-1`–`PAGE-36` (36) | §3.4, §7.1–§7.3 | | 8 | Transports and the Conformance Kit (8a kit · 8b reference-transport hardening) | the new `Dexpace.Sdk.Conformance` package (phase 8 owns its csproj, version and first release), `Dexpace.Sdk.Http.SystemNet`, `tests/Dexpace.Sdk.Http.SystemNet.Tests` | ch.17 — `TRANSPORT-1`–`TRANSPORT-30` (30); ch.18 — `ASYNC-1`–`ASYNC-22` (22) | §2.1, §2.2, §3.2, §3.3, §3.7, §9.3 | | 9 | Host Integration | the new `Dexpace.Sdk.Extensions.DependencyInjection` package, plus its test project | ch.16 — `CFG-1`–`CFG-7`, `CFG-10`, `CFG-11`, `CFG-14`, `CFG-37`, `CFG-38`; `SEAM-5` and `SEAM-6`'s single-registration check (rows carried from 2b); `TRANSPORT-1` for named clients; `XCUT-10`'s one-retry-layer rule | §2.1, §3.6, §8.2 | @@ -1647,3 +1647,23 @@ clause, `RECOV-17`–`RECOV-31`, `RECOV-34`, `BODY-5`, the retry third of `BODY- - **Open rulings were taken as designed** (the lead had not ruled): P6b-2, P6b-3, P6b-4, P6b-7, P6b-8, P6b-10, P6b-16, P6b-19, P6b-21, P6b-25. - **Hand-offs.** 6c: `HttpOrigin` is offered for `AuthorizationPolicy.GetOrigin`; the leak test's auth half must stay green. 8b: `RedirectWireTests` and the borrowed-client detection are unchanged. 9: binding `RedirectOptions.AllowedMethods` needs a string to `Method` conversion (`Method.Of`); `Predicate` is code-only. 10: audits `XCUT-17` against the 6b evidence. + +**2026-10-08 — Phase 6c exit (authentication).** Sub-phase 6c closes on branch `74-phase-6c-auth` (issue #74), the plan's eight PRs as commits: all 38 `AUTH` rows ✅ (36 MUST, 2 SHOULD), +[checklist](phase6/phase6c/2026-10-08-phase6c-auth-checklist.md), user page [`auth.md`](../../sdk-documentation/auth.md). Issues #7 (challenge parser, Basic and composite handlers, the `401` hook and the bearer +retry) and #2 (RFC 7616 Digest) are closed by it, with their checklist rows named in the drafted closing comments. + +- **Built.** `AuthScheme`, `AuthRequirement`, `AuthDescriptor`, `AuthResolver` (per-call, operation and client tiers on `RequestOptions.Auth` / `OperationAuth` and the policy), `AuthResolutionException`, `HttpsRequiredException`, + `TokenProviderException`; the redacting `AccessToken`, `ApiKeyCredential`, `BasicCredential`, `DigestCredential` and `AuthCredentials`; `AuthenticationChallenge` and its lenient linear parser; + `IChallengeHandler`, `BasicChallengeHandler`, `CompositeChallengeHandler`, `DigestChallengeHandler`; `AuthorizationPolicy`'s `401` lifecycle with `AuthChallengeContext`; `ChallengeAuthPolicy` and + `MultiSchemeAuthPolicy`; `AccessTokenCache` rebuilt (zones, margin, coalescing, background refresh through `Internal/BackgroundWork.cs`, bounded, a real `Get`); log event 160. +- **Breaking changes** (nine, in `CHANGELOG.md` `[Unreleased]`): `AccessToken`; `ApiKeyCredential`; `BasicCredential`; `TokenRequestContext.Scopes` as a copy; `AccessTokenCache`; `AuthorizationPolicy`'s protected surface; + `HttpsRequiredException`; every policy honours the tiers and answers a `401` (and the bearer retry); `RequestOptions` equality. +- **Open rulings were taken as designed** (the lead had not ruled): P6c-2, P6c-6, P6c-7, P6c-8, P6c-12, P6c-17, P6c-19, P6c-21, P6c-25, P6c-29, P6c-31, P6c-33, P6c-36. The checklist's deviation ledger lists each; if the lead drops + `MultiSchemeAuthPolicy` (P6c-36), its commit and its tests go and `AUTH-4`/`AUTH-5` stay ✅ through the single-scheme policies and `AuthResolverTests`. +- **Security.** One `Security` class added (`CredentialRedactionTests`, `XCUT-19`(d)) and one strengthened (`AuthHttpsGuardTests`, the exact diff of plan task 4.5); every other class is untouched. +- **Verified facts** (plan task 0.1): `MD5.IsSupported` does not exist on `net10.0` (the probe tries a hash); `Uri.GetComponents(PathAndQuery, UriEscaped)` equals the wire request-target; the RFC vectors reproduce. + Recorded in `docs/knowledge/notes/authentication.md`. +- **Dated corrections** filed: design §5.4/§8.1, §6.3, §10 entry 16, §11 items 62 to 65, §12; the phase 1 (S4, S5), 3b (`BODY-4`), 4a (`BoundedMap` hand-off) and 4c (P4c-13) checklists. +- **Hand-offs.** 6b: the end-to-end credential-leak test builds from the shipped policies (their public construction is unchanged); a cross-origin hop is forwarded credential-free and its `401` is not challenge-handled. + 8b: the transport answers a proxy's `407`; the handlers' `proxy` flag lets a caller adapt them to `ICredentials`. 9: DI registration of an auth policy (an `AuthCredentials` bound from configuration). + 10: `XCUT-12`, `XCUT-14`, `XCUT-16`, `XCUT-18`, `XCUT-19`(d), `XCUT-21` cite 6c's tests. 12: claims challenges and `apiKey` in a query string or cookie are recorded in `docs/first-release.md`. diff --git a/docs/work/mvp/phase1/2026-09-28-phase1-security-fixes-checklist.md b/docs/work/mvp/phase1/2026-09-28-phase1-security-fixes-checklist.md index 563e8c2..23bd9ee 100644 --- a/docs/work/mvp/phase1/2026-09-28-phase1-security-fixes-checklist.md +++ b/docs/work/mvp/phase1/2026-09-28-phase1-security-fixes-checklist.md @@ -83,3 +83,9 @@ Each encoded the defect it now contradicts; none was loosened on behaviour it di | Test conventions | Every new test carries `[Trait("Category", "Security")]`; `TestCategoryTests` passes in both suites; core's suite still references no transport (`SEAM-2` architecture test) | | Trim and AOT | No reflection or dynamic code added (#18 uses `FrozenSet` and `LoggerMessage.Define`); AOT smoke consumer publishes and runs | | Styleguide-vs-design conflicts | None touched | + +## Correction 2026-10-08 (phase 6c) + +S4's open item is closed: the typed exception the row left to 6c is `HttpsRequiredException` (a subclass of `SdkException` carrying the policy name and the scheme), `AuthHttpsGuardTests` +asserts the exact type and the scheme, and the seed-origin note stands (`AUTH-29`). S5's `XCUT-19`(d) remainder is closed by `CredentialRedactionTests`, a new permanent `Security` +class (see the [6c checklist](../phase6/phase6c/2026-10-08-phase6c-auth-checklist.md)). The rows above stand as written at phase 1's exit. diff --git a/docs/work/mvp/phase3/phase3b/2026-10-03-phase3b-bodies-checklist.md b/docs/work/mvp/phase3/phase3b/2026-10-03-phase3b-bodies-checklist.md index d103647..a2b00a8 100644 --- a/docs/work/mvp/phase3/phase3b/2026-10-03-phase3b-bodies-checklist.md +++ b/docs/work/mvp/phase3/phase3b/2026-10-03-phase3b-bodies-checklist.md @@ -191,3 +191,9 @@ body that cannot be re-sent throws `RedirectBodyNotReplayableException` (a seeka idempotency is consulted, so a body-less `POST` is followed when `POST` is allowed). The evidence is the `REDIR-6:` rows of `RedirectDecisionMatrixTests`, the matrix row `REDIR-3: AllowedMethods containing POST follows a body-less POST` and `ReDriveLifecycleTests.A_redirect_over_a_non_replayable_body_throws_and_disposes_the_in_flight_response_once` (see the [6b checklist](../../phase6/phase6b/2026-10-08-phase6b-redirect-checklist.md#work-on-other-owners-rows)). The auth third of `BODY-4` stays ⏳ 6c. + +## Correction 2026-10-08 (phase 6c) + +The auth third of `BODY-4` is no longer ⏳: `AuthorizationPolicy` returns the original `401` undisposed instead of sending a replacement whose body is not replayable, on both +paths and for the bearer retry too (`AuthorizationPolicyChallengeTests.A_replacement_with_a_non_replayable_body_is_not_sent_…`, `BearerTokenAuthPolicyTests.A_stream_body_is_not_retried_…`; +see the [6c checklist](../../phase6/phase6c/2026-10-08-phase6c-auth-checklist.md#work-on-other-owners-rows)). The redirect third stays ⏳ 6b. The rows above stand as written. diff --git a/docs/work/mvp/phase4/phase4a/2026-10-07-phase4a-context-checklist.md b/docs/work/mvp/phase4/phase4a/2026-10-07-phase4a-context-checklist.md index 4441f20..9c2835a 100644 --- a/docs/work/mvp/phase4/phase4a/2026-10-07-phase4a-context-checklist.md +++ b/docs/work/mvp/phase4/phase4a/2026-10-07-phase4a-context-checklist.md @@ -45,3 +45,5 @@ Written 2026-10-07 from what was built, against the [design](2026-10-05-phase4a- - **Build and test:** `dotnet build` (warnings as errors), `dotnet format --verify-no-changes` and the full test run pass; the AOT smoke prints "aot-smoke: all checks passed". - **Dated correction, 2026-10-07 (phase 5a, P5a-24):** the hand-off "5a may make `ContextStore.DefaultCapacity` configurable" is closed as declined. The store is process-wide, so a per-client option cannot size it coherently, and a process-wide setter is the global slot `CFG-13` declines; `CTX-11` to `CTX-13` require a bound, not a knob. +- **Dated correction, 2026-10-08 (phase 6c):** the hand-off "6c's `AUTH-19` nonce store and token cache build on `BoundedMap`" is closed: `AccessTokenCache` keeps a `BoundedMap` of + 1024 keys and `DigestChallengeHandler` a `BoundedMap` of 1024 nonces, both through the new internal `BoundedMapExtensions.GetOrAdd` (`BoundedMap` has no get-or-add). diff --git a/docs/work/mvp/phase4/phase4c/2026-10-07-phase4c-pipeline-checklist.md b/docs/work/mvp/phase4/phase4c/2026-10-07-phase4c-pipeline-checklist.md index 6a0b6c2..92774ba 100644 --- a/docs/work/mvp/phase4/phase4c/2026-10-07-phase4c-pipeline-checklist.md +++ b/docs/work/mvp/phase4/phase4c/2026-10-07-phase4c-pipeline-checklist.md @@ -142,3 +142,8 @@ and `git diff -U0 main -- tests/Dexpace.Sdk.Core.Tests/Security` shows exactly t predicate dispose first, a failing dispose riding the exception's suppressed trail; every one of the five stop reasons returns the response open) and the split `ReDriveLifecycleTests`. The "non-replayable body" abandon path in `PIPE-40`'s list is read as retry's; redirect's is governed by `REDIR-6` and `REDIR-22`(b), which throw after disposing (design §11 item 59, P6b-17). The row above stands as written at 4c's exit. + +## Correction 2026-10-08 (phase 6c) + +P4c-13's hand-off to 6c is closed: `AuthorizationPolicy.GetCredential` is abstract and the base's sync-over-async bridge and its `RS0030` pragma are gone; the bearer policy's synchronous path is +`AccessTokenCache.Get` (see the [6c checklist](../../phase6/phase6c/2026-10-08-phase6c-auth-checklist.md)). One fewer documented bridge remains in core. The rows above stand as written. diff --git a/docs/work/mvp/phase6/phase6c/2026-10-08-phase6c-auth-checklist.md b/docs/work/mvp/phase6/phase6c/2026-10-08-phase6c-auth-checklist.md new file mode 100644 index 0000000..fa207d8 --- /dev/null +++ b/docs/work/mvp/phase6/phase6c/2026-10-08-phase6c-auth-checklist.md @@ -0,0 +1,207 @@ +# Phase 6c — Authentication: Checklist + +The execution-time checklist for sub-phase 6c of roadmap phase 6 +([phase card](../../2026-09-27-dotnet-sdk-v1-roadmap-design.md)): one row per requirement ID, in the legend of the roadmap's +cross-cutting constraint 3. Written from what was built on branch `74-phase-6c-auth` (issue #74), following the +[design](2026-10-08-phase6c-auth-design.md) and the [plan](2026-10-08-phase6c-auth.md). User page: +[`auth.md`](../../../../sdk-documentation/auth.md). + +The scope is 38 rows, `AUTH-1`-`AUTH-38` (36 MUST, 2 SHOULD: `AUTH-19`, `AUTH-38`), all ✅. 6c also did the work for the rows of other owners listed in the +[carried-rows table](#work-on-other-owners-rows); each of those owners' checklists gets one dated correction (task 8.4). Every test is +`[Trait("Category", "Unit")]` and lives in `tests/Dexpace.Sdk.Core.Tests/` unless another place is named; `CredentialRedactionTests` and `AuthHttpsGuardTests` are `Security`, +`DigestWireTests` is `Integration` (`tests/Dexpace.Sdk.Http.SystemNet.Tests/`), and the smoke check is `AotSmoke`. + +**Adaptation to the as-built code.** The plan's eight PRs landed as commits on the one branch in the plan's order: descriptor and resolver, credentials, challenge parser +with the Basic and composite handlers, the `401` lifecycle, the token cache (PR 5), Digest, the shared stampers (`chore:`) with `MultiSchemeAuthPolicy`, and the close-out +(the AOT smoke, the user page, this checklist, the dated corrections). The deviations from the plan are listed [below](#deviations-from-the-plan). + +**Red evidence.** Tests were written beside the production change in each commit, and for every new type the first compile of the test project was red (a missing type +or member). The plan's request to prove each *pin* able to fail by temporary mutation (convention 1) was **not** done for the pins that pass on unchanged behaviour +(`The_stage_order_is_Redirect_then_Retry_then_Auth`, `Stage_is_Auth_and_sealed`, `A_second_auth_policy_in_one_pipeline_is_rejected_by_the_builder`, +`SuppressFlow_appears_only_in_BackgroundWork` has a scanner self-test instead). + +| Mark | Meaning | +|---|---| +| ✅ | Implemented and tested. The row names the test, the `[Trait]` category and the file. | +| 🚫 | Not built: a permanent simplification, with the reason and the design §10 topic or §11 resolution it rests on. | +| ⏳ | Deferred or declined. The row names the plan task that will do it, or the `docs/first-release.md` entry that owns it. | +| N/A | Not applicable in this port. The row cites the design section that retires it. | + +## Requirement rows + +Test files (all under `tests/Dexpace.Sdk.Core.Tests/` unless named): `Auth/{AuthDescriptorTests,AuthResolverTests,CredentialTypesTests,AuthenticationChallengeTests,BasicChallengeHandlerTests, +DigestChallengeHandlerTests,DigestComputationTests,Md5AvailabilityTests,NonceCounterTests,AccessTokenCacheTests}.cs`; `Http/Request/RequestOptionsTests.cs`; +`Pipeline/Policies/{AuthorizationPolicyChallengeTests,ApiKeyAuthPolicyTests,BasicAuthPolicyTests,BearerTokenAuthPolicyTests,ChallengeAuthPolicyTests,MultiSchemeAuthPolicyTests}.cs`; +`Pipeline/BearerPipelineTests.cs`; `Security/{CredentialRedactionTests,AuthHttpsGuardTests}.cs`; `Exceptions/{HttpsRequiredExceptionTests,TokenProviderExceptionTests}.cs` (folder `Errors/`); +`Diagnostics/AuthLogTests.cs`; `Internal/{AuthOriginTests,BackgroundWorkTests,BoundedMapExtensionsTests}.cs`; `Architecture/BannedSymbolsSiteTests.cs`; +`tests/Dexpace.Sdk.Http.SystemNet.Tests/DigestWireTests.cs` (`Integration`); the AOT smoke check is `CheckPhase6cAuthAsync` in `tests/Dexpace.Sdk.AotSmoke/SmokeChecks.cs`. +Vectors: `tests/vectors/auth/{challenges,digest}.json`. + +| ID | Level | Mark | Evidence | +|---|---|---|---| +| `AUTH-1` | MUST | ✅ | `AuthDescriptorTests.AuthScheme_values_are_explicit_and_exactly_five`, `A_requirement_rejects_an_undefined_scheme`, `NoAuth_is_the_anonymous_sentinel` (Unit) | +| `AUTH-2` | MUST | ✅ | `AuthDescriptorTests.Scopes_and_parameters_are_copied_at_init`, `Scopes_and_parameters_are_preserved_and_never_interpreted`, `Requirements_with_equal_content_are_equal_and_hash_alike`, `A_requirement_does_not_print_a_parameter_value` (Unit) | +| `AUTH-3` | MUST | ✅ (P6c-4: `ImmutableArray` implements `IList` explicitly, so read-only is asserted by the mutator throwing, not by an uncastable type) | `AuthDescriptorTests.A_descriptor_copies_its_requirements_and_exposes_them_read_only`, `An_empty_descriptor_or_a_null_element_throws_ArgumentException`, `AllowsAnonymous_is_true_iff_any_requirement_is_NoAuth`, `A_descriptor_has_no_init_member_so_with_cannot_empty_it` (Unit) | +| `AUTH-4` | MUST | ✅ (P6c-6, P6c-7) | `AuthResolverTests.Per_call_wins_over_operation_and_client`, `Operation_wins_over_client`, `Client_is_used_when_the_others_are_absent`, `A_higher_tier_that_cannot_be_satisfied_does_not_fall_through`; `RequestOptionsTests.Auth_and_OperationAuth_default_to_null` and the equality cases; `AuthorizationPolicyChallengeTests.The_operation_tier_is_used_when_no_per_call_tier_is_set`; `MultiSchemeAuthPolicyTests` (Unit) | +| `AUTH-5` | MUST | ✅ | `AuthResolverTests.The_first_satisfiable_requirement_in_preference_order_wins`, `NoAuth_is_always_satisfiable`, `No_credential_is_inspected`; `MultiSchemeAuthPolicyTests.The_client_descriptor_order_decides` (Unit) | +| `AUTH-6` | MUST | ✅ | `AuthResolverTests.All_tiers_absent_throws_ArgumentException`, `Nothing_satisfiable_throws_AuthResolutionException_with_required_and_available`; `AuthorizationPolicyChallengeTests.A_per_call_scheme_the_policy_cannot_serve_throws_AuthResolutionException_before_sending` (Unit) | +| `AUTH-7` | MUST | ✅ | `AuthResolverTests.Resolve_is_safe_under_concurrent_calls_on_one_descriptor` (a static class with no field) (Unit) | +| `AUTH-8` | MUST | ✅ (P6c-19: the named-key clause is vacuous in a port with one key type) | `CredentialRedactionTests` (all four), `AccessTokenTests.AccessToken_equality_is_by_value_over_token_expiry_and_refresh`, `AccessToken_ToString_redacts_the_token`, `ApiKeyCredentialTests.ApiKeyCredential_ToString_redacts_the_key`, `BasicCredentialTests.BasicCredential_ToString_redacts_the_password` (Unit, Security) | +| `AUTH-9` | MUST | ✅ (the named-key clause is vacuous) | `AccessTokenTests.AccessToken_rejects_null_empty_and_whitespace_tokens`, `ApiKeyCredentialTests.ApiKeyCredential_rejects_a_whitespace_only_key` (Unit) | +| `AUTH-10` | MUST | ✅ | `AccessTokenTests.AccessToken_without_expiry_never_expires`, `IsExpired_is_strictly_after` (Unit) | +| `AUTH-11` | MUST | ✅ (met before; now with a sync-throw arm) | `AccessTokenCacheTests.A_provider_exception_propagates_unwrapped_and_nothing_is_cached`, `A_synchronous_throw_from_a_non_async_GetTokenAsync_override_surfaces_through_the_returned_task` (Unit) | +| `AUTH-12` | MUST | ✅ | `AuthenticationChallengeTests.Parse_matches_every_vector` (46 rows of `tests/vectors/auth/challenges.json`, both overloads), `Scheme_and_parameter_names_are_lower_cased_with_ASCII_folding_only` (en-US and tr-TR), `Values_are_never_folded`, `The_constructor_folds_parameter_names_and_copies_them`, `Token68_is_Parameters_token68` (Unit) | +| `AUTH-13` | MUST | ✅ | `AuthenticationChallengeTests.Parse_never_throws_for_arbitrary_text` (5 000 seeded inputs), `Parse_never_throws_over_the_metacharacter_alphabet` (100 000 inputs), `Parse_is_linear_in_the_input`, and the leniency rows of the vectors (Unit) | +| `AUTH-14` | MUST | ✅ (P6c-17: the `:` rule is stricter than both siblings) | `BasicChallengeHandlerTests.Value_is_Basic_plus_base64_of_UTF8_user_colon_password`, `The_value_equals_the_one_BasicAuthPolicy_stamps_preemptively`; `BasicCredentialTests.BasicCredential_rejects_empty_username_or_password_but_allows_whitespace`, `BasicCredential_rejects_a_colon_in_the_username`; `BasicAuthPolicyTests.Preemptive_Basic_is_stamped_on_the_first_request` (Unit) | +| `AUTH-15` | MUST | ✅ (MD5 is platform-dependent: design §10 entry 16, P6c-30) | `DigestChallengeHandlerTests` FIPS cases (`With_MD5_unavailable_an_MD5_only_challenge_is_declined`, `…_falls_through_to_SHA256`, `Construction_never_fails_on_a_host_without_MD5_…`), `Every_vector_row_produces_its_expected_Authorization_or_null`, `No_rspauth_verification`; `Md5AvailabilityTests`; `DigestAlgorithmTests` (Unit) | +| `AUTH-16` | MUST | ✅ (a `-sess` challenge without qop is declined: P6c-31, design §11 new item) | `DigestChallengeHandlerTests.Chosen_by_configured_preference_not_wire_order`, `Sess_without_qop_is_declined`, `Satisfiable_requires_digest_realm_nonce`, `Qop_absent_or_a_list_containing_auth_is_accepted`, `Algorithm_absent_means_MD5`, `A_caller_preference_is_any_non_empty_duplicate_free_subset` (Unit) | +| `AUTH-17` | MUST | ✅ | `DigestComputationTests.The_RFC_2617_vectors_reproduce`, `The_RFC_7616_inputs_give_the_re_derived_SHA256_values`, `HA1_sess_folds_nonce_and_cnonce`, `Hex_is_lower_case`; the 38 rows of `tests/vectors/auth/digest.json` (Unit) | +| `AUTH-18` | MUST | ✅ | `NonceCounterTests.First_use_of_a_nonce_is_nc_00000001`, `Reuse_of_the_same_nonce_increments`, `A_different_nonce_starts_at_one`, `Nc_is_eight_lower_case_hex_digits_of_the_low_32_bits` (Unit) | +| `AUTH-19` | SHOULD | ✅ | `NonceCounterTests.The_store_is_a_BoundedMap_of_1024` (Unit) | +| `AUTH-20` | MUST | ✅ | `DigestChallengeHandlerTests.The_cnonce_default_is_32_lower_case_hex_characters_and_fresh_each_time`; `DigestComputationTests.The_cnonce_source_is_RandomNumberGenerator` (Unit) | +| `AUTH-21` | MUST | ✅ (P6c-32: an unrepresentable credential is declined, not hashed lossily) | `DigestComputationTests.Encoding_is_UTF8_under_charset_UTF8_else_Latin1`, `A_Latin1_unrepresentable_credential_is_reported_as_not_encodable_not_hashed_with_question_marks`; vector rows `utf8_charset_*`, `latin1_*`; `DigestChallengeHandlerTests.A_declined_attempt_does_not_advance_nc` (Unit) | +| `AUTH-22` | MUST | ✅ (P6c-33: `username*`; P6c-34: the escaped request-target) | `DigestChallengeHandlerTests.Username_realm_nonce_uri_response_cnonce_opaque_are_quoted_qop_nc_algorithm_are_bare`, `Algorithm_is_always_emitted_in_full_spelling`, `A_username_with_non_ascii_goes_out_as_username_star`, `An_unechoable_realm_nonce_or_opaque_is_declined`; `DigestWireTests.The_uri_parameter_equals_the_request_target_the_server_received` (Integration) (Unit, Integration) | +| `AUTH-23` | MUST | ✅ | `CompositeChallengeHandlerTests` (all), `ChallengeAuthPolicyTests.A_composite_digest_then_basic_falls_back_to_basic_when_digest_declines`, `A_composite_prefers_digest_when_the_server_offers_both` (Unit) | +| `AUTH-24` | MUST | ✅ | `NonceCounterTests.Sixty_four_parallel_Authorize_calls_on_one_nonce_yield_nc_1_to_64_without_duplicates`, `Handlers_hold_no_state_except_the_counters`; `CompositeChallengeHandlerTests.Is_stateless_and_safe_under_parallel_use` (Unit) | +| `AUTH-25` | MUST | ✅ | `ChallengeHandlerContractTests` (Basic, composite, two Digest challenges), `BasicChallengeHandlerTests.Sets_Proxy_Authorization_when_proxy_is_true`, `DigestChallengeHandlerTests.Proxy_true_sets_Proxy_Authorization_only` (Unit) | +| `AUTH-26` | MUST | ✅ (met but unreconciled in §12 before; now addressed, P6c-19) | `ApiKeyCredentialTests.ApiKeyCredential_scheme_must_be_null_or_non_blank_without_whitespace`, `ApiKeyCredential_rejects_a_value_the_outbound_header_grammar_refuses`, `ApiKeyCredential_exposes_the_stamped_value_computed_once`; `ApiKeyAuthPolicyTests.The_header_value_is_stamped_from_the_credential_computed_once`, `A_custom_header_is_withheld_cross_origin` (Unit) | +| `AUTH-27` | MUST | ✅ (pin) | `AuthorizationPolicyChallengeTests.Stage_is_Auth_and_sealed`, `The_stage_order_is_Redirect_then_Retry_then_Auth`, `A_second_auth_policy_in_one_pipeline_is_rejected_by_the_builder`; `BearerPipelineTests` (Unit) | +| `AUTH-28` | MUST | ✅ (the guard covers a reactive scheme's outbound pass: P6c-8) | `AuthHttpsGuardTests` (strengthened: `HttpsRequiredException`, scheme `http`), `AuthorizationPolicyChallengeTests.A_non_https_url_throws_HttpsRequiredException_before_any_credential_is_resolved`, `The_guard_also_covers_a_reactive_schemes_outbound_pass`, `Case_insensitive_scheme_compare`; `ChallengeAuthPolicyTests.The_guard_refuses_http_before_the_first_request`; `HttpsRequiredExceptionTests` (Unit, Security) | +| `AUTH-29` | MUST | ✅ (pin; no marker, §10 entry 15; P6c-12: a cross-origin `401` is not challenge-handled) | `AuthorizationPolicyChallengeTests.A_cross_origin_hop_has_its_credential_header_removed_and_is_not_guarded`, `A_cross_origin_401_is_returned_without_challenge_handling`, `No_marker_property_is_set_on_the_context`; `BearerPipelineTests.A_pipeline_with_redirect_forwards_no_bearer_cross_origin`; `AuthOriginTests` (Unit) | +| `AUTH-30` | MUST | ✅ | `AuthorizationPolicyChallengeTests.A_401_with_WWW_Authenticate_calls_the_hook_with_the_parsed_challenges`, `A_non_null_replacement_is_driven_once_through_the_continuation`, `No_further_challenge_handling_after_the_replay`, `The_default_hook_yields_null_and_the_401_is_returned`, `The_401_is_disposed_before_the_replay_and_the_returned_response_is_the_replays`, `Each_WWW_Authenticate_field_is_parsed_on_its_own`; `ChallengeAuthPolicyTests`; `BearerTokenAuthPolicyTests.A_second_401_after_the_retry_is_returned_without_another_retry` (Unit) | +| `AUTH-31` | MUST | ✅ | `AuthorizationPolicyChallengeTests.A_replacement_with_a_non_replayable_body_is_not_sent_and_the_original_401_is_returned_undisposed` (both paths), `A_replacement_with_a_replayable_or_no_body_is_sent`, `The_gate_applies_to_every_replacement_regardless_of_which_hook_built_it`; `BearerTokenAuthPolicyTests.A_stream_body_is_not_retried_and_the_401_is_returned_undisposed`, `The_retry_is_method_agnostic` (Unit) | +| `AUTH-32` | MUST | ✅ | `AuthorizationPolicyChallengeTests.A_hook_that_throws_disposes_the_401_and_propagates_the_exception` (sync hook, async hook, synchronous throw from the async override), `A_failing_dispose_is_suppressed_onto_the_hook_exception` (Unit) | +| `AUTH-33` | MUST | ✅ | `AuthorizationPolicyChallengeTests.A_401_without_WWW_Authenticate_is_returned_unchanged_and_the_hook_is_not_called`, `A_non_401_with_the_header_is_returned_unchanged` (Unit) | +| `AUTH-34` | MUST | ✅ | `AccessTokenCacheTests.The_default_margin_is_30_seconds_and_configurable`, `The_hot_path_takes_no_lock`, `A_negative_margin_throws`, `N_concurrent_requests_at_expiry_cost_one_fetch`, `With_a_failing_provider_N_waiters_fail_together_after_one_attempt`, `A_waiters_own_cancellation_leaves_the_fetch_running`, `A_fetchers_own_cancellation_is_not_adopted_by_the_waiters` (Unit) | +| `AUTH-35` | MUST | ✅ | `AccessTokenCacheTests.A_default_token_from_the_provider_throws_TokenProviderException`, `A_token_already_expired_at_fetch_throws_TokenProviderException_and_never_contains_the_token`, `A_request_arriving_after_a_failure_fetches_again`; `TokenProviderExceptionTests` (Unit) | +| `AUTH-36` | MUST | ✅ | `AccessTokenCacheTests.A_rejection_of_the_current_token_evicts_it_and_fetches_fresh`, `A_rejection_of_a_stale_header_keeps_a_token_another_request_already_refreshed`, `Eviction_and_fetch_happen_under_one_critical_section`; `BearerTokenAuthPolicyTests.A_401_with_a_Bearer_challenge_evicts_fetches_fresh_and_retries_once` (both paths), `A_401_with_a_non_bearer_challenge_is_returned`, `A_provider_that_hands_back_the_rejected_token_yields_no_retry` (Unit) | +| `AUTH-37` | MUST | ✅ | `AccessTokenCacheTests.A_token_inside_the_margin_is_stamped_now_and_refreshed_in_the_background`, `Only_one_background_refresh_runs_per_key_however_many_requests_arrive`, `The_background_refresh_sees_no_ambient_Activity`, `A_failing_background_refresh_logs_event_160_once_and_the_triggering_call_is_unaffected`, `The_refresh_task_is_observed_and_completes_without_faulting`; `AuthLogTests`; `BackgroundWorkTests` (Unit) | +| `AUTH-38` | SHOULD | ✅ (by construction, pinned) | `AuthorizationPolicyChallengeTests.ProcessAsync_returns_a_faulted_task_instead_of_throwing_for_an_http_url`, `ProcessAsync_faults_for_a_resolver_failure_and_a_credential_that_throws_synchronously`, `Process_throws_the_original_exception_on_the_sync_path` (Unit) | + +Count: **38 rows**, all ✅ (0 ⏳, 0 🚫, 0 N/A). + +### Work on other owners' rows + +These carry no mark in this checklist. 6c's tests are cited by the owner's row. + +| ID (owner) | Work | Evidence | +|---|---|---| +| `XCUT-12` (10) | Per-key semaphore, never global | `AccessTokenCacheTests.Different_keys_do_not_block_each_other` | +| `XCUT-14` (10) | The token cache and the Digest nonce store are `BoundedMap`s | `AccessTokenCacheTests.The_cache_holds_at_most_1024_keys_once_quiescent`, `An_evicted_live_entry_costs_one_extra_fetch_and_no_failure`; `NonceCounterTests.The_store_is_a_BoundedMap_of_1024` | +| `XCUT-16` (10) | The typed guard and the reactive reading | `AuthHttpsGuardTests`, `HttpsRequiredExceptionTests` | +| `XCUT-18` (10) | The echoed Digest values and the key value pass the outbound grammar | `ApiKeyCredentialTests.ApiKeyCredential_rejects_a_value_the_outbound_header_grammar_refuses`, `DigestChallengeHandlerTests.An_unechoable_realm_nonce_or_opaque_is_declined` | +| `XCUT-19`(d) (10; phase 1 S5 remainder) | Credentials never reveal secrets in string form | `CredentialRedactionTests` (Security) | +| `XCUT-21` (10) | The cnonce is from `RandomNumberGenerator` | `DigestComputationTests.The_cnonce_source_is_RandomNumberGenerator` | +| `BODY-4` (3b) | The `AUTH-31` gate, auth's third of the hand-off | the `AUTH-31` tests | +| `PIPE-5` (4c) | One policy per pillar | `AuthorizationPolicyChallengeTests.A_second_auth_policy_in_one_pipeline_is_rejected_by_the_builder` | +| `PIPE-28` (4c) | One fewer documented bridge in core | `BearerTokenAuthPolicyTests.Process_sync_stamps_through_the_real_sync_path`, `The_sync_and_async_paths_share_one_cache`; `AuthorizationPolicyChallengeTests.The_sync_and_async_paths_produce_identical_requests` | +| `CTX-19` (4a) | Background work does not pin a call | `BackgroundWorkTests.The_work_observes_no_Activity_current` | +| `OBS-39` (5b) | Event id 160 inside the reserved auth range | `AuthLogTests.TokenRefreshFailed_is_id_160_named_dexpace_auth_token_refresh_failed_at_Warning`; `LogVocabularyTests.Id_ranges_are_inside_the_reserved_blocks` | +| `REDIR-7`, `REDIR-11`, `REDIR-24` (6b/4c) | The seed origin the policy compares against; no marker | `AuthorizationPolicyChallengeTests` cross-origin cases | + +## Existing assertions changed + +None was deleted without a replacement. + +| Test | Was | Now | +|---|---|---| +| `BasicAuthPolicyTests.ProcessAsync_EmptyPassword_StampsCorrectly` | an empty password stamps `Basic dXNlcjo=` | `BasicCredential_with_an_empty_password_is_rejected_at_construction` (`AUTH-14`, breaking 3) | +| `AccessTokenCacheTests.GetAsync_PastRefreshOn_RefreshesOnce` | the refresh was awaited on the request path and the call returned the new token | `Past_RefreshOn_the_valid_token_is_stamped_and_one_background_refresh_runs` (breaking 5) | +| `AccessTokenCacheTests.GetAsync_RefreshThrows_WhileStillValid_ReturnsCachedToken` | a failed refresh was swallowed silently | `A_failed_background_refresh_while_valid_stamps_the_valid_token_and_logs` (event 160) | +| `BearerTokenAuthPolicyTests.Process_sync_stamps_through_the_documented_bridge` | the sync path blocked on the async one | `Process_sync_stamps_through_the_real_sync_path` (the credential's `GetTokenAsync` throws; `GetToken` answers) | +| `TokenRequestContextTests.Ctor_SetsScopes` | `Assert.Same(scopes, ctx.Scopes)` | `Assert.Equal` (the scopes are a copy, breaking 4) | +| `LogVocabularyTests.Id_ranges_are_inside_the_reserved_blocks` (not `Security`) | 160 to 169 reserved and unused | 160 is `TokenRefreshFailedId`; 161 to 169 stay reserved | +| `AuthHttpsGuardTests` (`Security`) | `typeof(SdkException)`, `ThrowsAsync`, the old `CountingAuthPolicy` signature | the exact diff of plan task 4.5: the re-signatured probe, `typeof(HttpsRequiredException)`, `ThrowsAsync`, `Assert.Equal("http", ex.Scheme)`; nothing removed or loosened | + +## Security evidence + +`git diff --stat origin/main...HEAD -- tests/Dexpace.Sdk.Http.SystemNet.Tests/Security tests/Dexpace.Sdk.Core.Tests/Security` lists exactly two files: `CredentialRedactionTests.cs` (added) +and `AuthHttpsGuardTests.cs` (modified as above). Every other `Security` class is untouched, and the two Security suites pass (see the gate results below). The SystemNet and +System.Text.Json `PublicAPI.Unshipped.txt` files are unchanged. + +## Vector provenance + +| File | Source | Not ported, and why | +|---|---|---| +| `tests/vectors/auth/challenges.json` (46 rows) | `ruby-sdk gems/dexpace-core/test/dexpace/auth/{challenge,challenges}_test.rb` and `test/support/challenge_fixtures.rb` (the eight strings are `ChallengeFixtures`); `nodejs-sdk@54aeed4 packages/core/src/auth/challenge.test.ts`; RFC 7235 section 2.1; RFC 6750; RFC 7617 | Ruby's frozen-ness and `.build` construction cases (a different language's object model); the 100 KB and `Process.clock_gettime` timing tests (replaced by the allocation-ratio and 1 MiB tests); the per-pattern `Regexp#timeout` pins (no regular expression exists here); Node's fast-check properties (replaced by two seeded loops). Node's duplicate-last-wins differs from P6c-15 and the `duplicate_parameter_first_wins` row says so | +| `tests/vectors/auth/digest.json` (38 rows) | RFC 2617 section 3.5; RFC 7616 section 3.9.1; `ruby-sdk .../digest_handler_test.rb`; `nodejs-sdk@54aeed4 packages/core/src/auth/digest.test.ts` | The expected `Authorization` values were generated by an **independent Python implementation** (`hashlib`), not by the C# code, and the four published values were asserted equal in that script. Ruby's `UnencodableCredentialError` cases became declines (P6c-32); Node's `-sess` cnonce-without-qop differs (P6c-31); Node's `md5.test.ts` is not ported (it tests Node's own MD5) | + +## To-verify facts (plan task 0.1), run on 10.0.401 + +The note is `phase6c-preflight.md` in the implementer's scratch directory (not committed); the outcomes: + +| Fact | Outcome | +|---|---| +| 4 | `Convert.ToHexStringLower([0xAB, 0x0F])` is `ab0f` | +| 7 | `MD5.HashData([])` succeeds on this host and `MD5.IsSupported` does **not** exist on `net10.0`, so the probe tries a hash (R13). No FIPS-mode OpenSSL was available, so the FIPS exception type was not observed; the probe catches `CryptographicException` and `PlatformNotSupportedException` | +| 8 | A task started inside `ExecutionContext.SuppressFlow()` while `Activity.Current` is non-null sees `Activity.Current == null` | +| 9 | `SocketsHttpHandler` writes exactly `Uri.GetComponents(PathAndQuery, UriEscaped)` as the request-target for `/a%2Fb?x=%26y`, an empty path (`/`), an empty path with a query (`/?q=1`) and `/p%20q/r?a=b%20c&d=%7E` (both write `d=~`). P6c-34 stands; `DigestWireTests` pins it | +| 13 | The RFC 2617 vectors and the re-derived SHA-256 and SHA-256-sess values equal the design's | +| extras | `SemaphoreSlim.Wait(0)` returns true then false; `Encoding.Latin1.GetBytes("é€")` is `E9 3F`; `RandomNumberGenerator.GetHexString(32, lowercase: true)` is 32 characters; an exception-fallback ISO-8859-1 encoding throws `EncoderFallbackException` on an unrepresentable character | + +The knowledge queries named in the plan were not run: `scripts/knowledge` is a `dotnet run` wrapper over the repository's own tools, and the implementer worked with the +SDK from a scratch install rather than the global `dotnet`. The design's file-based reading of the corpus stands. + +**Flake guard (plan task 5.3).** `AccessTokenCacheTests` (42 tests, including every concurrency case) was run 200 times in a row against the Release binary: 200 runs, 0 failures. + +## Deviations from the plan + +1. **`AuthDescriptor.Requirements` is castable to `IList`** (task 1.1's `…not castable to IList` assertion): `ImmutableArray` implements `IList` explicitly. The test asserts what the requirement + needs (every mutator throws `NotSupportedException`). +2. **Two `#pragma warning disable CA5351` sites** (`DigestComputation.HashHex`, `Md5Availability`'s probe) with a why-comment: MD5 is the Digest protocol's own hash. The plan said to add no waiver for `MA0051`; this is a different rule and the only one added. +3. **`HttpsRequiredExceptionTests`, `TokenProviderExceptionTests` live in `tests/…/Errors/` but declare `namespace …Tests.Exceptions`**: a `Tests.Errors` namespace would shadow `Dexpace.Sdk.Core.Errors` for the rest of the test project. +4. **`A_401_without_a_stamped_Authorization_header_is_returned` (task 5.5) was not written**: no public path produces a bearer `401` whose stamped request lacks the header (a `NoAuth` call and a cross-origin hop never reach the hook). The case is replaced by `A_401_without_WWW_Authenticate_is_returned_without_a_retry`. +5. **`No_marker_property_is_set_on_the_context`** asserts through reflection that `CallState`'s lazily created property dictionary is still `null` (the dictionary is keyed by reference, so keys cannot be enumerated). +6. **The Digest handler tries the next candidate when the best one cannot carry the credential** (a Latin-1 challenge preferred over a UTF-8 one for a password with a character above U+00FF): the plan said "decline"; the handler declines only when no candidate can be answered. The vector `latin1_unrepresentable_falls_to_a_utf8_challenge` pins it. +7. **`nc` is consumed only when `qop` is negotiated**: the legacy no-`qop` form has no `nc` (the plan was silent). +8. **`AccessTokenCache.GetAfterRejection(Async)` takes no logger** (the plan listed `(context, rejectedHeaderValue, ct)`; the design block had a logger overload, which nothing needs). +9. **`BackgroundWork.Run` skips `SuppressFlow` when flow is already suppressed** (a nested call would throw `InvalidOperationException`). +10. **`AccessTokenCache` test doubles**: the cache tests share an `internal sealed class GatedCredential` (a per-call `TaskCompletionSource` gate) in `AccessTokenCacheTests.cs`; `file`-scoped types cannot appear in a member signature. +11. **The `LogVocabularyTests` id-range test was edited** (listed above); the plan did not name it. +12. **`ChallengeHandlerContractTests` covers Basic, composite and two Digest challenges** (the plan listed two shipped handlers plus Digest "added in task 6.4"). +13. **The five-PR→commit mapping**: PR 5 and PR 6 were developed together and split into two commits by file; the PR 5 commit was built on its own and is green. +14. **No `PackageReference` change and no lock file changed.** + +## Deviation ledger as built + +The rulings below are the design's. The lead has not ruled on the open ones, so each was **taken as designed**; the state column says what was built. + +| ID | Decision | State | +|---|---|---| +| P6c-2 | the resolver and carriers in core, not in a new package | built as designed (open for the lead) | +| P6c-6 | per-call and operation tiers on `RequestOptions.Auth` / `OperationAuth` | built as designed (open for the lead) | +| P6c-7 | every auth policy resolves; a per-call `NoAuth` sends anonymously | built as designed (open for the lead) | +| P6c-8 | `HttpsRequiredException`; the guard also covers a reactive scheme's outbound pass | built as designed (open for the lead) | +| P6c-12 | a cross-origin `401` is returned without challenge handling | built as designed (open for the lead) | +| P6c-17 | `BasicCredential` rejects empty parts and a `:` in the username | built as designed (open for the lead) | +| P6c-19 | no `NamedKeyCredential`; the named-key clauses of `AUTH-8`/`AUTH-9` are vacuous | built as designed (open for the lead) | +| P6c-21 | `AccessToken` equality includes `RefreshOn` | built as designed (open for the lead) | +| P6c-25 | a background refresh under flow suppression, never cancelled | built as designed (open for the lead) | +| P6c-29 | the default Digest preference is SHA-256 first | built as designed (open for the lead) | +| P6c-30 | the MD5 probe, once per process | built | +| P6c-31 | a `-sess` challenge without `qop` is declined | built as designed (open for the lead) | +| P6c-33 | a non-ASCII username goes out as `username*` | built as designed (open for the lead) | +| P6c-36 | `MultiSchemeAuthPolicy` over shared stampers | built as designed (open for the lead; R12: if dropped, PR 7 and its end-to-end share go, `AUTH-4`/`AUTH-5` stay ✅ through the single-scheme policies and `AuthResolverTests`) | +| P6c-38 | `Convert.ToHexStringLower` on `net10.0` | built | + +Plan readings: **R3** (the temporary bearer sync bridge in PR 4) was built as written and removed in PR 5; **R12** as above. + +## Gate results + +Recorded when the branch was closed. + +Run on the branch head before the close-out commit, .NET SDK 10.0.401: + +| Gate | Result | +|---|---| +| `dotnet restore Dexpace.Sdk.sln --locked-mode` | ok, no lock file changed | +| `dotnet build Dexpace.Sdk.sln -c Release` (warnings as errors) | 0 warnings, 0 errors | +| `dotnet format Dexpace.Sdk.sln --verify-no-changes` | clean | +| `dotnet test --solution Dexpace.Sdk.sln -c Release` | 4 269 tests, 4 268 passed, 0 failed, 1 skipped (a pre-existing skip) | +| `Security` suites (core, SystemNet) | 263 and 30 tests, all passed | +| Coverage gate (floor 80 %) and its self-test | aggregate 97.56 % (Core 97.76 %, SystemNet 93.75 %, System.Text.Json 88.00 %); the three self-test cases ok | +| NativeAOT smoke (`dotnet publish tests/Dexpace.Sdk.AotSmoke` and run) | "aot-smoke: all checks passed", no trim or AOT warning | +| `dotnet pack`, `scripts/ci/dependency-audit.cs`, `scripts/ci/reproducible-pack.sh` | three libraries conform; the three packages are byte-identical across two packs | +| `tools/Dexpace.Tools.sln` build and test | 0 warnings; 438 tests passed | +| `scripts/knowledge verify-structure` | OK (12 notes) | +| `housekeeping probe` (full, then `--only links,citations`) | no drift found | diff --git a/src/Dexpace.Sdk.Core/README.md b/src/Dexpace.Sdk.Core/README.md index b8c1dad..c9fb3fe 100644 --- a/src/Dexpace.Sdk.Core/README.md +++ b/src/Dexpace.Sdk.Core/README.md @@ -14,7 +14,7 @@ that SDK-domain concerns run in. It ships no transport; pair it with one, such a | `Dexpace.Sdk.Core.Http.*` | `Method`, `Protocol`, `MediaType`, `HttpHeaderName`, `Headers`, `Request`, `RequestBody`, `Response`, `ResponseBody`, `Status` | | `Dexpace.Sdk.Core.Client` | The transport SPI: `IAsyncHttpClient`, `IHttpClient`, the `AsAsync` / `AsBlocking` bridges, and `DelegateHttpClient` | | `Dexpace.Sdk.Core.Pipeline` | `HttpPipeline` (itself an `IAsyncHttpClient` / `IHttpClient`), `PipelineBuilder` (`Flatten` / `Nest`, `AddStandardResilience`), `HttpPipelinePolicy` (request in, response out; `ProcessAsync` / `Process`), `PipelineContext`, `DexpacePipeline.CreateDefault` / `CreateEmpty`, and the policies: operation timeout, redirect, idempotency key, client identity, retry, `Date`, auth, instrumentation, error mapping | -| `Dexpace.Sdk.Core.Auth` | `TokenCredential`, `AccessTokenCache`, `ApiKeyCredential`, `BasicCredential` | +| `Dexpace.Sdk.Core.Auth` | `AuthScheme`, `AuthRequirement`, `AuthDescriptor`, `AuthResolver` (per-call, operation and client tiers), `TokenCredential`, `AccessToken`, `AccessTokenCache` (30 s margin, background refresh, bounded), `ApiKeyCredential`, `BasicCredential`, `DigestCredential`, `AuthCredentials`, `AuthenticationChallenge` (lenient RFC 7235 parser), `IChallengeHandler` with `BasicChallengeHandler`, `DigestChallengeHandler` (RFC 7616) and `CompositeChallengeHandler` | | `Dexpace.Sdk.Core.Pagination` | `AsyncPageable`, `Page`, `Pageable.Create`, `PaginationStrategies` | | `Dexpace.Sdk.Core.Serialization` | The `ISerde` seam; concrete codecs live in their own packages | | `Dexpace.Sdk.Core.Configuration` | The options as sealed records (`DexpaceClientOptions`, `RetryOptions`, `RedirectOptions`, `HttpLoggingOptions`), `HttpLogLevel` (logging is off by default), `ProxyOptions` with `FromEnvironment`, `TimeProviderWaits` and `BuildInfo` | @@ -73,6 +73,28 @@ failure, never an escape), and rethrows the terminal failure as the same excepti async form. A failure while closing a response lands on the primary exception's trail, readable with `ExceptionTrail.GetSuppressed`. See `docs/sdk-documentation/recovery.md` in the repository. +## Authentication + +Every auth policy derives from `AuthorizationPolicy`, which owns the HTTPS guard, the cross-origin withholding and the single +`401` replay. Pick the policy for the scheme; `MultiSchemeAuthPolicy` serves several at once, chosen per call by +`RequestOptions.Auth` / `OperationAuth` over the policy's client `AuthDescriptor`. + +```csharp +// OAuth 2.0 bearer: the cache refreshes 30 s early in the background and retries once on a Bearer 401. +var bearer = new BearerTokenAuthPolicy(tokenCredential, "files.read"); + +// Digest (RFC 7616) with a Basic fallback, answered on the server's challenge. +var challenge = new ChallengeAuthPolicy(new CompositeChallengeHandler( + new DigestChallengeHandler(new DigestCredential(user, password)), + new BasicChallengeHandler(new BasicCredential(user, password)))); + +// One call anonymous, whatever the policy. +using var response = await pipeline.SendAsync( + request, new RequestOptions { Auth = new AuthDescriptor(AuthRequirement.NoAuth) }, ct); +``` + +Credentials redact their secrets in `ToString`. See `docs/sdk-documentation/auth.md` in the repository. + ## Links - Repository and design: From bf5509e0abbbb374d88efe5a4b994c9d9dd9d1be Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 8 Oct 2026 21:11:16 +0300 Subject: [PATCH 11/13] chore: compare auth origins with HttpOrigin (phase 6c) --- src/Dexpace.Sdk.Core/Internal/AuthOrigin.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Dexpace.Sdk.Core/Internal/AuthOrigin.cs b/src/Dexpace.Sdk.Core/Internal/AuthOrigin.cs index 2156296..fff896a 100644 --- a/src/Dexpace.Sdk.Core/Internal/AuthOrigin.cs +++ b/src/Dexpace.Sdk.Core/Internal/AuthOrigin.cs @@ -1,6 +1,8 @@ // Copyright (c) 2026 dexpace and Omar Aljarrah. // Licensed under the MIT License. See LICENSE in the repository root for details. +using Dexpace.Sdk.Core.Http.Common; + namespace Dexpace.Sdk.Core.Internal; /// @@ -18,5 +20,6 @@ internal static string Of(Uri uri) return $"{uri.Scheme.ToLowerInvariant()}://{uri.Host.ToLowerInvariant()}:{uri.Port}"; } - internal static bool Same(Uri a, Uri b) => string.Equals(Of(a), Of(b), StringComparison.OrdinalIgnoreCase); + // 6b's HttpOrigin is the one comparison (it folds an IDN host and its punycode spelling into one origin). + internal static bool Same(Uri a, Uri b) => HttpOrigin.From(a) == HttpOrigin.From(b); } From f5d1b2677dd03e3420ac56727887a968bb38f2ba Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 8 Oct 2026 21:13:47 +0300 Subject: [PATCH 12/13] docs: record the phase 6c gate results after the rebase onto 6b --- .../phase6c/2026-10-08-phase6c-auth-checklist.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/work/mvp/phase6/phase6c/2026-10-08-phase6c-auth-checklist.md b/docs/work/mvp/phase6/phase6c/2026-10-08-phase6c-auth-checklist.md index fa207d8..f2c5f37 100644 --- a/docs/work/mvp/phase6/phase6c/2026-10-08-phase6c-auth-checklist.md +++ b/docs/work/mvp/phase6/phase6c/2026-10-08-phase6c-auth-checklist.md @@ -161,6 +161,9 @@ SDK from a scratch install rather than the global `dotnet`. The design's file-ba 12. **`ChallengeHandlerContractTests` covers Basic, composite and two Digest challenges** (the plan listed two shipped handlers plus Digest "added in task 6.4"). 13. **The five-PR→commit mapping**: PR 5 and PR 6 were developed together and split into two commits by file; the PR 5 commit was built on its own and is green. 14. **No `PackageReference` change and no lock file changed.** +15. **Rebased onto 6b.** 6b merged first (`29a7754`); the rebase conflicted only in append-only files (`DexpaceLogEvents`, `LogVocabularyTests`, the AOT smoke, and the docs and checklists both sub-phases + correct), each resolved by keeping both sides, and §11 items 62 to 65 are 6c's after 6b's 57 to 61. 6b's credential-leak tests build their pipelines from the shipped policies with unchanged public + construction, so no construction needed adapting and no assertion changed. As 6b offered, `AuthOrigin.Same` compares with its internal `HttpOrigin` (a small `chore:` commit). ## Deviation ledger as built @@ -190,16 +193,16 @@ Plan readings: **R3** (the temporary bearer sync bridge in PR 4) was built as wr Recorded when the branch was closed. -Run on the branch head before the close-out commit, .NET SDK 10.0.401: +Run on the branch head after rebasing onto `main` at `29a7754` (6b merged), .NET SDK 10.0.401: | Gate | Result | |---|---| | `dotnet restore Dexpace.Sdk.sln --locked-mode` | ok, no lock file changed | | `dotnet build Dexpace.Sdk.sln -c Release` (warnings as errors) | 0 warnings, 0 errors | | `dotnet format Dexpace.Sdk.sln --verify-no-changes` | clean | -| `dotnet test --solution Dexpace.Sdk.sln -c Release` | 4 269 tests, 4 268 passed, 0 failed, 1 skipped (a pre-existing skip) | -| `Security` suites (core, SystemNet) | 263 and 30 tests, all passed | -| Coverage gate (floor 80 %) and its self-test | aggregate 97.56 % (Core 97.76 %, SystemNet 93.75 %, System.Text.Json 88.00 %); the three self-test cases ok | +| `dotnet test --solution Dexpace.Sdk.sln -c Release` | 4 564 tests, 4 563 passed, 0 failed, 1 skipped (a pre-existing skip) | +| `Security` suites (core, SystemNet) | 281 and 32 tests, all passed (including 6b's `RedirectCredentialLeakTests` and `RedirectCredentialLeakWireTests`, unedited) | +| Coverage gate (floor 80 %) and its self-test | aggregate 97.45 % (Core 97.64 %, SystemNet 93.75 %, System.Text.Json 88.00 %); the three self-test cases ok | | NativeAOT smoke (`dotnet publish tests/Dexpace.Sdk.AotSmoke` and run) | "aot-smoke: all checks passed", no trim or AOT warning | | `dotnet pack`, `scripts/ci/dependency-audit.cs`, `scripts/ci/reproducible-pack.sh` | three libraries conform; the three packages are byte-identical across two packs | | `tools/Dexpace.Tools.sln` build and test | 0 warnings; 438 tests passed | From 18471eab0ec1e0d3dced5aaf15f2f1c57040080b Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Thu, 8 Oct 2026 21:20:47 +0300 Subject: [PATCH 13/13] fix: correct AuthRequirement.Scopes exception docs and drop the dead AuthOrigin.Of --- src/Dexpace.Sdk.Core/Auth/AuthRequirement.cs | 3 ++- src/Dexpace.Sdk.Core/Internal/AuthOrigin.cs | 9 --------- tests/Dexpace.Sdk.Core.Tests/Internal/AuthOriginTests.cs | 1 - 3 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/Dexpace.Sdk.Core/Auth/AuthRequirement.cs b/src/Dexpace.Sdk.Core/Auth/AuthRequirement.cs index b21ae51..747761f 100644 --- a/src/Dexpace.Sdk.Core/Auth/AuthRequirement.cs +++ b/src/Dexpace.Sdk.Core/Auth/AuthRequirement.cs @@ -41,7 +41,8 @@ public AuthRequirement(AuthScheme scheme) public AuthScheme Scheme { get; } /// The scopes, copied at initialisation; empty by default. - /// The value, or an element of it, is . + /// The value is . + /// An element is . public IReadOnlyList Scopes { get => _scopes.IsDefault ? [] : _scopes; diff --git a/src/Dexpace.Sdk.Core/Internal/AuthOrigin.cs b/src/Dexpace.Sdk.Core/Internal/AuthOrigin.cs index fff896a..2387cd3 100644 --- a/src/Dexpace.Sdk.Core/Internal/AuthOrigin.cs +++ b/src/Dexpace.Sdk.Core/Internal/AuthOrigin.cs @@ -11,15 +11,6 @@ namespace Dexpace.Sdk.Core.Internal; /// internal static class AuthOrigin { - // Derives a canonical origin string: "://:". Port is always included. For a URL - // written without a port, Uri.Port returns the scheme's default (443 for https, 80 for http), not -1, so - // "https://a/" and "https://a:443/" yield the same origin whether or not the caller supplied the port explicitly. - internal static string Of(Uri uri) - { - ArgumentNullException.ThrowIfNull(uri); - return $"{uri.Scheme.ToLowerInvariant()}://{uri.Host.ToLowerInvariant()}:{uri.Port}"; - } - // 6b's HttpOrigin is the one comparison (it folds an IDN host and its punycode spelling into one origin). internal static bool Same(Uri a, Uri b) => HttpOrigin.From(a) == HttpOrigin.From(b); } diff --git a/tests/Dexpace.Sdk.Core.Tests/Internal/AuthOriginTests.cs b/tests/Dexpace.Sdk.Core.Tests/Internal/AuthOriginTests.cs index c6b08c8..7c0a96f 100644 --- a/tests/Dexpace.Sdk.Core.Tests/Internal/AuthOriginTests.cs +++ b/tests/Dexpace.Sdk.Core.Tests/Internal/AuthOriginTests.cs @@ -21,7 +21,6 @@ public void Default_ports_are_equal_to_explicit_ones() { Assert.True(AuthOrigin.Same(new Uri("https://a/"), new Uri("https://a:443/"))); Assert.True(AuthOrigin.Same(new Uri("http://a/"), new Uri("http://a:80/"))); - Assert.Equal("https://a:443", AuthOrigin.Of(new Uri("https://a/"))); } [Fact]