File: Internal\GitHubMembershipClient.cs
Web Access
Project: Microsoft.DotNet.Cli.InternalMicrosoft.csproj (Microsoft.DotNet.Cli.InternalMicrosoft)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;

namespace Microsoft.DotNet.Cli.InternalMicrosoft;

/// <summary>
/// Checks whether one of a bounded set of GitHub credentials belongs to the Microsoft organization.
/// A GitHub provider creates this client for one probe execution.
/// </summary>
internal sealed class GitHubMembershipClient
{
    private const string MicrosoftGitHubOrg = "microsoft";
    private const int MaxTokenCandidates = 5;

    private static readonly TimeSpan s_cancelledProbeDrainTimeout = TimeSpan.FromSeconds(1);

    private readonly InternalMicrosoftDetectionContext _context;

    internal GitHubMembershipClient(InternalMicrosoftDetectionContext context)
    {
        _context = context;
    }

    /// <summary>
    /// Checks candidates concurrently and stops after the first Microsoft membership result.
    /// </summary>
    internal async Task<InternalMicrosoftProbeResult> CheckAnyAsync(
        IEnumerable<GitHubTokenCandidate> candidates,
        CancellationToken cancellationToken)
    {
        var candidatesToCheck = candidates
            .DistinctBy(candidate => candidate.Token, StringComparer.Ordinal)
            .Take(MaxTokenCandidates)
            .ToArray();
        if (candidatesToCheck.Length == 0)
        {
            return InternalMicrosoftProbeResult.NotDetected;
        }

        using var timeoutSource = new CancellationTokenSource(_context.GitHubCandidateTimeout);
        using var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutSource.Token);
        var candidateTasks = candidatesToCheck
            .Select(candidate => CheckAsync(candidate.Token, linkedSource.Token))
            .ToList();
        InternalMicrosoftProbeFailure? failure = null;

        try
        {
            while (candidateTasks.Count > 0)
            {
                var completedTask = await Task.WhenAny(candidateTasks).WaitAsync(linkedSource.Token).ConfigureAwait(false);
                candidateTasks.Remove(completedTask);

                var result = await completedTask.ConfigureAwait(false);
                if (result.IsInternalMicrosoft)
                {
                    await linkedSource.CancelAsync().ConfigureAwait(false);
                    return result;
                }

                failure ??= result.Failure;
            }

            return failure is null
                ? InternalMicrosoftProbeResult.NotDetected
                : InternalMicrosoftProbeResult.Failed(failure);
        }
        catch (OperationCanceledException) when (
            !cancellationToken.IsCancellationRequested &&
            timeoutSource.IsCancellationRequested)
        {
            return InternalMicrosoftProbeResult.Failed(new(
                InternalMicrosoftProbeFailureCode.RequestFailed,
                InternalMicrosoftProbeFailureStage.GitHubCandidates,
                ExceptionType: nameof(TaskCanceledException)));
        }
        finally
        {
            await linkedSource.CancelAsync().ConfigureAwait(false);
            await DrainCandidateTasksAsync(candidateTasks).ConfigureAwait(false);
        }
    }

    /// <summary>
    /// Checks one GitHub credential without returning its login or token.
    /// </summary>
    internal async Task<InternalMicrosoftProbeResult> CheckAsync(
        string token,
        CancellationToken cancellationToken)
    {
        using var client = _context.GitHubHttpMessageHandler is null
            ? new HttpClient()
            : new HttpClient(_context.GitHubHttpMessageHandler, disposeHandler: false);
        client.Timeout = _context.GitHubHttpTimeout;
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
        client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("dotnet-sdk", _context.GitHubUserAgentVersion));
        client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
        client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28");

        try
        {
            using var userResponse = await client.GetAsync(
                "https://api.github.com/user",
                HttpCompletionOption.ResponseHeadersRead,
                cancellationToken).ConfigureAwait(false);
            if (!userResponse.IsSuccessStatusCode)
            {
                return HttpFailure("user", userResponse.StatusCode);
            }

            await using var userStream = await userResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
            using var userDocument = await JsonDocument.ParseAsync(userStream, cancellationToken: cancellationToken).ConfigureAwait(false);
            if (!InternalMicrosoftDetectionUtilities.TryGetString(userDocument.RootElement, "login", out var login))
            {
                return InternalMicrosoftProbeResult.Failed(new(
                    InternalMicrosoftProbeFailureCode.JsonShape,
                    InternalMicrosoftProbeFailureStage.GitHubUser));
            }

            using var membershipResponse = await client.GetAsync(
                $"https://api.github.com/user/memberships/orgs/{MicrosoftGitHubOrg}",
                HttpCompletionOption.ResponseHeadersRead,
                cancellationToken).ConfigureAwait(false);
            if (membershipResponse.IsSuccessStatusCode)
            {
                await using var membershipStream = await membershipResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
                using var membershipDocument = await JsonDocument.ParseAsync(membershipStream, cancellationToken: cancellationToken).ConfigureAwait(false);
                if (InternalMicrosoftDetectionUtilities.TryGetString(
                        membershipDocument.RootElement,
                        "state",
                        out var state) &&
                    state.Equals("active", StringComparison.OrdinalIgnoreCase))
                {
                    return new InternalMicrosoftProbeResult(true, null, null);
                }

                return InternalMicrosoftProbeResult.NotDetected;
            }

            if (membershipResponse.StatusCode != HttpStatusCode.NotFound)
            {
                return HttpFailure("membership", membershipResponse.StatusCode);
            }

            using var publicMembershipResponse = await client.GetAsync(
                $"https://api.github.com/orgs/{MicrosoftGitHubOrg}/public_members/{Uri.EscapeDataString(login)}",
                HttpCompletionOption.ResponseHeadersRead,
                cancellationToken).ConfigureAwait(false);
            return publicMembershipResponse.StatusCode switch
            {
                HttpStatusCode.NoContent => new InternalMicrosoftProbeResult(true, null, null),
                HttpStatusCode.NotFound => InternalMicrosoftProbeResult.NotDetected,
                _ => HttpFailure("public_membership", publicMembershipResponse.StatusCode)
            };
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            throw;
        }
        catch (OperationCanceledException)
        {
            return InternalMicrosoftProbeResult.Failed(new(
                InternalMicrosoftProbeFailureCode.HttpTimeout,
                InternalMicrosoftProbeFailureStage.GitHub,
                ExceptionType: nameof(TaskCanceledException)));
        }
        catch (Exception exception)
        {
            return InternalMicrosoftProbeResult.Failed(
                InternalMicrosoftDetectionUtilities.CreateExceptionFailure(
                    exception,
                    InternalMicrosoftProbeFailureStage.GitHub));
        }
    }

    private static async Task DrainCandidateTasksAsync(
        IReadOnlyList<Task<InternalMicrosoftProbeResult>> candidateTasks)
    {
        if (candidateTasks.Count == 0)
        {
            return;
        }

        try
        {
            await Task.WhenAll(candidateTasks).WaitAsync(s_cancelledProbeDrainTimeout).ConfigureAwait(false);
        }
        catch
        {
            // Candidate diagnostics report only the bounded aggregate failure.
        }
    }

    private static InternalMicrosoftProbeResult HttpFailure(string request, HttpStatusCode statusCode) =>
        InternalMicrosoftProbeResult.Failed(new(
            InternalMicrosoftProbeFailureCode.HttpStatus,
            request,
            HttpStatusCode: (int)statusCode));
}