# 服务子系统设计 — 认证与 OAuth > **所属项目**: free-code .NET 10 重写 > **文档类型**: 子模块设计 > **原始源码**: `../../src/services/oauth/` > **配套文档**: [服务子系统设计总览](服务子系统设计.md) | [参考映射](reference/原始代码映射-服务子系统.md) --- ## 概述 认证模块负责管理用户身份凭证的完整生命周期,涵盖 OAuth 授权流程启动、回调处理、token 交换、安全存储和登出。原始 TypeScript 实现包含两条独立的 OAuth 流程:Anthropic 控制台 OAuth 和 OpenAI Codex OAuth,均通过本地 HTTP 监听器完成浏览器回调拦截。 .NET 10 重写将认证能力抽象为 `IAuthService` 接口,安全存储单独抽象为 `ISecureTokenStorage`,允许在不同平台(macOS Keychain、Windows DPAPI、Linux Secret Service)下替换存储后端。 --- ## 19.1 IAuthService 接口 ```csharp /// /// 认证服务接口 — 管理 OAuth 登录、登出和 token 获取 /// 对应原始 ../../src/services/oauth/ 目录的整体能力 /// public interface IAuthService { /// 当前是否已通过任意提供商完成认证 bool IsAuthenticated { get; } /// 是否为 Claude.ai 用户(通过 claudeai_token 判断) bool IsClaudeAiUser { get; } /// 是否为 Anthropic 内部用户 bool IsInternalUser { get; } /// /// 启动 OAuth 授权流程 /// /// 提供商名称,支持 "anthropic" 和 "codex" Task LoginAsync(string provider = "anthropic"); /// 清除所有本地存储的 token,退出登录 Task LogoutAsync(); /// 获取当前有效的 OAuth access token,未登录时返回 null Task GetOAuthTokenAsync(); /// 认证状态发生变化时触发(登录或登出后) event EventHandler? AuthStateChanged; } ``` --- ## 19.2 AuthService 实现 ```csharp /// /// 认证服务实现 — Anthropic OAuth + Codex OAuth 双流程 /// 对应原始 ../../src/services/oauth/anthropic.ts 和 openai.ts /// public sealed class AuthService : IAuthService { private readonly ISecureTokenStorage _tokenStorage; private readonly ILogger _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 logger) { _tokenStorage = tokenStorage; _logger = logger; } /// /// Anthropic OAuth 流程 /// 1. 启动本地 HTTP 监听器(拦截浏览器回调) /// 2. 构建授权 URL 并打开浏览器 /// 3. 等待回调获取 authorization code /// 4. 用 code 交换 access token + refresh token /// 5. 写入安全存储 /// 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 { ["grant_type"] = "authorization_code", ["code"] = code ?? "", ["redirect_uri"] = $"http://localhost:{callbackPort}/", ["client_id"] = "free-code-cli", })); var tokens = await tokenResponse.Content.ReadFromJsonAsync(); _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 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 }); } } /// /// OAuth token 响应的反序列化模型 /// 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 接口 ```csharp /// /// 安全 token 存储接口 — 平台无关 /// macOS: Keychain / Windows: DPAPI / Linux: Secret Service /// public interface ISecureTokenStorage { /// 读取指定键的 token,不存在时返回 null string? Get(string key); /// 写入指定键的 token,value 为 null 时等同于 Remove void Set(string key, string? value); /// 删除指定键的 token void Remove(string key); } ``` --- ## 19.4 KeychainTokenStorage — macOS Keychain 集成 ```csharp /// /// macOS Keychain 安全存储实现 /// 对应原始代码中对系统 Keychain 的直接访问 /// 通过 security CLI 工具与 macOS Keychain Services 通信 /// public sealed class KeychainTokenStorage : ISecureTokenStorage { /// /// 读取 Keychain 中的 token /// 等效命令: security find-generic-password -s free-code-{key} -w /// 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; } /// /// 写入 token 到 Keychain(-U 标志: 不存在则创建,存在则更新) /// 等效命令: security add-generic-password -U -s free-code-{key} -p {value} /// 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(); } /// /// 从 Keychain 删除 token /// 等效命令: security delete-generic-password -s free-code-{key} /// 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 容器可以按运行平台注入不同实现。注册时可这样处理: ```csharp if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) services.AddSingleton(); else services.AddSingleton(); ``` --- ## 参考资料 - [服务子系统设计总览](服务子系统设计.md) - [原始代码映射 — 服务子系统](reference/原始代码映射-服务子系统.md) - [核心模块设计 — API 提供商路由](../核心模块设计/核心模块设计-API提供商路由.md)