< Summary

Information
Class: dotnet_etcd.AuthenticationHttpHandler
Assembly: dotnet-etcd
File(s): /home/runner/work/dotnet-etcd/dotnet-etcd/dotnet-etcd/AuthenticationHttpHandler.cs
Line coverage
96%
Covered lines: 75
Uncovered lines: 3
Coverable lines: 78
Total lines: 165
Line coverage: 96.1%
Branch coverage
93%
Covered branches: 28
Total branches: 30
Branch coverage: 93.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%22100%
InvalidateToken()100%2281.25%
SendAsync()90%1010100%
IsUnauthenticated(...)100%22100%
HasUnauthenticatedStatus(...)100%22100%
GetValidTokenAsync()100%1010100%
Dispose(...)50%22100%
get_Token()100%11100%

File(s)

/home/runner/work/dotnet-etcd/dotnet-etcd/dotnet-etcd/AuthenticationHttpHandler.cs

#LineLine coverage
 1#nullable enable
 2
 3using System;
 4using System.Linq;
 5using System.Net.Http;
 6using System.Net.Http.Headers;
 7using System.Threading;
 8using System.Threading.Tasks;
 9
 10namespace dotnet_etcd;
 11
 12/// <summary>
 13///     DelegatingHandler that attaches an etcd auth token to outgoing requests. Fetches
 14///     the token via the supplied provider, caches it for the configured duration, and
 15///     bypasses the Authenticate RPC itself so the token-fetch call doesn't recurse.
 16/// </summary>
 17internal sealed class AuthenticationHttpHandler : DelegatingHandler
 18{
 19    private const string AuthorizationHeader = "authorization";
 20    private const string AuthenticatePath = "/etcdserverpb.Auth/Authenticate";
 21    private const string GrpcStatusHeader = "grpc-status";
 22    private const string GrpcStatusUnauthenticated = "16"; // StatusCode.Unauthenticated
 23
 12424    private readonly SemaphoreSlim _semaphore = new(1, 1);
 25    private readonly Func<
 26        CancellationToken,
 27        Task<(string token, TimeSpan cacheDuration)?>
 28    > _tokenProvider;
 29
 30    private CachedToken? _cachedToken;
 31    private volatile bool _disposed;
 32
 33    public AuthenticationHttpHandler(
 34        Func<CancellationToken, Task<(string token, TimeSpan cacheDuration)?>> tokenProvider,
 35        HttpMessageHandler innerHandler
 36    )
 12437        : base(innerHandler)
 12438    {
 12439        _tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider));
 12340    }
 41
 42    /// <summary>
 43    ///     Clears any cached token so the next request re-fetches via the token provider.
 44    ///     Call this after credentials change so in-flight cached tokens don't outlive the change.
 45    /// </summary>
 46    public void InvalidateToken()
 7047    {
 7048        if (_disposed)
 149            return;
 50
 51        try
 6952        {
 6953            _semaphore.Wait();
 6954        }
 055        catch (ObjectDisposedException)
 056        {
 57            // Lost a race with Dispose() — nothing to invalidate on a disposed handler.
 058            return;
 59        }
 60
 61        try
 6962        {
 6963            _cachedToken = null;
 6964        }
 65        finally
 6966        {
 6967            _semaphore.Release();
 6968        }
 7069    }
 70
 71    protected override async Task<HttpResponseMessage> SendAsync(
 72        HttpRequestMessage request,
 73        CancellationToken cancellationToken
 74    )
 60175    {
 76        // If the request is an authorization request, we do nothing to prevent deadlocking
 60177        if (request.RequestUri?.AbsolutePath == AuthenticatePath)
 6278            return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
 79
 53980        string? token = await GetValidTokenAsync(cancellationToken).ConfigureAwait(false);
 52281        bool tokenAttached = !string.IsNullOrWhiteSpace(token);
 52282        if (tokenAttached)
 17883        {
 84            // Replace any existing authorization header. Use TryAddWithoutValidation so opaque
 85            // etcd tokens (JWT/base64 containing '+', '/', '=') aren't rejected by the typed
 86            // Authorization header parser, which would otherwise throw FormatException.
 17887            request.Headers.Remove(AuthorizationHeader);
 17888            request.Headers.TryAddWithoutValidation(AuthorizationHeader, token);
 17889        }
 90
 52291        HttpResponseMessage response = await base.SendAsync(request, cancellationToken)
 52292            .ConfigureAwait(false);
 93
 94        // If etcd rejected the token we attached, purge the cache so the next attempt (e.g. a
 95        // watch/lease stream re-establishing) fetches a fresh token instead of replaying the
 96        // rejected one. See issue #283.
 51797        if (tokenAttached && IsUnauthenticated(response))
 398            InvalidateToken();
 99
 517100        return response;
 579101    }
 102
 103    private static bool IsUnauthenticated(HttpResponseMessage response)
 178104    {
 105        // A gRPC error surfaces grpc-status either in the initial headers (a "trailers-only"
 106        // response, which is how etcd fast-fails an invalid/expired token) or in the trailing
 107        // headers. Check both so we catch the rejection wherever it lands.
 178108        return HasUnauthenticatedStatus(response.Headers)
 178109            || HasUnauthenticatedStatus(response.TrailingHeaders);
 178110    }
 111
 112    private static bool HasUnauthenticatedStatus(HttpResponseHeaders headers)
 353113    {
 353114        return headers.TryGetValues(GrpcStatusHeader, out var values)
 353115            && values.Contains(GrpcStatusUnauthenticated);
 353116    }
 117
 118    private async Task<string?> GetValidTokenAsync(CancellationToken cancellationToken)
 539119    {
 539120        var current = _cachedToken;
 539121        if (current is not null && DateTime.UtcNow < current.ExpiresAtUtc)
 382122            return current.Token;
 123
 157124        await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
 125        try
 157126        {
 157127            current = _cachedToken;
 157128            if (current is not null && DateTime.UtcNow < current.ExpiresAtUtc)
 2129                return current.Token;
 130
 155131            (string token, TimeSpan cacheDuration)? result = await _tokenProvider(cancellationToken)
 155132                .ConfigureAwait(false);
 133
 138134            if (!result.HasValue)
 80135            {
 136                // Client needs no authentication, cache null token value until the next invalidated call
 80137                _cachedToken = new CachedToken(null, DateTime.MaxValue);
 80138                return null;
 139            }
 140
 58141            _cachedToken = new CachedToken(
 58142                result.Value.token,
 58143                DateTime.UtcNow + result.Value.cacheDuration
 58144            );
 58145            return result.Value.token;
 146        }
 147        finally
 157148        {
 157149            _semaphore.Release();
 157150        }
 522151    }
 152
 153    protected override void Dispose(bool disposing)
 101154    {
 101155        if (disposing)
 101156        {
 101157            _disposed = true;
 101158            _semaphore.Dispose();
 101159        }
 160
 101161        base.Dispose(disposing);
 101162    }
 163
 908164    private record CachedToken(string? Token, DateTime ExpiresAtUtc);
 165}