---
source: tests/golden_csharp.rs
expression: contents
---
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Out.Auth.Strategies;
using Out.Core;

namespace Out.Auth;

/// <summary>
/// Selects exactly one active auth strategy per deployment, chosen via
/// the <see cref="AuthMethod"/> config value (REQ-1.2.3) — there is no
/// runtime engine for resolving multiple simultaneously-required schemes.
/// The active strategy is resolved through .NET 8+ keyed DI
/// (<see cref="IKeyedServiceProvider"/>), keyed by the
/// <see cref="AuthMethod"/> enum value itself — investigated per
/// v4-implementation-plan.md's C4 goal text and confirmed to map onto
/// "select one active strategy by a config-driven key" more directly than
/// a hand-rolled dictionary/switch would.
/// </summary>
public sealed class AuthManager(
    IOptions<McpifyOptions> options,
    IKeyedServiceProvider keyedServiceProvider,
    ICredentialStore credentialStore)
{
    private const string CredentialKey = "active-credentials";
    private Dictionary<string, string>? _cachedCredentials;

    private IAuthStrategy Strategy =>
        keyedServiceProvider.GetRequiredKeyedService<IAuthStrategy>(options.Value.AuthMethod);

    /// <summary>
    /// Where each discovered auth method's credential value travels on
    /// the wire — used only by the HTTP-transport auth-gate/extractor
    /// (<c>Http/AuthExtractor.cs</c>) to know which incoming header to
    /// read; stdio never calls this.
    /// </summary>
    private static readonly Dictionary<AuthMethod, (string Location, string Name)> HeaderLocations = new()
    {
        [AuthMethod.Basic] = ("header", "Authorization"),
        [AuthMethod.Pat] = ("header", "Authorization"),
        [AuthMethod.OAuth2] = ("header", "Authorization"),
        [AuthMethod.OAuth1] = ("none", ""),
    };

    public static (string Location, string Name) HeaderLocationFor(AuthMethod authMethod) =>
        HeaderLocations.TryGetValue(authMethod, out var location) ? location : ("none", string.Empty);

    public async Task<Dictionary<string, string>> LoginAsync(
        Dictionary<string, string> config, CancellationToken cancellationToken = default)
    {
        var credentials = await Strategy.AuthenticateAsync(config, cancellationToken);
        _cachedCredentials = credentials;
        await credentialStore.SaveAsync(CredentialKey, JsonSerializer.Serialize(credentials), cancellationToken);
        return credentials;
    }

    /// <summary>
    /// Seeds in-memory credentials directly, bypassing OS-keychain/file
    /// storage entirely — for tests (which must never touch the real
    /// credential store as a side effect of running) and for callers that
    /// already hold validated credentials from elsewhere.
    /// </summary>
    public void SetCredentials(Dictionary<string, string> credentials) => _cachedCredentials = credentials;

    public async Task<Dictionary<string, string>> GetCredentialsAsync(CancellationToken cancellationToken = default)
    {
        if (_cachedCredentials is not null && Strategy.ValidateCredentials(_cachedCredentials))
        {
            return _cachedCredentials;
        }

        var stored = await credentialStore.GetAsync(CredentialKey, cancellationToken);
        if (stored is not null)
        {
            var parsed = JsonSerializer.Deserialize<Dictionary<string, string>>(stored) ?? [];
            if (Strategy.ValidateCredentials(parsed))
            {
                _cachedCredentials = parsed;
                return parsed;
            }

            try
            {
                var refreshed = await Strategy.RefreshTokenAsync(parsed, cancellationToken);
                _cachedCredentials = refreshed;
                await credentialStore.SaveAsync(CredentialKey, JsonSerializer.Serialize(refreshed), cancellationToken);
                return refreshed;
            }
            catch
            {
                // Fall through to NoActiveCredentialsException below —
                // matches `targets::python`'s auth_manager.py: a failed
                // refresh means "no valid credentials", not a fatal error.
            }
        }

        throw new NoActiveCredentialsException(options.Value.AuthMethod.ToString());
    }

    /// <summary>
    /// Decorates outgoing request headers with credentials for
    /// <paramref name="transport"/>: on <see cref="Transport.Http"/>,
    /// <paramref name="requestOverride"/> (extracted per-request from the
    /// caller's own headers — see
    /// <c>Http/RequestCredentialProvider.cs</c>) is the *only* source
    /// ever consulted, since HTTP deployments must never leak the
    /// operator's local config/keychain secrets to a caller who didn't
    /// supply their own; a null override is a hard error, not a
    /// fallback. On <see cref="Transport.Stdio"/>,
    /// <paramref name="requestOverride"/> is ignored and behavior is
    /// unchanged from before HTTP-transport support existed:
    /// <see cref="GetCredentialsAsync"/> (config cascade + keychain).
    ///
    /// API-key placement (header vs. query, and the header/param name)
    /// defaults to an <c>X-Api-Key</c> header here; a scheme declaring a
    /// different location is one of Story C6's api-client
    /// responsibilities to refine, not this manager's —
    /// <paramref name="requestOverride"/>'s
    /// <c>request_header_name</c>/<c>request_header_value</c> pair is
    /// the one exception, since the HTTP path already knows the scheme's
    /// declared header name at generation time.
    /// </summary>
    public async Task<Dictionary<string, string>> ApplyAuthHeadersAsync(
        Dictionary<string, string> headers,
        string method,
        string url,
        Transport transport,
        RequestCredentials? requestOverride = null,
        CancellationToken cancellationToken = default)
    {
        if (transport == Transport.Http && options.Value.AuthMethod == AuthMethod.OAuth1)
        {
            throw new AuthException(
                "OAuth1 is not supported over HTTP transport: its signature must be recomputed per " +
                "request against the destination URL using the server's consumer key/secret, which a " +
                "simple incoming-header relay cannot provide. Use stdio transport for OAuth1-authenticated APIs.");
        }

        Dictionary<string, string> credentials;
        if (transport == Transport.Http)
        {
            if (requestOverride is null)
            {
                throw new AuthException(
                    "no credentials were supplied on this HTTP request; local config/env/keychain is " +
                    "never used as a fallback over HTTP transport");
            }
            credentials = requestOverride.ToCredentialsMap();
        }
        else
        {
            credentials = await GetCredentialsAsync(cancellationToken);
        }
        if (options.Value.AuthMethod == AuthMethod.OAuth1)
        {
            if (!credentials.TryGetValue("consumer_key", out var consumerKey) ||
                !credentials.TryGetValue("private_key", out var privateKey))
            {
                throw new MissingCredentialsException("consumer_key, private_key");
            }
            credentials.TryGetValue("token", out var token);

            return new Dictionary<string, string>(headers)
            {
                ["Authorization"] = OAuth1Signer.BuildAuthorizationHeader(consumerKey, privateKey, token, method, url),
            };
        }

        var updated = new Dictionary<string, string>(headers);
        if (credentials.TryGetValue("request_header_name", out var requestHeaderName))
        {
            updated[requestHeaderName] = credentials.GetValueOrDefault("request_header_value", string.Empty);
        }
        else if (credentials.TryGetValue("authorization_header", out var authHeader))
        {
            updated["Authorization"] = authHeader;
        }
        else if (credentials.TryGetValue("api_key", out var apiKey))
        {
            updated["X-Api-Key"] = apiKey;
        }
        return updated;
    }
}
