From e9aac47a2bad8edf33a577eb582af7fb4c6df363 Mon Sep 17 00:00:00 2001 From: limityan Date: Mon, 3 Aug 2026 10:41:20 +0800 Subject: [PATCH] refactor(mcp): converge OAuth integration ownership Move concrete RMCP authorization and credential access behind the services integration owner. Keep Core's compatibility facade and product callback orchestration while routing Desktop credential status through the assembled manager. --- Cargo.lock | 1 - scripts/core-boundaries/rules/crate-rules.mjs | 11 + .../core-boundaries/rules/feature-rules.mjs | 1 - scripts/core-boundaries/self-test.mjs | 11 +- src/apps/desktop/src/api/mcp_api.rs | 8 +- src/crates/assembly/core/Cargo.toml | 5 - .../assembly/core/src/service/mcp/auth.rs | 25 +- .../assembly/core/src/service/mcp/mod.rs | 10 +- .../src/service/mcp/server/manager/auth.rs | 553 +---------------- .../mcp/server/manager/auth_callback.rs | 561 ++++++++++++++++++ .../src/service/mcp/server/manager/mod.rs | 12 +- .../src/service/mcp/server/manager/tests.rs | 59 ++ .../services/services-integrations/AGENTS.md | 5 +- .../services-integrations/src/mcp/auth.rs | 26 +- .../tests/mcp_contracts.rs | 16 +- 15 files changed, 736 insertions(+), 568 deletions(-) create mode 100644 src/crates/assembly/core/src/service/mcp/server/manager/auth_callback.rs diff --git a/Cargo.lock b/Cargo.lock index 46eea98cbd..c6df8b380e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1007,7 +1007,6 @@ dependencies = [ "notify", "regex", "reqwest", - "rmcp", "rusqlite", "serde", "serde_json", diff --git a/scripts/core-boundaries/rules/crate-rules.mjs b/scripts/core-boundaries/rules/crate-rules.mjs index 4c68435fcb..69366bebee 100644 --- a/scripts/core-boundaries/rules/crate-rules.mjs +++ b/scripts/core-boundaries/rules/crate-rules.mjs @@ -69,6 +69,17 @@ export const noCoreDependencyCrates = [ ]; export const forbiddenManifestDependencyRules = [ + { + dependencyNames: ['rmcp'], + scanRoots: ['src/apps', 'src/crates', 'BitFun-Installer/src-tauri'], + workspaceManifestPath: 'Cargo.toml', + forbidWorkspaceAliases: false, + allowManifestPaths: [ + 'src/crates/services/services-integrations/Cargo.toml', + ], + reason: 'the RMCP SDK is a concrete MCP integration service dependency', + message: 'rmcp must stay in services-integrations and be consumed through its MCP owner facade', + }, { dependencyNames: ['bitfun-agent-runtime-ipc'], scanRoots: ['src/apps', 'src/crates', 'BitFun-Installer/src-tauri'], diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index e04f9f0749..cb3132f397 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -86,7 +86,6 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'indexmap', ownerFeatures: ['product-full'] }, { depName: 'md5', ownerFeatures: ['product-full'] }, { depName: 'reqwest', ownerFeatures: ['ai-adapter-runtime', 'product-full'] }, - { depName: 'rmcp', ownerFeatures: ['product-full'] }, { depName: 'rusqlite', ownerFeatures: ['product-full'] }, { depName: 'serde_yaml', ownerFeatures: ['workspace-runtime'] }, { depName: 'similar', ownerFeatures: ['product-full'] }, diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 287e4ac07d..d22f57ec00 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -763,6 +763,7 @@ export function runManifestParserSelfTest({ 'qrcode', 'rand', 'readability-js', + 'rmcp', 'russh', 'rustls', 'rustls-native-certs', @@ -779,7 +780,7 @@ export function runManifestParserSelfTest({ throw new Error(`core optional dependency owner rule must cover forbidden dependency ${dep}`); } } - for (const dep of ['rmcp', 'image', 'tool-runtime']) { + for (const dep of ['image', 'tool-runtime']) { if (!coreOptionalOwnerDeps.has(dep)) { throw new Error(`core optional dependency owner rule must cover ${dep}`); } @@ -1530,6 +1531,14 @@ export function runManifestParserSelfTest({ )) { throw new Error('speech engine manifest guard must allow only its integration service owner'); } + const rmcpManifestRule = forbiddenManifestDependencyRules.find((rule) => + rule.dependencyNames?.includes('rmcp'), + ); + if (!rmcpManifestRule?.allowManifestPaths?.includes( + 'src/crates/services/services-integrations/Cargo.toml', + )) { + throw new Error('RMCP manifest guard must allow only its integration service owner'); + } const coreSpeechOwnerRule = forbiddenContentUnderRules.find( (rule) => rule.path === 'src/crates/assembly/core/src/service', ); diff --git a/src/apps/desktop/src/api/mcp_api.rs b/src/apps/desktop/src/api/mcp_api.rs index bfb59d6727..8b0cd3dbed 100644 --- a/src/apps/desktop/src/api/mcp_api.rs +++ b/src/apps/desktop/src/api/mcp_api.rs @@ -2,9 +2,7 @@ use crate::api::app_state::AppState; use crate::startup_trace::DesktopStartupTrace; -use bitfun_core::service::mcp::auth::{ - has_stored_oauth_credentials, MCPRemoteOAuthSessionSnapshot, -}; +use bitfun_core::service::mcp::auth::MCPRemoteOAuthSessionSnapshot; use bitfun_core::service::mcp::config::MCPConfigService; use bitfun_core::service::mcp::protocol::{ MCPPrompt, MCPResource, PromptsGetResult, ResourcesReadResult, @@ -203,6 +201,7 @@ pub async fn get_mcp_servers(state: State<'_, AppState>) -> Result) -> Result Result, rmcp::transport::auth::AuthError> { + async fn load(&self) -> Result, AuthError> { MCPRemoteOAuthCredentialVault::new() - .map_err(|error| rmcp::transport::auth::AuthError::InternalError(error.to_string()))? + .map_err(|error| AuthError::InternalError(error.to_string()))? .load(&self.server_id) .await - .map_err(|error| rmcp::transport::auth::AuthError::InternalError(error.to_string())) + .map_err(|error| AuthError::InternalError(error.to_string())) } - async fn save( - &self, - credentials: StoredCredentials, - ) -> Result<(), rmcp::transport::auth::AuthError> { + async fn save(&self, credentials: StoredCredentials) -> Result<(), AuthError> { MCPRemoteOAuthCredentialVault::new() - .map_err(|error| rmcp::transport::auth::AuthError::InternalError(error.to_string()))? + .map_err(|error| AuthError::InternalError(error.to_string()))? .store(&self.server_id, &credentials) .await - .map_err(|error| rmcp::transport::auth::AuthError::InternalError(error.to_string())) + .map_err(|error| AuthError::InternalError(error.to_string())) } - async fn clear(&self) -> Result<(), rmcp::transport::auth::AuthError> { + async fn clear(&self) -> Result<(), AuthError> { MCPRemoteOAuthCredentialVault::new() - .map_err(|error| rmcp::transport::auth::AuthError::InternalError(error.to_string()))? + .map_err(|error| AuthError::InternalError(error.to_string()))? .clear(&self.server_id) .await - .map_err(|error| rmcp::transport::auth::AuthError::InternalError(error.to_string())) + .map_err(|error| AuthError::InternalError(error.to_string())) } } diff --git a/src/crates/assembly/core/src/service/mcp/mod.rs b/src/crates/assembly/core/src/service/mcp/mod.rs index 673a9cb19f..f2e2b28472 100644 --- a/src/crates/assembly/core/src/service/mcp/mod.rs +++ b/src/crates/assembly/core/src/service/mcp/mod.rs @@ -54,7 +54,15 @@ impl MCPService { config_service: Arc, ) -> crate::util::errors::BitFunResult { let mcp_config_service = Arc::new(MCPConfigService::new(config_service)?); - let server_manager = Arc::new(MCPServerManager::new(mcp_config_service.clone())); + // Keep service startup compatible when strict path initialization is + // unavailable; OAuth operations retain the existing lazy error path. + let oauth_data_dir = crate::infrastructure::try_get_path_manager_arc() + .ok() + .map(|manager| manager.user_data_dir()); + let server_manager = Arc::new(MCPServerManager::assemble( + mcp_config_service.clone(), + oauth_data_dir, + )); let context_provider = Arc::new(MCPContextProvider::new(server_manager.clone())); Ok(Self { diff --git a/src/crates/assembly/core/src/service/mcp/server/manager/auth.rs b/src/crates/assembly/core/src/service/mcp/server/manager/auth.rs index e4ecd4e339..e12225c947 100644 --- a/src/crates/assembly/core/src/service/mcp/server/manager/auth.rs +++ b/src/crates/assembly/core/src/service/mcp/server/manager/auth.rs @@ -1,515 +1,32 @@ -use std::collections::HashMap; use std::sync::Arc; -use axum::{ - extract::{Query, State}, - http::HeaderMap, - response::{Html, IntoResponse}, - routing::get, - Router, -}; use reqwest::Url; use tokio::sync::{oneshot, Mutex}; use tokio::time::{timeout, Duration}; use crate::service::config::app_language::get_app_language_code; -use crate::service::i18n::LocaleId; use crate::service::mcp::auth::{ - clear_stored_oauth_credentials, map_auth_error, prepare_remote_oauth_authorization, - MCPRemoteOAuthSessionSnapshot, MCPRemoteOAuthStatus, + map_auth_error, MCPRemoteOAuthSessionSnapshot, MCPRemoteOAuthStatus, }; use crate::service::mcp::server::MCPServerType; use crate::util::errors::{BitFunError, BitFunResult}; +use bitfun_services_integrations::mcp::auth::{ + clear_stored_oauth_credentials, has_stored_oauth_credentials, + prepare_remote_oauth_authorization, +}; +use super::auth_callback::build_oauth_callback_router; use super::{ActiveRemoteOAuthSession, MCPServerManager}; const OAUTH_CALLBACK_TIMEOUT: Duration = Duration::from_secs(300); -#[derive(Debug)] -struct OAuthCallbackPayload { - code: Option, - state: Option, - error: Option, - error_description: Option, -} - -#[derive(Clone, Copy)] -enum OAuthCallbackLocale { - ZhCN, - ZhTW, - EnUS, -} - -struct OAuthCallbackPageCopy { - html_lang: &'static str, - page_title: &'static str, - brand_label: &'static str, - badge_success: &'static str, - badge_warning: &'static str, - badge_error: &'static str, - success_title: &'static str, - success_message: &'static str, - success_detail_title: &'static str, - success_detail_body: &'static str, - warning_title: &'static str, - warning_message: &'static str, - warning_detail_title: &'static str, - error_title: &'static str, - error_message: &'static str, - error_detail_title: &'static str, - close_hint: &'static str, -} - -impl OAuthCallbackLocale { - fn from_language_code(value: &str) -> Option { - match LocaleId::from_str(value)? { - LocaleId::ZhCN => Some(Self::ZhCN), - LocaleId::ZhTW => Some(Self::ZhTW), - LocaleId::EnUS => Some(Self::EnUS), - } - } - - fn from_accept_language(value: &str) -> Self { - value - .split(',') - .filter_map(|part| part.split(';').next()) - .find_map(|part| Self::from_language_code(part.trim())) - .unwrap_or(Self::ZhCN) - } - - fn copy(self) -> OAuthCallbackPageCopy { - match self { - Self::ZhCN => OAuthCallbackPageCopy { - html_lang: "zh-CN", - page_title: "BitFun OAuth 回调", - brand_label: "BitFun Desktop", - badge_success: "已收到授权", - badge_warning: "回调参数不完整", - badge_error: "授权失败", - success_title: "BitFun 已收到 OAuth 回调", - success_message: "可以返回 BitFun。应用正在交换授权码并重新连接 MCP 服务器。", - success_detail_title: "接下来会发生什么", - success_detail_body: - "这个页面可以直接关闭。如果 BitFun 没有自动完成重连,请回到 MCP 设置页后重试 OAuth。", - warning_title: "BitFun 收到的 OAuth 回调缺少必要参数", - warning_message: - "OAuth 提供方已跳转回来,但缺少必须的参数。请返回 BitFun 重新发起登录流程。", - warning_detail_title: "缺少的参数", - error_title: "BitFun 未能完成 OAuth 授权", - error_message: - "请返回 BitFun,并根据下面的提供方返回信息检查问题后重新发起 OAuth。", - error_detail_title: "提供方返回", - close_hint: "处理完成后,这个页面可以直接关闭。", - }, - Self::ZhTW => OAuthCallbackPageCopy { - html_lang: "zh-TW", - page_title: "BitFun OAuth 回調", - brand_label: "BitFun Desktop", - badge_success: "已收到授權", - badge_warning: "回調參數不完整", - badge_error: "授權失敗", - success_title: "BitFun 已收到 OAuth 回調", - success_message: "可以返回 BitFun。應用正在交換授權碼並重新連接 MCP 服務器。", - success_detail_title: "接下來會發生什麼", - success_detail_body: - "這個頁面可以直接關閉。如果 BitFun 沒有自動完成重連,請回到 MCP 設置頁後重試 OAuth。", - warning_title: "BitFun 收到的 OAuth 回調缺少必要參數", - warning_message: - "OAuth 提供方已跳轉回來,但缺少必須的參數。請返回 BitFun 重新發起登錄流程。", - warning_detail_title: "缺少的參數", - error_title: "BitFun 未能完成 OAuth 授權", - error_message: - "請返回 BitFun,並根據下面的提供方返回信息檢查問題後重新發起 OAuth。", - error_detail_title: "提供方返回", - close_hint: "處理完成後,這個頁面可以直接關閉。", - }, - Self::EnUS => OAuthCallbackPageCopy { - html_lang: "en-US", - page_title: "BitFun OAuth Callback", - brand_label: "BitFun Desktop", - badge_success: "Authorization received", - badge_warning: "Callback incomplete", - badge_error: "Authorization failed", - success_title: "BitFun received the OAuth callback", - success_message: - "You can return to BitFun now. The app is exchanging the authorization code and reconnecting the MCP server.", - success_detail_title: "What happens next", - success_detail_body: - "This page can be closed now. If BitFun does not finish reconnecting automatically, return to MCP settings and retry OAuth.", - warning_title: "BitFun received an OAuth callback with missing parameters", - warning_message: - "The provider redirected back, but required OAuth parameters were missing. Return to BitFun and start the sign-in flow again.", - warning_detail_title: "Missing parameters", - error_title: "BitFun could not finish the OAuth authorization", - error_message: - "Return to BitFun and review the provider response below before retrying OAuth.", - error_detail_title: "Provider response", - close_hint: "This page can be closed after you review the status.", - }, - } +impl MCPServerManager { + fn oauth_data_dir(&self) -> BitFunResult { + self.oauth_data_dir.clone().map(Ok).unwrap_or_else(|| { + Ok(crate::infrastructure::try_get_path_manager_arc()?.user_data_dir()) + }) } -} - -fn escape_html(input: &str) -> String { - input - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} -fn resolve_oauth_callback_locale( - preferred_language: Option<&str>, - accept_language: Option<&str>, -) -> OAuthCallbackLocale { - preferred_language - .and_then(OAuthCallbackLocale::from_language_code) - .or_else(|| accept_language.map(OAuthCallbackLocale::from_accept_language)) - .unwrap_or(OAuthCallbackLocale::ZhCN) -} - -fn render_oauth_callback_page( - payload: &OAuthCallbackPayload, - locale: OAuthCallbackLocale, -) -> String { - let copy = locale.copy(); - let (badge, badge_class, title, message, detail_title, detail_body, icon_label) = - if let Some(error) = payload.error.as_deref() { - let description = payload - .error_description - .as_deref() - .unwrap_or(match locale { - OAuthCallbackLocale::ZhCN => "OAuth 提供方拒绝了这次授权请求。", - OAuthCallbackLocale::ZhTW => "OAuth 提供方拒絕了這次授權請求。", - OAuthCallbackLocale::EnUS => "The provider rejected the authorization request.", - }); - ( - copy.badge_error, - "is-error", - copy.error_title, - copy.error_message, - copy.error_detail_title, - format!("{}: {}", escape_html(error), escape_html(description)), - "!", - ) - } else if payload.code.is_some() && payload.state.is_some() { - ( - copy.badge_success, - "is-success", - copy.success_title, - copy.success_message, - copy.success_detail_title, - copy.success_detail_body.to_string(), - match locale { - OAuthCallbackLocale::ZhCN => "完成", - OAuthCallbackLocale::ZhTW => "完成", - OAuthCallbackLocale::EnUS => "Done", - }, - ) - } else { - let mut missing = Vec::new(); - if payload.code.is_none() { - missing.push("code"); - } - if payload.state.is_none() { - missing.push("state"); - } - ( - copy.badge_warning, - "is-warning", - copy.warning_title, - copy.warning_message, - copy.warning_detail_title, - escape_html(&missing.join(", ")), - "?", - ) - }; - - format!( - r#" - - - - - {page_title} - - - -
-
-
-
-
-
BF
-
- {brand_label} -

{title}

-
-
-
-
{badge}
-

{message}

-
-
{icon_label}
-
-

{detail_title}

-

{detail_body}

-
-
-
-

{close_hint}

-
-
-
-
- -"#, - html_lang = copy.html_lang, - page_title = copy.page_title, - brand_label = copy.brand_label, - title = title, - badge = badge, - badge_class = badge_class, - message = message, - detail_title = detail_title, - detail_body = detail_body, - icon_label = icon_label, - close_hint = copy.close_hint, - ) -} - -#[derive(Clone)] -struct OAuthCallbackAppState { - callback_tx: Arc>>>, - preferred_language: String, -} - -impl MCPServerManager { pub(super) async fn set_oauth_snapshot( session: &Arc, snapshot: MCPRemoteOAuthSessionSnapshot, @@ -582,7 +99,9 @@ impl MCPServerManager { Self::shutdown_oauth_session(&existing).await; } - let prepared = prepare_remote_oauth_authorization(&config).await?; + let prepared = prepare_remote_oauth_authorization(self.oauth_data_dir()?, &config) + .await + .map_err(map_auth_error)?; let callback_path = Url::parse(&prepared.redirect_uri) .map_err(|error| { BitFunError::MCPError(format!( @@ -612,13 +131,8 @@ impl MCPServerManager { Self::shutdown_oauth_session(&previous).await; } - let callback_state = OAuthCallbackAppState { - callback_tx: Arc::new(Mutex::new(Some(callback_tx))), - preferred_language: get_app_language_code().await, - }; - let router = Router::new() - .route(&callback_path, get(handle_oauth_callback)) - .with_state(callback_state); + let router = + build_oauth_callback_router(&callback_path, callback_tx, get_app_language_code().await); let callback_server_session = session.clone(); let callback_server_id = server_id.to_string(); tokio::spawn(async move { @@ -786,6 +300,12 @@ impl MCPServerManager { Some(snapshot) } + pub async fn has_remote_oauth_credentials(&self, server_id: &str) -> BitFunResult { + has_stored_oauth_credentials(self.oauth_data_dir()?, server_id) + .await + .map_err(map_auth_error) + } + pub async fn cancel_remote_oauth_authorization(&self, server_id: &str) -> BitFunResult<()> { let session = self.oauth_sessions.write().await.remove(server_id); if let Some(session) = session { @@ -801,31 +321,8 @@ impl MCPServerManager { pub async fn clear_remote_oauth_credentials(&self, server_id: &str) -> BitFunResult<()> { self.cancel_remote_oauth_authorization(server_id).await?; - clear_stored_oauth_credentials(server_id).await - } -} - -async fn handle_oauth_callback( - State(state): State, - headers: HeaderMap, - Query(params): Query>, -) -> impl IntoResponse { - let payload = OAuthCallbackPayload { - code: params.get("code").cloned(), - state: params.get("state").cloned(), - error: params.get("error").cloned(), - error_description: params.get("error_description").cloned(), - }; - let accept_language = headers - .get(axum::http::header::ACCEPT_LANGUAGE) - .and_then(|value| value.to_str().ok()); - let locale = - resolve_oauth_callback_locale(Some(state.preferred_language.as_str()), accept_language); - let page = render_oauth_callback_page(&payload, locale); - - if let Some(callback_tx) = state.callback_tx.lock().await.take() { - let _ = callback_tx.send(payload); + clear_stored_oauth_credentials(self.oauth_data_dir()?, server_id) + .await + .map_err(map_auth_error) } - - Html(page) } diff --git a/src/crates/assembly/core/src/service/mcp/server/manager/auth_callback.rs b/src/crates/assembly/core/src/service/mcp/server/manager/auth_callback.rs new file mode 100644 index 0000000000..3e63f5cff8 --- /dev/null +++ b/src/crates/assembly/core/src/service/mcp/server/manager/auth_callback.rs @@ -0,0 +1,561 @@ +//! Product-owned HTTP callback adapter and localized MCP OAuth result page. + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::{ + extract::{Query, State}, + http::HeaderMap, + response::{Html, IntoResponse}, + routing::get, + Router, +}; +use tokio::sync::{oneshot, Mutex}; + +use crate::service::i18n::LocaleId; + +#[derive(Debug)] +pub(super) struct OAuthCallbackPayload { + pub(super) code: Option, + pub(super) state: Option, + pub(super) error: Option, + pub(super) error_description: Option, +} + +#[derive(Clone, Copy)] +enum OAuthCallbackLocale { + ZhCN, + ZhTW, + EnUS, +} + +struct OAuthCallbackPageCopy { + html_lang: &'static str, + page_title: &'static str, + brand_label: &'static str, + badge_success: &'static str, + badge_warning: &'static str, + badge_error: &'static str, + success_title: &'static str, + success_message: &'static str, + success_detail_title: &'static str, + success_detail_body: &'static str, + warning_title: &'static str, + warning_message: &'static str, + warning_detail_title: &'static str, + error_title: &'static str, + error_message: &'static str, + error_detail_title: &'static str, + close_hint: &'static str, +} + +impl OAuthCallbackLocale { + fn from_language_code(value: &str) -> Option { + match LocaleId::from_str(value)? { + LocaleId::ZhCN => Some(Self::ZhCN), + LocaleId::ZhTW => Some(Self::ZhTW), + LocaleId::EnUS => Some(Self::EnUS), + } + } + + fn from_accept_language(value: &str) -> Self { + value + .split(',') + .filter_map(|part| part.split(';').next()) + .find_map(|part| Self::from_language_code(part.trim())) + .unwrap_or(Self::ZhCN) + } + + fn copy(self) -> OAuthCallbackPageCopy { + match self { + Self::ZhCN => OAuthCallbackPageCopy { + html_lang: "zh-CN", + page_title: "BitFun OAuth 回调", + brand_label: "BitFun Desktop", + badge_success: "已收到授权", + badge_warning: "回调参数不完整", + badge_error: "授权失败", + success_title: "BitFun 已收到 OAuth 回调", + success_message: "可以返回 BitFun。应用正在交换授权码并重新连接 MCP 服务器。", + success_detail_title: "接下来会发生什么", + success_detail_body: + "这个页面可以直接关闭。如果 BitFun 没有自动完成重连,请回到 MCP 设置页后重试 OAuth。", + warning_title: "BitFun 收到的 OAuth 回调缺少必要参数", + warning_message: + "OAuth 提供方已跳转回来,但缺少必须的参数。请返回 BitFun 重新发起登录流程。", + warning_detail_title: "缺少的参数", + error_title: "BitFun 未能完成 OAuth 授权", + error_message: + "请返回 BitFun,并根据下面的提供方返回信息检查问题后重新发起 OAuth。", + error_detail_title: "提供方返回", + close_hint: "处理完成后,这个页面可以直接关闭。", + }, + Self::ZhTW => OAuthCallbackPageCopy { + html_lang: "zh-TW", + page_title: "BitFun OAuth 回調", + brand_label: "BitFun Desktop", + badge_success: "已收到授權", + badge_warning: "回調參數不完整", + badge_error: "授權失敗", + success_title: "BitFun 已收到 OAuth 回調", + success_message: "可以返回 BitFun。應用正在交換授權碼並重新連接 MCP 服務器。", + success_detail_title: "接下來會發生什麼", + success_detail_body: + "這個頁面可以直接關閉。如果 BitFun 沒有自動完成重連,請回到 MCP 設置頁後重試 OAuth。", + warning_title: "BitFun 收到的 OAuth 回調缺少必要參數", + warning_message: + "OAuth 提供方已跳轉回來,但缺少必須的參數。請返回 BitFun 重新發起登錄流程。", + warning_detail_title: "缺少的參數", + error_title: "BitFun 未能完成 OAuth 授權", + error_message: + "請返回 BitFun,並根據下面的提供方返回信息檢查問題後重新發起 OAuth。", + error_detail_title: "提供方返回", + close_hint: "處理完成後,這個頁面可以直接關閉。", + }, + Self::EnUS => OAuthCallbackPageCopy { + html_lang: "en-US", + page_title: "BitFun OAuth Callback", + brand_label: "BitFun Desktop", + badge_success: "Authorization received", + badge_warning: "Callback incomplete", + badge_error: "Authorization failed", + success_title: "BitFun received the OAuth callback", + success_message: + "You can return to BitFun now. The app is exchanging the authorization code and reconnecting the MCP server.", + success_detail_title: "What happens next", + success_detail_body: + "This page can be closed now. If BitFun does not finish reconnecting automatically, return to MCP settings and retry OAuth.", + warning_title: "BitFun received an OAuth callback with missing parameters", + warning_message: + "The provider redirected back, but required OAuth parameters were missing. Return to BitFun and start the sign-in flow again.", + warning_detail_title: "Missing parameters", + error_title: "BitFun could not finish the OAuth authorization", + error_message: + "Return to BitFun and review the provider response below before retrying OAuth.", + error_detail_title: "Provider response", + close_hint: "This page can be closed after you review the status.", + }, + } + } +} + +fn escape_html(input: &str) -> String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn resolve_oauth_callback_locale( + preferred_language: Option<&str>, + accept_language: Option<&str>, +) -> OAuthCallbackLocale { + preferred_language + .and_then(OAuthCallbackLocale::from_language_code) + .or_else(|| accept_language.map(OAuthCallbackLocale::from_accept_language)) + .unwrap_or(OAuthCallbackLocale::ZhCN) +} + +fn render_oauth_callback_page( + payload: &OAuthCallbackPayload, + locale: OAuthCallbackLocale, +) -> String { + let copy = locale.copy(); + let (badge, badge_class, title, message, detail_title, detail_body, icon_label) = + if let Some(error) = payload.error.as_deref() { + let description = payload + .error_description + .as_deref() + .unwrap_or(match locale { + OAuthCallbackLocale::ZhCN => "OAuth 提供方拒绝了这次授权请求。", + OAuthCallbackLocale::ZhTW => "OAuth 提供方拒絕了這次授權請求。", + OAuthCallbackLocale::EnUS => "The provider rejected the authorization request.", + }); + ( + copy.badge_error, + "is-error", + copy.error_title, + copy.error_message, + copy.error_detail_title, + format!("{}: {}", escape_html(error), escape_html(description)), + "!", + ) + } else if payload.code.is_some() && payload.state.is_some() { + ( + copy.badge_success, + "is-success", + copy.success_title, + copy.success_message, + copy.success_detail_title, + copy.success_detail_body.to_string(), + match locale { + OAuthCallbackLocale::ZhCN => "完成", + OAuthCallbackLocale::ZhTW => "完成", + OAuthCallbackLocale::EnUS => "Done", + }, + ) + } else { + let mut missing = Vec::new(); + if payload.code.is_none() { + missing.push("code"); + } + if payload.state.is_none() { + missing.push("state"); + } + ( + copy.badge_warning, + "is-warning", + copy.warning_title, + copy.warning_message, + copy.warning_detail_title, + escape_html(&missing.join(", ")), + "?", + ) + }; + + format!( + r#" + + + + + {page_title} + + + +
+
+
+
+
+
BF
+
+ {brand_label} +

{title}

+
+
+
+
{badge}
+

{message}

+
+
{icon_label}
+
+

{detail_title}

+

{detail_body}

+
+
+
+

{close_hint}

+
+
+
+
+ +"#, + html_lang = copy.html_lang, + page_title = copy.page_title, + brand_label = copy.brand_label, + title = title, + badge = badge, + badge_class = badge_class, + message = message, + detail_title = detail_title, + detail_body = detail_body, + icon_label = icon_label, + close_hint = copy.close_hint, + ) +} + +#[derive(Clone)] +struct OAuthCallbackAppState { + callback_tx: Arc>>>, + preferred_language: String, +} + +pub(super) fn build_oauth_callback_router( + callback_path: &str, + callback_tx: oneshot::Sender, + preferred_language: String, +) -> Router { + let state = OAuthCallbackAppState { + callback_tx: Arc::new(Mutex::new(Some(callback_tx))), + preferred_language, + }; + Router::new() + .route(callback_path, get(handle_oauth_callback)) + .with_state(state) +} + +async fn handle_oauth_callback( + State(state): State, + headers: HeaderMap, + Query(params): Query>, +) -> impl IntoResponse { + let payload = OAuthCallbackPayload { + code: params.get("code").cloned(), + state: params.get("state").cloned(), + error: params.get("error").cloned(), + error_description: params.get("error_description").cloned(), + }; + let accept_language = headers + .get(axum::http::header::ACCEPT_LANGUAGE) + .and_then(|value| value.to_str().ok()); + let locale = + resolve_oauth_callback_locale(Some(state.preferred_language.as_str()), accept_language); + let page = render_oauth_callback_page(&payload, locale); + + if let Some(callback_tx) = state.callback_tx.lock().await.take() { + let _ = callback_tx.send(payload); + } + + Html(page) +} + +#[cfg(test)] +mod tests { + use super::{render_oauth_callback_page, resolve_oauth_callback_locale, OAuthCallbackPayload}; + + #[test] + fn callback_page_prefers_the_product_locale_and_escapes_provider_errors() { + let locale = resolve_oauth_callback_locale(Some("en-US"), Some("zh-CN,zh;q=0.9")); + let page = render_oauth_callback_page( + &OAuthCallbackPayload { + code: None, + state: None, + error: Some("denied