11 KiB
服务子系统设计 — 认证与 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>写入指定键的 token,value 为 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>();