Files
CC-Switch/src-tauri/src/commands/auth.rs
T
hotelbe 87635e7fc6 feat(copilot): add GitHub Enterprise Server support (#2175)
* feat(copilot): add GitHub Enterprise Server support

* fix(copilot): address GHES PR review findings (P1 + 2×P2)

- P1: Use composite account ID (domain:user_id) for GHES to prevent
  cross-instance ID collisions; github.com keeps plain numeric ID for
  backward compatibilit
- P2-a: Use get_api_endpoint() for model list URL with automatic
  fallback to static URL when dynamic endpoint resolution fails
- P2-b: Add normalize_github_domain() as backend SSOT for domain
  normalization (lowercase, strip protocol/path/query, reject userinfo)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 20:29:46 +08:00

306 lines
11 KiB
Rust

use tauri::State;
use crate::commands::codex_oauth::CodexOAuthState;
use crate::commands::copilot::CopilotAuthState;
use crate::proxy::providers::codex_oauth_auth::CodexOAuthError;
use crate::proxy::providers::copilot_auth::{
CopilotAuthError, GitHubAccount, GitHubDeviceCodeResponse,
};
const AUTH_PROVIDER_GITHUB_COPILOT: &str = "github_copilot";
const AUTH_PROVIDER_CODEX_OAUTH: &str = "codex_oauth";
#[derive(Debug, Clone, serde::Serialize)]
pub struct ManagedAuthAccount {
pub id: String,
pub provider: String,
pub login: String,
pub avatar_url: Option<String>,
pub authenticated_at: i64,
pub is_default: bool,
pub github_domain: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ManagedAuthStatus {
pub provider: String,
pub authenticated: bool,
pub default_account_id: Option<String>,
pub migration_error: Option<String>,
pub accounts: Vec<ManagedAuthAccount>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ManagedAuthDeviceCodeResponse {
pub provider: String,
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub expires_in: u64,
pub interval: u64,
}
fn ensure_auth_provider(auth_provider: &str) -> Result<&'static str, String> {
match auth_provider {
AUTH_PROVIDER_GITHUB_COPILOT => Ok(AUTH_PROVIDER_GITHUB_COPILOT),
AUTH_PROVIDER_CODEX_OAUTH => Ok(AUTH_PROVIDER_CODEX_OAUTH),
_ => Err(format!("Unsupported auth provider: {auth_provider}")),
}
}
fn map_account(
provider: &str,
account: GitHubAccount,
default_account_id: Option<&str>,
) -> ManagedAuthAccount {
ManagedAuthAccount {
is_default: default_account_id == Some(account.id.as_str()),
id: account.id,
provider: provider.to_string(),
login: account.login,
avatar_url: account.avatar_url,
authenticated_at: account.authenticated_at,
github_domain: account.github_domain,
}
}
fn map_device_code_response(
provider: &str,
response: GitHubDeviceCodeResponse,
) -> ManagedAuthDeviceCodeResponse {
ManagedAuthDeviceCodeResponse {
provider: provider.to_string(),
device_code: response.device_code,
user_code: response.user_code,
verification_uri: response.verification_uri,
expires_in: response.expires_in,
interval: response.interval,
}
}
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_start_login(
auth_provider: String,
github_domain: Option<String>,
copilot_state: State<'_, CopilotAuthState>,
codex_state: State<'_, CodexOAuthState>,
) -> Result<ManagedAuthDeviceCodeResponse, String> {
let auth_provider = ensure_auth_provider(&auth_provider)?;
match auth_provider {
AUTH_PROVIDER_GITHUB_COPILOT => {
let auth_manager = copilot_state.0.read().await;
let response = auth_manager
.start_device_flow(github_domain.as_deref())
.await
.map_err(|e| e.to_string())?;
Ok(map_device_code_response(auth_provider, response))
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.read().await;
let response = auth_manager
.start_device_flow()
.await
.map_err(|e| e.to_string())?;
Ok(map_device_code_response(auth_provider, response))
}
_ => unreachable!(),
}
}
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_poll_for_account(
auth_provider: String,
device_code: String,
github_domain: Option<String>,
copilot_state: State<'_, CopilotAuthState>,
codex_state: State<'_, CodexOAuthState>,
) -> Result<Option<ManagedAuthAccount>, String> {
let auth_provider = ensure_auth_provider(&auth_provider)?;
match auth_provider {
AUTH_PROVIDER_GITHUB_COPILOT => {
let auth_manager = copilot_state.0.write().await;
match auth_manager
.poll_for_token(&device_code, github_domain.as_deref())
.await
{
Ok(account) => {
let default_account_id = auth_manager.get_status().await.default_account_id;
Ok(account.map(|account| {
map_account(auth_provider, account, default_account_id.as_deref())
}))
}
Err(CopilotAuthError::AuthorizationPending) => Ok(None),
Err(e) => Err(e.to_string()),
}
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.write().await;
match auth_manager.poll_for_token(&device_code).await {
Ok(account) => {
let default_account_id = auth_manager.get_status().await.default_account_id;
Ok(account.map(|account| {
map_account(auth_provider, account, default_account_id.as_deref())
}))
}
Err(CodexOAuthError::AuthorizationPending) => Ok(None),
Err(e) => Err(e.to_string()),
}
}
_ => unreachable!(),
}
}
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_list_accounts(
auth_provider: String,
copilot_state: State<'_, CopilotAuthState>,
codex_state: State<'_, CodexOAuthState>,
) -> Result<Vec<ManagedAuthAccount>, String> {
let auth_provider = ensure_auth_provider(&auth_provider)?;
match auth_provider {
AUTH_PROVIDER_GITHUB_COPILOT => {
let auth_manager = copilot_state.0.read().await;
let status = auth_manager.get_status().await;
let default_account_id = status.default_account_id.clone();
Ok(status
.accounts
.into_iter()
.map(|account| map_account(auth_provider, account, default_account_id.as_deref()))
.collect())
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.read().await;
let status = auth_manager.get_status().await;
let default_account_id = status.default_account_id.clone();
Ok(status
.accounts
.into_iter()
.map(|account| map_account(auth_provider, account, default_account_id.as_deref()))
.collect())
}
_ => unreachable!(),
}
}
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_get_status(
auth_provider: String,
copilot_state: State<'_, CopilotAuthState>,
codex_state: State<'_, CodexOAuthState>,
) -> Result<ManagedAuthStatus, String> {
let auth_provider = ensure_auth_provider(&auth_provider)?;
match auth_provider {
AUTH_PROVIDER_GITHUB_COPILOT => {
let auth_manager = copilot_state.0.read().await;
let status = auth_manager.get_status().await;
let default_account_id = status.default_account_id.clone();
Ok(ManagedAuthStatus {
provider: auth_provider.to_string(),
authenticated: status.authenticated,
default_account_id: default_account_id.clone(),
migration_error: status.migration_error,
accounts: status
.accounts
.into_iter()
.map(|account| {
map_account(auth_provider, account, default_account_id.as_deref())
})
.collect(),
})
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.read().await;
let status = auth_manager.get_status().await;
let default_account_id = status.default_account_id.clone();
Ok(ManagedAuthStatus {
provider: auth_provider.to_string(),
authenticated: status.authenticated,
default_account_id: default_account_id.clone(),
migration_error: None,
accounts: status
.accounts
.into_iter()
.map(|account| {
map_account(auth_provider, account, default_account_id.as_deref())
})
.collect(),
})
}
_ => unreachable!(),
}
}
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_remove_account(
auth_provider: String,
account_id: String,
copilot_state: State<'_, CopilotAuthState>,
codex_state: State<'_, CodexOAuthState>,
) -> Result<(), String> {
let auth_provider = ensure_auth_provider(&auth_provider)?;
match auth_provider {
AUTH_PROVIDER_GITHUB_COPILOT => {
let auth_manager = copilot_state.0.write().await;
auth_manager
.remove_account(&account_id)
.await
.map_err(|e| e.to_string())
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.write().await;
auth_manager
.remove_account(&account_id)
.await
.map_err(|e| e.to_string())
}
_ => unreachable!(),
}
}
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_set_default_account(
auth_provider: String,
account_id: String,
copilot_state: State<'_, CopilotAuthState>,
codex_state: State<'_, CodexOAuthState>,
) -> Result<(), String> {
let auth_provider = ensure_auth_provider(&auth_provider)?;
match auth_provider {
AUTH_PROVIDER_GITHUB_COPILOT => {
let auth_manager = copilot_state.0.write().await;
auth_manager
.set_default_account(&account_id)
.await
.map_err(|e| e.to_string())
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.write().await;
auth_manager
.set_default_account(&account_id)
.await
.map_err(|e| e.to_string())
}
_ => unreachable!(),
}
}
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_logout(
auth_provider: String,
copilot_state: State<'_, CopilotAuthState>,
codex_state: State<'_, CodexOAuthState>,
) -> Result<(), String> {
let auth_provider = ensure_auth_provider(&auth_provider)?;
match auth_provider {
AUTH_PROVIDER_GITHUB_COPILOT => {
let auth_manager = copilot_state.0.write().await;
auth_manager.clear_auth().await.map_err(|e| e.to_string())
}
AUTH_PROVIDER_CODEX_OAUTH => {
let auth_manager = codex_state.0.write().await;
auth_manager.clear_auth().await.map_err(|e| e.to_string())
}
_ => unreachable!(),
}
}