mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
6a34253934
Adds a new managed OAuth provider that lets Claude Code route requests through a user's ChatGPT Plus/Pro subscription via the chatgpt.com backend-api/codex endpoint. - CodexOAuthManager: OpenAI Device Code flow with multi-account support, JWT-based account identification, and automatic access_token refresh. - Reuses the generic managed-auth command surface (auth_start_login, auth_poll_for_account, etc.) via provider dispatch in commands/auth.rs. - ClaudeAdapter detects codex_oauth providers, forces the base URL to the ChatGPT backend, pins api_format to openai_responses, and emits Authorization + originator headers; the forwarder injects the dynamic access_token and ChatGPT-Account-Id per request. - transform_responses gains an is_codex_oauth path that aligns the body with OpenAI's codex-rs ResponsesApiRequest contract: sets store:false, appends reasoning.encrypted_content to include, strips max_output_tokens / temperature / top_p, injects default instructions/tools/parallel_tool_calls, and forces stream:true. Covered by 9 new unit tests plus regression guards for the non-Codex path. - Stream check reuses the same transform flag so detection matches the production request shape. - Frontend adds CodexOAuthSection + useCodexOauth hook, integrates it into ClaudeFormFields / ProviderForm / AuthCenterPanel, ships a new "Codex (ChatGPT Plus/Pro)" preset, and adds zh/en/ja i18n strings.
260 lines
7.7 KiB
Rust
260 lines
7.7 KiB
Rust
//! Authentication Types
|
||
//!
|
||
//! 定义认证信息和认证策略,支持多种上游供应商的认证方式。
|
||
|
||
/// 认证信息
|
||
///
|
||
/// 包含 API Key 和对应的认证策略
|
||
#[derive(Debug, Clone)]
|
||
pub struct AuthInfo {
|
||
/// API Key
|
||
pub api_key: String,
|
||
/// 认证策略
|
||
pub strategy: AuthStrategy,
|
||
/// OAuth access_token(用于 GoogleOAuth 策略)
|
||
pub access_token: Option<String>,
|
||
}
|
||
|
||
impl AuthInfo {
|
||
/// 创建新的认证信息
|
||
pub fn new(api_key: String, strategy: AuthStrategy) -> Self {
|
||
Self {
|
||
api_key,
|
||
strategy,
|
||
access_token: None,
|
||
}
|
||
}
|
||
|
||
/// 创建带有 access_token 的认证信息(用于 OAuth)
|
||
pub fn with_access_token(api_key: String, access_token: String) -> Self {
|
||
Self {
|
||
api_key,
|
||
strategy: AuthStrategy::GoogleOAuth,
|
||
access_token: Some(access_token),
|
||
}
|
||
}
|
||
|
||
/// 返回遮蔽后的 API Key(用于日志输出)
|
||
///
|
||
/// 显示前4位和后4位,中间用 `...` 代替
|
||
/// 如果 key 长度不足8位,则返回 `***`
|
||
#[allow(dead_code)]
|
||
pub fn masked_key(&self) -> String {
|
||
if self.api_key.chars().count() > 8 {
|
||
let prefix: String = self.api_key.chars().take(4).collect();
|
||
let suffix: String = self
|
||
.api_key
|
||
.chars()
|
||
.rev()
|
||
.take(4)
|
||
.collect::<Vec<_>>()
|
||
.into_iter()
|
||
.rev()
|
||
.collect();
|
||
format!("{prefix}...{suffix}")
|
||
} else {
|
||
"***".to_string()
|
||
}
|
||
}
|
||
|
||
/// 返回遮蔽后的 access_token(用于日志输出)
|
||
#[allow(dead_code)]
|
||
pub fn masked_access_token(&self) -> Option<String> {
|
||
self.access_token.as_ref().map(|token| {
|
||
if token.chars().count() > 8 {
|
||
let prefix: String = token.chars().take(4).collect();
|
||
let suffix: String = token
|
||
.chars()
|
||
.rev()
|
||
.take(4)
|
||
.collect::<Vec<_>>()
|
||
.into_iter()
|
||
.rev()
|
||
.collect();
|
||
format!("{prefix}...{suffix}")
|
||
} else {
|
||
"***".to_string()
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
/// 认证策略
|
||
///
|
||
/// 不同供应商使用不同的认证方式
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum AuthStrategy {
|
||
/// Anthropic 认证方式
|
||
/// - Header: `x-api-key: <api_key>`
|
||
/// - Header: `anthropic-version: 2023-06-01`
|
||
Anthropic,
|
||
|
||
/// Claude 中转服务认证方式(仅 Bearer,无 x-api-key)
|
||
///
|
||
/// - Header: `Authorization: Bearer <api_key>`
|
||
///
|
||
/// 用于不支持 x-api-key 的中转服务
|
||
ClaudeAuth,
|
||
|
||
/// Bearer Token 认证方式(OpenAI 等)
|
||
///
|
||
/// - Header: `Authorization: Bearer <api_key>`
|
||
Bearer,
|
||
|
||
/// Google API Key 认证方式
|
||
///
|
||
/// - Header: `x-goog-api-key: <api_key>`
|
||
Google,
|
||
|
||
/// Google OAuth 认证方式
|
||
///
|
||
/// - Header: `Authorization: Bearer <access_token>`
|
||
///
|
||
/// 用于 Gemini CLI 等需要 OAuth 的场景
|
||
GoogleOAuth,
|
||
|
||
/// GitHub Copilot 认证方式
|
||
///
|
||
/// - Header: `Authorization: Bearer <copilot_token>`
|
||
///
|
||
/// 使用动态获取的 Copilot Token(通过 GitHub OAuth 设备码流程获取)
|
||
GitHubCopilot,
|
||
|
||
/// Codex OAuth 认证方式(ChatGPT Plus/Pro)
|
||
///
|
||
/// - Header: `Authorization: Bearer <access_token>`
|
||
/// - Header: `ChatGPT-Account-Id: <account_id>` (来自 forwarder 注入)
|
||
/// - Header: `originator: cc-switch`
|
||
///
|
||
/// 使用动态获取的 OpenAI access_token(通过 Device Code 流程获取)
|
||
CodexOAuth,
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_masked_key_long() {
|
||
let auth = AuthInfo::new("sk-1234567890abcdef".to_string(), AuthStrategy::Bearer);
|
||
assert_eq!(auth.masked_key(), "sk-1...cdef");
|
||
}
|
||
|
||
#[test]
|
||
fn test_masked_key_short() {
|
||
let auth = AuthInfo::new("short".to_string(), AuthStrategy::Bearer);
|
||
assert_eq!(auth.masked_key(), "***");
|
||
}
|
||
|
||
#[test]
|
||
fn test_masked_key_exactly_8() {
|
||
let auth = AuthInfo::new("12345678".to_string(), AuthStrategy::Bearer);
|
||
assert_eq!(auth.masked_key(), "***");
|
||
}
|
||
|
||
#[test]
|
||
fn test_masked_key_9_chars() {
|
||
let auth = AuthInfo::new("123456789".to_string(), AuthStrategy::Bearer);
|
||
assert_eq!(auth.masked_key(), "1234...6789");
|
||
}
|
||
|
||
#[test]
|
||
fn test_masked_key_utf8_safe() {
|
||
let auth = AuthInfo::new("测试⚠️1234567890".to_string(), AuthStrategy::Bearer);
|
||
let masked = auth.masked_key();
|
||
assert!(!masked.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn test_auth_strategy_equality() {
|
||
assert_eq!(AuthStrategy::Anthropic, AuthStrategy::Anthropic);
|
||
assert_ne!(AuthStrategy::Anthropic, AuthStrategy::Bearer);
|
||
assert_ne!(AuthStrategy::Bearer, AuthStrategy::Google);
|
||
}
|
||
|
||
#[test]
|
||
fn test_auth_info_new_has_no_access_token() {
|
||
let auth = AuthInfo::new("api-key".to_string(), AuthStrategy::Bearer);
|
||
assert!(auth.access_token.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_auth_info_with_access_token() {
|
||
let auth = AuthInfo::with_access_token(
|
||
"refresh-token".to_string(),
|
||
"ya29.access-token-12345".to_string(),
|
||
);
|
||
assert_eq!(auth.api_key, "refresh-token");
|
||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||
assert_eq!(
|
||
auth.access_token,
|
||
Some("ya29.access-token-12345".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_masked_access_token_long() {
|
||
let auth =
|
||
AuthInfo::with_access_token("refresh".to_string(), "ya29.1234567890abcdef".to_string());
|
||
assert_eq!(auth.masked_access_token(), Some("ya29...cdef".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn test_masked_access_token_utf8_safe() {
|
||
let auth =
|
||
AuthInfo::with_access_token("refresh".to_string(), "令牌⚠️1234567890".to_string());
|
||
let masked = auth.masked_access_token().unwrap();
|
||
assert!(!masked.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn test_masked_access_token_short() {
|
||
let auth = AuthInfo::with_access_token("refresh".to_string(), "short".to_string());
|
||
assert_eq!(auth.masked_access_token(), Some("***".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn test_masked_access_token_none() {
|
||
let auth = AuthInfo::new("api-key".to_string(), AuthStrategy::Bearer);
|
||
assert!(auth.masked_access_token().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_claude_auth_strategy() {
|
||
let auth = AuthInfo::new("sk-test".to_string(), AuthStrategy::ClaudeAuth);
|
||
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
||
assert_ne!(auth.strategy, AuthStrategy::Anthropic);
|
||
assert_ne!(auth.strategy, AuthStrategy::Bearer);
|
||
}
|
||
|
||
#[test]
|
||
fn test_google_oauth_strategy() {
|
||
let auth = AuthInfo::new("refresh-token".to_string(), AuthStrategy::GoogleOAuth);
|
||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||
assert_ne!(auth.strategy, AuthStrategy::Google);
|
||
}
|
||
|
||
#[test]
|
||
fn test_all_strategies_are_distinct() {
|
||
let strategies = [
|
||
AuthStrategy::Anthropic,
|
||
AuthStrategy::ClaudeAuth,
|
||
AuthStrategy::Bearer,
|
||
AuthStrategy::Google,
|
||
AuthStrategy::GoogleOAuth,
|
||
AuthStrategy::GitHubCopilot,
|
||
AuthStrategy::CodexOAuth,
|
||
];
|
||
|
||
for (i, s1) in strategies.iter().enumerate() {
|
||
for (j, s2) in strategies.iter().enumerate() {
|
||
if i == j {
|
||
assert_eq!(s1, s2);
|
||
} else {
|
||
assert_ne!(s1, s2);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|