diff --git a/Cargo.lock b/Cargo.lock index 46eea98cb..c6df8b380 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 4c68435fc..69366bebe 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 e04f9f074..cb3132f39 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 287e4ac07..d22f57ec0 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 bfb59d672..8b0cd3dbe 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 673a9cb19..f2e2b2847 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 e4ecd4e33..e12225c94 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 000000000..3e63f5cff --- /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