free-code-dotnet/docs/服务子系统设计/服务子系统设计-认证与OAuth.md
应文浩wenhao.ying@xiaobao100.com e25ac591a7 init easy-code
2026-04-06 07:24:24 +08:00

11 KiB
Raw Blame History

服务子系统设计 — 认证与 OAuth

所属项目: free-code .NET 10 重写 文档类型: 子模块设计 原始源码: ../../src/services/oauth/ 配套文档: 服务子系统设计总览 | 参考映射


概述

认证模块负责管理用户身份凭证的完整生命周期,涵盖 OAuth 授权流程启动、回调处理、token 交换、安全存储和登出。原始 TypeScript 实现包含两条独立的 OAuth 流程Anthropic 控制台 OAuth 和 OpenAI Codex OAuth均通过本地 HTTP 监听器完成浏览器回调拦截。

.NET 10 重写将认证能力抽象为 IAuthService 接口,安全存储单独抽象为 ISecureTokenStorage允许在不同平台macOS Keychain、Windows DPAPI、Linux Secret Service下替换存储后端。


19.1 IAuthService 接口

/// <summary>
/// 认证服务接口 — 管理 OAuth 登录、登出和 token 获取
/// 对应原始 ../../src/services/oauth/ 目录的整体能力
/// </summary>
public interface IAuthService
{
    /// <summary>当前是否已通过任意提供商完成认证</summary>
    bool IsAuthenticated { get; }

    /// <summary>是否为 Claude.ai 用户(通过 claudeai_token 判断)</summary>
    bool IsClaudeAiUser { get; }

    /// <summary>是否为 Anthropic 内部用户</summary>
    bool IsInternalUser { get; }

    /// <summary>
    /// 启动 OAuth 授权流程
    /// </summary>
    /// <param name="provider">提供商名称,支持 "anthropic" 和 "codex"</param>
    Task LoginAsync(string provider = "anthropic");

    /// <summary>清除所有本地存储的 token退出登录</summary>
    Task LogoutAsync();

    /// <summary>获取当前有效的 OAuth access token未登录时返回 null</summary>
    Task<string?> GetOAuthTokenAsync();

    /// <summary>认证状态发生变化时触发(登录或登出后)</summary>
    event EventHandler? AuthStateChanged;
}

19.2 AuthService 实现

/// <summary>
/// 认证服务实现 — Anthropic OAuth + Codex OAuth 双流程
/// 对应原始 ../../src/services/oauth/anthropic.ts 和 openai.ts
/// </summary>
public sealed class AuthService : IAuthService
{
    private readonly ISecureTokenStorage _tokenStorage;
    private readonly ILogger<AuthService> _logger;

    public bool IsAuthenticated => _tokenStorage.Get("oauth_token") != null;
    public bool IsClaudeAiUser => _tokenStorage.Get("claudeai_token") != null;
    public bool IsInternalUser => false; // Anthropic 内部用户标记,外部构建始终为 false

    public event EventHandler? AuthStateChanged;

    public AuthService(ISecureTokenStorage tokenStorage, ILogger<AuthService> logger)
    {
        _tokenStorage = tokenStorage;
        _logger = logger;
    }

    /// <summary>
    /// Anthropic OAuth 流程
    /// 1. 启动本地 HTTP 监听器(拦截浏览器回调)
    /// 2. 构建授权 URL 并打开浏览器
    /// 3. 等待回调获取 authorization code
    /// 4. 用 code 交换 access token + refresh token
    /// 5. 写入安全存储
    /// </summary>
    public async Task LoginAsync(string provider = "anthropic")
    {
        if (provider == "anthropic")
        {
            // 1. 启动本地 HTTP 监听器(获取 OAuth 回调)
            var callbackPort = GetAvailablePort();
            using var listener = new HttpListener();
            listener.Prefixes.Add($"http://localhost:{callbackPort}/");
            listener.Start();

            // 2. 构建 OAuth 授权 URL
            var authUrl = $"https://console.anthropic.com/oauth/authorize"
                + $"?client_id=free-code-cli"
                + $"&redirect_uri=http://localhost:{callbackPort}/"
                + $"&response_type=code"
                + $"&scope=openid profile email";

            // 3. 打开浏览器
            OpenBrowser(authUrl);

            // 4. 等待回调 → 获取 code
            var context = await listener.GetContextAsync();
            var code = context.Request.QueryString["code"];

            // 5. 交换 token
            using var httpClient = new HttpClient();
            var tokenResponse = await httpClient.PostAsync(
                "https://console.anthropic.com/oauth/token",
                new FormUrlEncodedContent(new Dictionary<string, string>
                {
                    ["grant_type"] = "authorization_code",
                    ["code"] = code ?? "",
                    ["redirect_uri"] = $"http://localhost:{callbackPort}/",
                    ["client_id"] = "free-code-cli",
                }));

            var tokens = await tokenResponse.Content.ReadFromJsonAsync<OAuthTokens>();
            _tokenStorage.Set("oauth_token", tokens?.AccessToken);
            _tokenStorage.Set("oauth_refresh_token", tokens?.RefreshToken);
        }
        else if (provider == "codex")
        {
            // OpenAI Codex OAuth — 不同端点,流程相同
            var authUrl = "https://auth.openai.com/authorize"
                + "?client_id=codex-cli"
                + "&response_type=code"
                + "&scope=openid profile email";
            // 后续 code exchange 流程与 Anthropic 流程对称
        }

        AuthStateChanged?.Invoke(this, EventArgs.Empty);
    }

    public Task LogoutAsync()
    {
        _tokenStorage.Remove("oauth_token");
        _tokenStorage.Remove("oauth_refresh_token");
        _tokenStorage.Remove("claudeai_token");
        AuthStateChanged?.Invoke(this, EventArgs.Empty);
        return Task.CompletedTask;
    }

    public Task<string?> GetOAuthTokenAsync() =>
        Task.FromResult(_tokenStorage.Get("oauth_token"));

    private static int GetAvailablePort()
    {
        using var socket = new System.Net.Sockets.TcpListener(
            System.Net.IPAddress.Loopback, 0);
        socket.Start();
        var port = ((System.Net.IPEndPoint)socket.LocalEndpoint).Port;
        socket.Stop();
        return port;
    }

    private static void OpenBrowser(string url)
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
            Process.Start("open", url);
        else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
            Process.Start("xdg-open", url);
        else
            Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
    }
}

/// <summary>
/// OAuth token 响应的反序列化模型
/// </summary>
internal sealed record OAuthTokens
{
    [JsonPropertyName("access_token")]
    public string? AccessToken { get; init; }

    [JsonPropertyName("refresh_token")]
    public string? RefreshToken { get; init; }

    [JsonPropertyName("expires_in")]
    public int ExpiresIn { get; init; }
}

19.3 ISecureTokenStorage 接口

/// <summary>
/// 安全 token 存储接口 — 平台无关
/// macOS: Keychain / Windows: DPAPI / Linux: Secret Service
/// </summary>
public interface ISecureTokenStorage
{
    /// <summary>读取指定键的 token不存在时返回 null</summary>
    string? Get(string key);

    /// <summary>写入指定键的 tokenvalue 为 null 时等同于 Remove</summary>
    void Set(string key, string? value);

    /// <summary>删除指定键的 token</summary>
    void Remove(string key);
}

19.4 KeychainTokenStorage — macOS Keychain 集成

/// <summary>
/// macOS Keychain 安全存储实现
/// 对应原始代码中对系统 Keychain 的直接访问
/// 通过 security CLI 工具与 macOS Keychain Services 通信
/// </summary>
public sealed class KeychainTokenStorage : ISecureTokenStorage
{
    /// <summary>
    /// 读取 Keychain 中的 token
    /// 等效命令: security find-generic-password -s free-code-{key} -w
    /// </summary>
    public string? Get(string key)
    {
        var psi = new ProcessStartInfo
        {
            FileName = "security",
            Arguments = $"find-generic-password -s free-code-{key} -w",
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            CreateNoWindow = true,
        };
        using var process = Process.Start(psi);
        var output = process?.StandardOutput.ReadToEnd().Trim();
        return string.IsNullOrEmpty(output) ? null : output;
    }

    /// <summary>
    /// 写入 token 到 Keychain-U 标志: 不存在则创建,存在则更新)
    /// 等效命令: security add-generic-password -U -s free-code-{key} -p {value}
    /// </summary>
    public void Set(string key, string? value)
    {
        if (value == null) { Remove(key); return; }
        var psi = new ProcessStartInfo
        {
            FileName = "security",
            Arguments = $"add-generic-password -U -s free-code-{key} -p {value}",
            CreateNoWindow = true,
        };
        Process.Start(psi)?.WaitForExit();
    }

    /// <summary>
    /// 从 Keychain 删除 token
    /// 等效命令: security delete-generic-password -s free-code-{key}
    /// </summary>
    public void Remove(string key)
    {
        var psi = new ProcessStartInfo
        {
            FileName = "security",
            Arguments = $"delete-generic-password -s free-code-{key}",
            CreateNoWindow = true,
        };
        Process.Start(psi)?.WaitForExit();
    }
}

设计说明

本地 HTTP 监听器模式

OAuth 授权码流程要求一个回调 URI。命令行工具无法注册自定义 URL Scheme不同于桌面应用因此采用本地 HTTP 监听器方案:随机选取可用端口,启动监听后打开浏览器,用户授权后浏览器重定向到 localhost:{port}/?code=...CLI 截获 code 完成交换。这是命令行 OAuth 的通行做法,与 GitHub CLI、Azure CLI 的实现模式一致。

Keychain 使用 CLI 而非 P/Invoke

原始 TypeScript 代码通过 Node.js 调用 security 命令行工具,.NET 重写保持相同策略。使用 security CLI 而非直接调用 Keychain Services C API避免了 macOS 10.15+ 的 hardened runtime 代码签名限制,也无需 entitlements 配置。代价是每次读写有子进程启动开销,但认证操作频率极低,可以接受。

跨平台存储抽象

ISecureTokenStorage 接口的引入使 DI 容器可以按运行平台注入不同实现。注册时可这样处理:

if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
    services.AddSingleton<ISecureTokenStorage, KeychainTokenStorage>();
else
    services.AddSingleton<ISecureTokenStorage, EncryptedFileTokenStorage>();

参考资料