diff --git a/crates/app/src/callbacks_pages.rs b/crates/app/src/callbacks_pages.rs index 3b22bbc..cdb89da 100644 --- a/crates/app/src/callbacks_pages.rs +++ b/crates/app/src/callbacks_pages.rs @@ -56,6 +56,14 @@ pub(crate) fn wire_pages(weak: Weak) { this.sync(); }); } + { + let weak = weak.clone(); + page.on_msa_browser_begin(move || { + let Some(this) = weak.upgrade() else { return }; + this.dispatch(AppCommand::StartBrowserLogin); + this.sync(); + }); + } { let weak = weak.clone(); page.on_msa_cancel(move || { diff --git a/crates/app/src/conv/pages.rs b/crates/app/src/conv/pages.rs index 2161f55..4c1a4df 100644 --- a/crates/app/src/conv/pages.rs +++ b/crates/app/src/conv/pages.rs @@ -58,7 +58,7 @@ pub fn active_account_name(state: &AppState) -> String { .unwrap_or_else(|| "未登录".to_string()) } -/// 设备码登录面板。 +/// 微软登录面板。 pub fn device(state: &AppState) -> UiDeviceCode { let view = &state.device_login; UiDeviceCode { diff --git a/crates/app/src/conv/pages_tests.rs b/crates/app/src/conv/pages_tests.rs index db4469a..cd7f23b 100644 --- a/crates/app/src/conv/pages_tests.rs +++ b/crates/app/src/conv/pages_tests.rs @@ -35,6 +35,22 @@ fn accounts_and_device_login_render() { assert_eq!(device.phase_label, "等待确认"); } +#[test] +fn browser_login_panel_renders_the_authorization_link() { + let mut state = AppState::new(); + state.device_login = DeviceLoginView::browser_waiting( + "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=x", + 300, + None, + ); + let device = device(&state); + assert!(device.visible && device.busy); + assert_eq!(device.user_code, "", "授权码流没有用户码"); + assert!(device.verification_uri.contains("/authorize?client_id=x")); + assert_eq!(device.qr_link, device.verification_uri); + assert_eq!(device.expires, 300); +} + #[test] fn install_progress_renders_labels_and_terminal_flags() { let mut state = AppState::new(); diff --git a/crates/app/src/dto/accounts.rs b/crates/app/src/dto/accounts.rs index 346feff..ffbdd14 100644 --- a/crates/app/src/dto/accounts.rs +++ b/crates/app/src/dto/accounts.rs @@ -1,4 +1,4 @@ -//! 账户页 DTO:三条轨道的统一抽象 + 微软设备码登录的可见状态。 +//! 账户页 DTO:三条轨道的统一抽象 + 微软登录(设备码 / 授权码浏览器)的可见状态。 //! //! **凭据红线**:本模块(以及整个 `crate::dto`)只有 [`AccountView`] 这种公开字段视图。 //! `core::auth` 的 `Credentials` / `MicrosoftSession` / token 永远不进 app 层—— @@ -65,7 +65,7 @@ pub struct AccountView { pub active: bool, } -/// 设备码登录阶段(UI 据此切换面板)。 +/// 微软登录阶段(UI 据此切换面板;两条流共用)。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum DeviceLoginPhase { /// 没有登录在进行 @@ -160,6 +160,33 @@ impl DeviceLoginView { } } + /// 授权码流:本地回调已就绪、浏览器已拉起(`present` 回调时刻)。 + /// + /// 面板不展示用户码,改展示**可复制的授权链接**(用户可能需要在另一台设备/ + /// 另一个浏览器里手动打开);`warning` 是「浏览器没拉起来」这类非致命提示。 + pub fn browser_waiting( + authorization_url: &str, + expires_in: u64, + warning: Option<&str>, + ) -> Self { + let mut message = format!( + "已在系统浏览器打开微软登录页,请完成授权({} 秒内有效);没弹出就把下面的链接粘到浏览器里", + expires_in.max(1) + ); + if let Some(warning) = warning { + message.push_str(&format!("({warning})")); + } + Self { + phase: DeviceLoginPhase::Waiting, + user_code: String::new(), + verification_uri: authorization_url.to_string(), + qr_link: authorization_url.to_string(), + expires_in, + message, + account_name: String::new(), + } + } + /// 失败态。 pub fn failed(message: impl Into) -> Self { Self { @@ -212,6 +239,25 @@ mod tests { } } + #[test] + fn browser_waiting_shows_a_copyable_link_instead_of_a_user_code() { + let view = DeviceLoginView::browser_waiting( + "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=x", + 300, + Some("拉起浏览器失败"), + ); + assert_eq!(view.phase, DeviceLoginPhase::Waiting); + assert!(view.phase.busy()); + assert!(view.user_code.is_empty(), "授权码流没有用户码"); + assert_eq!(view.qr_link, view.verification_uri); + assert!(view.message.contains("浏览器")); + assert!(view.message.contains("拉起浏览器失败"), "{}", view.message); + let json = serde_json::to_string(&view).expect("序列化"); + for forbidden in ["refresh", "mc-access"] { + assert!(!json.contains(forbidden), "视图里不该出现 {forbidden}"); + } + } + #[test] fn succeeded_and_failed_clear_the_code() { let ok = DeviceLoginView::succeeded("Steve"); diff --git a/crates/app/src/dto/events.rs b/crates/app/src/dto/events.rs index 739d621..248d628 100644 --- a/crates/app/src/dto/events.rs +++ b/crates/app/src/dto/events.rs @@ -65,7 +65,7 @@ pub enum CoreEvent { DiagnosisReady(DiagnosisReport), /// 账户列表刷新 AccountsChanged(Vec), - /// 设备码登录状态刷新 + /// 微软登录状态刷新 DeviceLoginChanged(DeviceLoginView), /// Java 页快照(发行版/回退链/运行时) JavaChanged(JavaPageView), @@ -129,9 +129,11 @@ pub enum AppCommand { RemoveAccount(String), /// 切换当前启动账户 SwitchAccount(String), - /// 开始微软设备码登录 + /// 开始微软设备码登录(CLI/无浏览器场景、备用通道) StartDeviceLogin, - /// 取消/关掉设备码登录面板(已发出的请求会在收尾时丢弃结果) + /// 开始微软授权码登录(拉起系统默认浏览器 + 本地回调,GUI 的默认入口) + StartBrowserLogin, + /// 取消/关掉微软登录面板(已发出的请求会在收尾时丢弃结果) CancelDeviceLogin, /// 安装指定发行版的托管 JDK(`temurin` / `oracle` / `microsoft` / `zulu`) InstallJava(String), diff --git a/crates/app/src/host/accounts.rs b/crates/app/src/host/accounts.rs index 652a363..46c767a 100644 --- a/crates/app/src/host/accounts.rs +++ b/crates/app/src/host/accounts.rs @@ -1,24 +1,29 @@ -//! 账户:三轨统一列表(只读公开字段)+ 离线新增 + 删除/切换 + 微软设备码登录。 +//! 账户:三轨统一列表(只读公开字段)+ 离线新增 + 删除/切换 + 微软登录(两条流)。 //! //! 凭据红线(D9):`Credentials` / `MicrosoftSession` 只在本模块与 `launch.rs` 里出现, -//! 出口一律是 [`AccountView`] / [`DeviceLoginView`]。设备码本身不是凭据,可以直接展示。 +//! 出口一律是 [`AccountView`] / [`DeviceLoginView`]。 //! -//! 取消语义:设备码轮询跑在 `core` 里、没有取消钩子,所以取消只把面板收回;线程收尾时 +//! 两条登录流:授权码([`start_browser_login`],GUI 默认:拉起系统浏览器 + 本地回调, +//! `research/08` §3 方案 A)与设备码([`start_login`],无浏览器/CLI 场景)。除了「怎么拿到 +//! MS access_token」之外完全共用:同一个取消槽位、同一段账户入库、同一个刷新链。 +//! +//! 取消语义:轮询/等回调都跑在 `core` 里、没有取消钩子,所以取消只把面板收回;线程收尾时 //! 若发现「已取消」就不入库(避免用户点了取消却凭空多出一个账户)。 use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use unlucky_core::auth::account::Credentials; -use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth}; +use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth, MicrosoftSession}; use unlucky_core::auth::offline::offline_meta; -use unlucky_core::auth::AccountStore; +use unlucky_core::auth::{AccountStore, AuthResult}; use unlucky_core::download::blocking::block_on; use crate::dto::{AccountKind, AccountView, DeviceLoginView}; use super::env::HostEnv; use super::job::{spawn, HostTask, JobSender}; +use super::opener; /// 账户列表(公开字段)。 pub fn accounts(env: &HostEnv, active: Option<&str>) -> Vec { @@ -103,7 +108,7 @@ pub fn remove(env: &HostEnv, key: &str) -> Result { Ok(meta.display_name().to_string()) } -/// 设备码登录任务句柄(取消标志 + 账户名回执)。 +/// 微软登录任务句柄(取消标志 + 账户名回执;两条流共用)。 #[derive(Debug, Clone, Default)] pub struct LoginJob { /// 用户点了取消(线程收尾时据此丢弃结果) @@ -132,16 +137,9 @@ impl LoginJob { /// 起一次设备码登录(后台线程;`present` 时刻发 Waiting 事件)。 pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) { spawn("msa-login", move || { - let auth = match MicrosoftAuth::from_env() { - Ok(auth) => auth.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir( - env.layout.logs(), - ))), - Err(error) => { - let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed( - error.to_string(), - )))); - return; - } + let auth = match microsoft_auth(&env) { + Ok(auth) => auth, + Err(message) => return fail(&tx, message), }; let tx_prompt = tx.clone(); let result = block_on(auth.device_code_login(|prompt| { @@ -152,45 +150,84 @@ pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) { &format!("{}#{}", prompt.verification_uri, prompt.user_code), )))); })); - if job.is_cancelled() { - let _ = tx.send(HostTask::Login(Box::default())); - return; - } - match result { - Ok(session) => { - let meta = unlucky_core::auth::account::AccountMeta::new( - unlucky_core::auth::account::AccountKind::Microsoft, - session.profile_id.clone(), - session.profile_name.clone(), - now_ms(), - ); - let name = meta.display_name().to_string(); - let mut store = AccountStore::open(env.layout.auth_data()); - let saved = store - .insert(meta, Credentials::Microsoft(session)) - .and_then(|()| store.save()); - match saved { - Ok(()) => { - let _ = - tx.send(HostTask::Login(Box::new(DeviceLoginView::succeeded(&name)))); - let _ = tx.send(HostTask::Accounts(accounts(&env, None))); - } - Err(error) => { - let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed( - format!("账户入库失败:{error}"), - )))); - } - } - } - Err(error) => { - let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed( - error.to_string(), - )))); - } - } + finish(&env, &tx, &job, result); }); } +/// 起一次授权码登录(后台线程:绑本地端口 → 拉起系统默认浏览器 → 等回调 → 换链)。 +/// +/// 与设备码流共用同一个 [`LoginJob`] 取消槽位、同一段账户入库逻辑,所以两条流的 +/// 凭据/刷新语义完全一致(`core` 侧也共用 [`unlucky_core::auth::MicrosoftSession`])。 +pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) { + spawn("msa-authcode", move || { + let auth = match microsoft_auth(&env) { + Ok(auth) => auth, + Err(message) => return fail(&tx, message), + }; + let tx_prompt = tx.clone(); + let result = block_on(auth.authcode_login(|prompt| { + // 浏览器拉不起来不是致命错误:面板里还有可复制的授权链接。 + let opened = opener::open_url(&prompt.authorization_url); + let _ = tx_prompt.send(HostTask::Login(Box::new(DeviceLoginView::browser_waiting( + &prompt.authorization_url, + prompt.timeout.as_secs(), + opened.err().as_deref(), + )))); + })); + finish(&env, &tx, &job, result); + }); +} + +/// 生产入口的 `MicrosoftAuth`:client id 取环境/构建期,失败日志落 `<数据根>/logs/`。 +fn microsoft_auth(env: &HostEnv) -> Result { + MicrosoftAuth::from_env() + .map(|auth| { + auth.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir( + env.layout.logs(), + ))) + }) + .map_err(|error| error.to_string()) +} + +fn fail(tx: &JobSender, message: String) { + let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed(message)))); +} + +/// 收尾:取消就吞掉结果;成功入库;失败带 core 的诊断文案(M38 的 403 也走这条)。 +fn finish(env: &HostEnv, tx: &JobSender, job: &LoginJob, result: AuthResult) { + if job.is_cancelled() { + let _ = tx.send(HostTask::Login(Box::default())); + return; + } + match result { + Ok(session) => match store_session(env, session) { + Ok(name) => { + let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::succeeded(&name)))); + let _ = tx.send(HostTask::Accounts(accounts(env, None))); + } + Err(message) => fail(tx, message), + }, + Err(error) => fail(tx, error.to_string()), + } +} + +/// 微软会话入库(元数据 + 凭据分文件,见 `core::auth::AccountStore`)。 +fn store_session(env: &HostEnv, session: MicrosoftSession) -> Result { + let meta = unlucky_core::auth::account::AccountMeta::new( + unlucky_core::auth::account::AccountKind::Microsoft, + session.profile_id.clone(), + session.profile_name.clone(), + now_ms(), + ); + let name = meta.display_name().to_string(); + let mut store = AccountStore::open(env.layout.auth_data()); + store + .insert(meta, Credentials::Microsoft(session)) + .and_then(|()| store.save()) + .map_err(|error| format!("账户入库失败:{error}"))?; + Ok(name) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/app/src/host/dispatch.rs b/crates/app/src/host/dispatch.rs index ba24226..9da6bf1 100644 --- a/crates/app/src/host/dispatch.rs +++ b/crates/app/src/host/dispatch.rs @@ -109,6 +109,16 @@ impl CoreHost { })); accounts::start_login(self.env.clone(), self.tx.clone(), job); } + AppCommand::StartBrowserLogin => { + self.login = Some(LoginJob::new()); + let job = self.login.clone().unwrap_or_default(); + self.send(CoreEvent::DeviceLoginChanged(crate::dto::DeviceLoginView { + phase: crate::dto::DeviceLoginPhase::Requesting, + message: "正在准备浏览器登录(本地回调端口)…".to_string(), + ..crate::dto::DeviceLoginView::new() + })); + accounts::start_browser_login(self.env.clone(), self.tx.clone(), job); + } AppCommand::CancelDeviceLogin => { if let Some(job) = &self.login { job.cancel(); diff --git a/crates/app/src/host/mod.rs b/crates/app/src/host/mod.rs index 26e89a8..d009cf3 100644 --- a/crates/app/src/host/mod.rs +++ b/crates/app/src/host/mod.rs @@ -25,6 +25,7 @@ mod job; mod launch; mod logs; mod meter; +mod opener; mod settings; mod versions; diff --git a/crates/app/src/host/opener.rs b/crates/app/src/host/opener.rs new file mode 100644 index 0000000..65157e3 --- /dev/null +++ b/crates/app/src/host/opener.rs @@ -0,0 +1,72 @@ +//! 用系统默认浏览器打开授权 URL(授权码流唯一需要宿主做的事)。 +//! +//! core 只负责拼授权 URL 与等本地回调(`core::auth::microsoft::authcode`); +//! 「谁来打开浏览器」是宿主的决定:GUI 走这里,CLI/无浏览器场景仍用设备码流。 +//! +//! 平台差异收在 [`launcher`] 一处,不往业务代码里散写 `cfg` 补丁(`AGENTS.md` §7)。 + +use std::process::Command; + +/// 拉起系统默认浏览器打开 `url`(只接受 http/https)。 +/// +/// 不等待浏览器退出:用户可能在浏览器里停留几分钟,宿主只关心「有没有拉起来」。 +/// +/// # Errors +/// +/// URL 不是 http/https,或进程拉不起来时返回用户可读文案。 +pub fn open_url(url: &str) -> Result<(), String> { + if !url.starts_with("https://") && !url.starts_with("http://") { + return Err(format!("不是可打开的网址:{url}")); + } + let (program, args) = launcher(url); + Command::new(program) + .args(args) + .spawn() + .map(|_child| ()) + .map_err(|error| format!("拉起浏览器失败({program}):{error}")) +} + +#[cfg(target_os = "windows")] +fn launcher(url: &str) -> (&'static str, Vec) { + // `rundll32` 不用 shell,URL 里的 `&` 不会被当成命令分隔符(`cmd /C start` 会)。 + ( + "rundll32", + vec!["url.dll,FileProtocolHandler".to_string(), url.to_string()], + ) +} + +#[cfg(target_os = "macos")] +fn launcher(url: &str) -> (&'static str, Vec) { + ("open", vec![url.to_string()]) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn launcher(url: &str) -> (&'static str, Vec) { + ("xdg-open", vec![url.to_string()]) +} + +#[cfg(test)] +mod tests { + use super::{launcher, open_url}; + + #[test] + fn rejects_non_http_urls() { + assert!(open_url("javascript:alert(1)").is_err()); + assert!(open_url("").is_err()); + // 合法 URL 只验命令形态,不在单测里真去拉浏览器。 + let (program, args) = launcher("http://localhost:1234/auth?code=a&state=b"); + assert!(!program.is_empty()); + assert!(args + .iter() + .any(|arg| arg.contains("http://localhost:1234/auth"))); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_uses_the_file_protocol_handler() { + let (program, args) = launcher("https://example.invalid/"); + assert_eq!(program, "rundll32"); + assert_eq!(args[0], "url.dll,FileProtocolHandler"); + assert_eq!(args[1], "https://example.invalid/"); + } +} diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs index 97760c4..c389eed 100644 --- a/crates/app/src/state.rs +++ b/crates/app/src/state.rs @@ -113,7 +113,7 @@ pub struct AppState { pub download_query: String, /// 账户列表(三轨统一视图;凭据不在其中) pub accounts: Vec, - /// 微软设备码登录状态 + /// 微软登录状态 pub device_login: DeviceLoginView, /// Java 页快照(发行版 / 回退链 / 运行时) pub java: JavaPageView, diff --git a/crates/app/ui/models.slint b/crates/app/ui/models.slint index efcc913..b1e7dc3 100644 --- a/crates/app/ui/models.slint +++ b/crates/app/ui/models.slint @@ -238,7 +238,7 @@ export struct UiAccount { active: bool, } -/// 设备码登录面板(用户码/链接不是凭据,可直接展示)。 +/// 微软登录面板(用户码 / 授权链接都不是凭据,可直接展示)。 export struct UiDeviceCode { visible: bool, busy: bool, diff --git a/crates/app/ui/page_state.slint b/crates/app/ui/page_state.slint index b5811c7..a20cb12 100644 --- a/crates/app/ui/page_state.slint +++ b/crates/app/ui/page_state.slint @@ -45,6 +45,7 @@ export global PageState { callback account-remove(string); callback account-switch(string); callback msa-begin(); + callback msa-browser-begin(); callback msa-cancel(); // ── Java ────────────────────────────────────────────────── diff --git a/crates/app/ui/pages/settings-account.slint b/crates/app/ui/pages/settings-account.slint index e18c918..8e704c7 100644 --- a/crates/app/ui/pages/settings-account.slint +++ b/crates/app/ui/pages/settings-account.slint @@ -1,4 +1,4 @@ -// 设置 → 账户(D9 / G6):三轨账户列表 + 离线添加 + 微软设备码登录 + 删除/切换。 +// 设置 → 账户(D9 / G6):三轨账户列表 + 离线添加 + 微软登录(授权码浏览器 / 设备码)+ 删除/切换。 // // 凭据红线:这里能看到的只有「名字 / 轨道 / 公开 UUID / 登录名 / 创建时间」—— // token 与密码永远不进 UI(Rust 侧的 AccountView 不含这些字段)。 @@ -72,7 +72,7 @@ export component AccountSection inherits Rectangle { background: transparent; VerticalLayout { - // 设备码登录面板(Waiting 时显示用户码 + 链接;失败显示原因) + // 微软登录面板(设备码显示用户码;授权码显示可复制的授权链接;失败显示原因) if PageState.device.visible : Rectangle { height: PageState.device.busy ? 96px : 84px; background: PageState.device.phase_label == "失败" ? Theme.state-danger-soft : Theme.accent-soft; @@ -148,15 +148,22 @@ export component AccountSection inherits Rectangle { placeholder: "玩家名(离线 UUID 自动生成)"; } MetroButton { - kind: ButtonKind.accent; + kind: ButtonKind.standard; text: "添加离线"; button-height: 28px; enabled: PageState.offline-name != ""; clicked => { PageState.account-add-offline(PageState.offline-name); } } MetroButton { - kind: ButtonKind.standard; - text: "微软登录(设备码)"; + kind: ButtonKind.accent; + text: "微软登录"; + button-height: 28px; + enabled: !PageState.device.busy; + clicked => { PageState.msa-browser-begin(); } + } + MetroButton { + kind: ButtonKind.subtle; + text: "设备码登录"; button-height: 28px; enabled: !PageState.device.busy; clicked => { PageState.msa-begin(); } diff --git a/crates/core/src/auth/account.rs b/crates/core/src/auth/account.rs index 3aff5fc..a7f51f1 100644 --- a/crates/core/src/auth/account.rs +++ b/crates/core/src/auth/account.rs @@ -60,7 +60,7 @@ impl std::fmt::Display for AccountId { pub enum AccountKind { /// 离线(本地生成的 UUID,不联网)。 Offline, - /// 微软(device-code)。 + /// 微软(device-code / 授权码浏览器)。 Microsoft, /// authlib-injector 第三方认证服务器(标准 Yggdrasil 协议)。 AuthlibInjector, diff --git a/crates/core/src/auth/microsoft/authcode.rs b/crates/core/src/auth/microsoft/authcode.rs new file mode 100644 index 0000000..6757829 --- /dev/null +++ b/crates/core/src/auth/microsoft/authcode.rs @@ -0,0 +1,284 @@ +//! 授权码流(`research/08` §3 方案 A):授权 URL 构造 + code 换 token + 整链编排。 +//! +//! 与设备码流的分工:**只多一跳**——拿 MS access_token 的方式不同,往后的 +//! XBL → XSTS → login_with_xbox → entitlements → profile 与刷新链完全复用 +//! ([`super::chain::complete_chain`]),所以两条流的凭据存储与刷新语义天然一致。 +//! +//! # 三个硬性细节(都是「抄错就失败」的那一类) +//! +//! 1. **租户 `consumers`**:授权页与 token 端点都走 `login.microsoftonline.com/consumers/…`, +//! 换成 `common`/AAD 租户 id 时 `XboxLive.signin` 直接被拒(`research/08` §2 约束 1)。 +//! 2. **重定向 URI 严格一致**:`http://localhost:{port}/auth` —— 授权 URL 里的、 +//! token 请求体里的、本地服务器实际监听的必须是**同一个字符串**(含端口与路径), +//! 所以本模块只从 [`RedirectUri`] 取这一个来源。 +//! 3. **`prompt=select_account`**:不带这个参数,用户会被静默塞进上一次的账号(坑 6)。 +//! +//! scope 与设备码流共用 [`super::flow::SCOPE`](`XboxLive.signin offline_access`)。 + +use std::fmt; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use reqwest::Client; + +use crate::auth::error::AuthResult; +use crate::auth::http::post_form; +use crate::auth::secret::Secret; + +use super::callback::{CallbackServer, REDIRECT_PATH}; +use super::chain::complete_chain; +use super::endpoints::MicrosoftEndpoints; +use super::flow::{oauth_error, tokens_from, OAuthTokens, SCOPE}; +use super::now_ms; +use super::session::MicrosoftSession; + +/// 授权码 grant 类型。 +pub const AUTHORIZATION_CODE_GRANT: &str = "authorization_code"; +/// 默认等回调的时间预算(用户要开浏览器、登录、点同意)。 +pub const DEFAULT_CALLBACK_TIMEOUT: Duration = Duration::from_secs(300); +/// 默认的端口绑定尝试次数。 +pub const DEFAULT_BIND_ATTEMPTS: u8 = 3; + +/// 回填到授权 URL 与 token 请求里的重定向 URI(**唯一来源**)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RedirectUri { + port: u16, + url: String, +} + +impl RedirectUri { + /// 按实际绑定的端口构造 `http://localhost:{port}/auth`。 + /// + /// 用 `localhost` 而不是 `127.0.0.1`:微软对两者处理不一致(`research/08` 坑 5)。 + #[must_use] + pub fn new(port: u16) -> Self { + Self { + port, + url: format!("http://localhost:{port}{REDIRECT_PATH}"), + } + } + + /// 端口。 + #[must_use] + pub fn port(&self) -> u16 { + self.port + } + + /// URI 字面量(授权 URL 与 token 请求共用,保证逐字节一致)。 + #[must_use] + pub fn as_str(&self) -> &str { + &self.url + } +} + +impl fmt::Display for RedirectUri { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.url) + } +} + +/// 授权码流的运行参数(生产用 [`AuthCodePolicy::default`],测试压短超时/固定端口)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthCodePolicy { + /// 等浏览器回调的上限。 + pub callback_timeout: Duration, + /// 端口绑定尝试次数(`>= 1`)。 + pub bind_attempts: u8, + /// 固定端口(`None` = 由内核分配,生产路径)。 + pub port: Option, + /// CSRF 用的 `state`(`None` = 每次随机生成)。 + pub state: Option, +} + +impl Default for AuthCodePolicy { + fn default() -> Self { + Self { + callback_timeout: DEFAULT_CALLBACK_TIMEOUT, + bind_attempts: DEFAULT_BIND_ATTEMPTS, + port: None, + state: None, + } + } +} + +/// 要宿主做的事:打开浏览器(core 不碰 UI:谁来开由 CLI/GUI 决定)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthCodePrompt { + /// 授权页 URL(宿主交给系统默认浏览器;也可展示给用户手动打开)。 + pub authorization_url: String, + /// 本地回调地址(展示/排障用;与授权 URL 里的 `redirect_uri` 同源)。 + pub redirect_uri: String, + /// 最多等多久。 + pub timeout: Duration, +} + +/// 拼授权 URL(`response_type=code` + `prompt=select_account` + 严格编码的重定向 URI)。 +#[must_use] +pub fn authorization_url( + endpoints: &MicrosoftEndpoints, + client_id: &str, + redirect_uri: &RedirectUri, + state: &str, +) -> String { + format!( + "{}?client_id={}&response_type=code&redirect_uri={}&scope={}&prompt=select_account&state={}", + endpoints.authorize, + encode(client_id), + encode(redirect_uri.as_str()), + encode(SCOPE), + encode(state), + ) +} + +/// 授权码换 token(`grant_type=authorization_code`)。 +/// +/// # Errors +/// +/// OAuth 拒绝([`crate::auth::AuthError::Denied`] / +/// [`crate::auth::AuthError::CredentialExpired`])、网络失败、响应缺 token。 +pub(crate) async fn exchange_code( + client: &Client, + endpoints: &MicrosoftEndpoints, + client_id: &str, + code: &Secret, + redirect_uri: &RedirectUri, +) -> AuthResult { + let reply = post_form( + client, + &endpoints.token, + vec![ + ("client_id", client_id.to_string()), + ("grant_type", AUTHORIZATION_CODE_GRANT.to_string()), + ("code", code.expose().to_string()), + ("redirect_uri", redirect_uri.as_str().to_string()), + ("scope", SCOPE.to_string()), + ], + ) + .await?; + if reply.text("error").is_some() || !reply.is_success() { + return Err(oauth_error(&reply, &endpoints.token)); + } + tokens_from(&reply, &endpoints.token) +} + +/// 授权码登录主体:绑端口 → 出授权 URL(交给 `present`)→ 等回调 → 换 token → 整链。 +/// +/// `present` 在**端口绑定之后、等待之前**被调用:宿主此时才知道端口,也只有这时 +/// 打开浏览器才不会让用户对着还没起来的服务端登录。 +pub(crate) async fn login( + client: &Client, + endpoints: &MicrosoftEndpoints, + client_id: &str, + policy: AuthCodePolicy, + present: F, +) -> AuthResult +where + F: FnOnce(&AuthCodePrompt), +{ + let state = policy.state.clone().unwrap_or_else(nonce); + let server = CallbackServer::bind(policy.port, policy.bind_attempts, state.clone()).await?; + let redirect_uri = RedirectUri::new(server.port()); + present(&AuthCodePrompt { + authorization_url: authorization_url(endpoints, client_id, &redirect_uri, &state), + redirect_uri: redirect_uri.as_str().to_string(), + timeout: policy.callback_timeout, + }); + let code = server.wait_for_code(policy.callback_timeout).await?; + let tokens = exchange_code( + client, + endpoints, + client_id, + &Secret::new(code), + &redirect_uri, + ) + .await?; + complete_chain(client, endpoints, tokens, now_ms()).await +} + +/// 每次登录一个 `state`(不用于加密,只用来把迟到的旧回调挡掉)。 +/// +/// 128 位(两个独立随机种子的 SipHash 输出拼接):RFC 6749 §10.10 建议 `state` 至少 +/// 128 位熵,挡住「猜到 state 就能伪造回调」这类攻击面。 +fn nonce() -> String { + use std::collections::hash_map::RandomState; + use std::hash::{BuildHasher, Hasher}; + + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|since| since.as_nanos()) + .unwrap_or(0); + let mut halves = [String::new(), String::new()]; + for half in &mut halves { + // 每次 `RandomState::new()` 都是独立随机种子 → 两段各 64 位、彼此不可推。 + let mut hasher = RandomState::new().build_hasher(); + hasher.write_u128(nanos); + hasher.write_u32(std::process::id()); + *half = format!("{:016x}", hasher.finish()); + } + format!("{}{}", halves[0], halves[1]) +} + +/// 百分号编码(RFC 3986 unreserved 之外一律转义;空格用 `%20` 而不是 `+`)。 +fn encode(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + for byte in raw.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(char::from(byte)); + } + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::{authorization_url, encode, RedirectUri, AUTHORIZATION_CODE_GRANT}; + use crate::auth::microsoft::MicrosoftEndpoints; + + #[test] + fn redirect_uri_is_localhost_with_exact_port_and_path() { + let uri = RedirectUri::new(51_234); + assert_eq!(uri.as_str(), "http://localhost:51234/auth"); + assert_eq!(uri.port(), 51_234); + assert_eq!(uri.to_string(), uri.as_str()); + } + + #[test] + fn authorization_url_matches_research_08_vector() { + let endpoints = MicrosoftEndpoints::consumers(); + let uri = RedirectUri::new(8_080); + let url = authorization_url(&endpoints, "0000-client", &uri, "state-123"); + assert_eq!( + url, + "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize\ +?client_id=0000-client&response_type=code\ +&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fauth\ +&scope=XboxLive.signin%20offline_access&prompt=select_account&state=state-123" + ); + // 三个硬性细节各断言一遍,改坏了立刻知道是哪一条。 + assert!(url.contains("/consumers/oauth2/v2.0/authorize")); + assert!(url.contains("prompt=select_account")); + assert!(url.contains("scope=XboxLive.signin%20offline_access")); + } + + #[test] + fn encode_escapes_everything_but_unreserved() { + assert_eq!(encode("abc-._~09"), "abc-._~09"); + assert_eq!(encode("a b/c:"), "a%20b%2Fc%3A"); + } + + #[test] + fn grant_type_is_the_standard_one() { + assert_eq!(AUTHORIZATION_CODE_GRANT, "authorization_code"); + } + + #[test] + fn generated_state_has_128_bits_of_entropy() { + let first = super::nonce(); + let second = super::nonce(); + assert_eq!(first.len(), 32, "128 位 = 32 个 hex 字符:{first}"); + assert!(first.chars().all(|c| c.is_ascii_hexdigit()), "{first}"); + assert_ne!(first, second, "每次登录的 state 必须不同"); + } +} diff --git a/crates/core/src/auth/microsoft/callback.rs b/crates/core/src/auth/microsoft/callback.rs new file mode 100644 index 0000000..2e8a27c --- /dev/null +++ b/crates/core/src/auth/microsoft/callback.rs @@ -0,0 +1,346 @@ +//! 授权码流的本地回调服务器(`research/08` §3 方案 A:HMCL 主流做法)。 +//! +//! 只干一件事:等浏览器把 `?code=` 送到 `http://localhost:{port}/auth`,把 code 交出去。 +//! 协议面故意压到最小(只认一个 GET 路径、只回一个静态页),这样它能被单测直接驱动。 +//! +//! # 四条容易踩的细节 +//! +//! 1. **重定向 URI 用 `localhost`,监听绑 `127.0.0.1`**:微软对 `localhost` 与 `127.0.0.1` +//! 处理不一致(坑 5),URI 只能写前者;浏览器把 `localhost` 解析到 `::1` 时会被拒一次 +//! 再回落 IPv4,Chrome/Edge 都走这条回退,所以只绑 v4 就够。 +//! 2. **端口由内核分配**(绑定 `:0`),端口被占时按 `attempts` 重试;授权 URL 里的 +//! `redirect_uri` 必须用**最终绑到的端口**回填,换 token 时再原样带上(URI 严格一致)。 +//! 3. **非 `/auth` 的请求(`/favicon.ico`、浏览器预连)回 404 后继续等**:让一个 favicon +//! 把整次登录判成失败是最容易犯的错。 +//! 4. **`state` 不匹配的请求一律回 400 后继续等**——包括带 `error=` 的那些:旧标签页的 +//! 迟到回调不该打断新的登录,更要紧的是 RFC 6749 §4.1.2.1 / RFC 9207 要求**错误响应同样 +//! 要校验 `state`**;否则任何人 `GET /auth?error=access_denied` 就能静默中止别人的登录。 +//! 真正的超时由调用方的时间预算兜底。 + +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +use crate::auth::error::{AuthError, AuthResult}; + +/// 回调路径(重定向 URI 的路径段)。 +pub const REDIRECT_PATH: &str = "/auth"; +/// 一次回调请求最多读这么多字节(超了就丢弃连接,防被灌内存)。 +pub const MAX_REQUEST_HEAD: usize = 8 * 1024; +/// 端口被占用时的重试间隔。 +const RETRY_DELAY: Duration = Duration::from_millis(20); +/// 单条连接读完请求头的上限(浏览器/预连不把请求头发完时不能把整个登录卡死)。 +const REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(5); + +const SUCCESS_PAGE: &str = "\ +登录完成\ +

登录完成

可以关闭此窗口,回到启动器继续。

"; +const DENIED_PAGE: &str = "\ +登录未完成\ +

登录未完成

授权没有完成,可以回到启动器重试。

"; +const BAD_REQUEST_PAGE: &str = "\ +回调无效\ +

回调无效

这个链接不属于本次登录。

"; +const NOT_FOUND_PAGE: &str = "\ +Not Found

Not Found

"; + +/// 本地回调服务器(一个实例只服务一次登录)。 +#[derive(Debug)] +pub struct CallbackServer { + listener: TcpListener, + port: u16, + state: String, +} + +impl CallbackServer { + /// 绑定回调端口。 + /// + /// `port` 为 `None` 时由内核分配(生产路径);给定端口时按 `attempts` 重试 + /// (CLI/测试里固定端口用,端口被占会退避再试)。 + /// + /// # Errors + /// + /// 全部尝试都绑定失败时返回 [`AuthError::Network`](文案带端口与重试次数)。 + pub async fn bind( + port: Option, + attempts: u8, + state: impl Into, + ) -> AuthResult { + let wanted = port.unwrap_or(0); + let attempts = attempts.max(1); + let mut last = String::new(); + for attempt in 0..attempts { + match TcpListener::bind(("127.0.0.1", wanted)).await { + Ok(listener) => { + let port = listener + .local_addr() + .map_err(|error| AuthError::network("localhost", error.to_string()))? + .port(); + return Ok(Self { + listener, + port, + state: state.into(), + }); + } + Err(error) => { + last = error.to_string(); + if attempt + 1 < attempts { + tokio::time::sleep(RETRY_DELAY).await; + } + } + } + } + let what = if wanted == 0 { + "本地回调端口分配失败".to_string() + } else { + format!("本地回调端口 {wanted} 绑定失败(重试 {attempts} 次)") + }; + Err(AuthError::network("localhost", format!("{what}:{last}"))) + } + + /// 实际端口(回填 `redirect_uri` 用)。 + #[must_use] + pub fn port(&self) -> u16 { + self.port + } + + /// 本次登录的 `state`(授权 URL 与回调校验共用)。 + #[must_use] + pub fn state(&self) -> &str { + &self.state + } + + /// 等浏览器把 code 送到 `/auth`,最多等 `timeout`。 + /// + /// # Errors + /// + /// 超时([`AuthError::Timeout`])、用户在授权页点了取消([`AuthError::Canceled`])、 + /// 授权页回了别的 `error`([`AuthError::Denied`])、socket 出错([`AuthError::Network`])。 + pub async fn wait_for_code(self, timeout: Duration) -> AuthResult { + let deadline = tokio::time::Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err(wait_timeout(timeout)); + } + let accepted = tokio::time::timeout(remaining, self.listener.accept()).await; + let (mut stream, _) = match accepted { + Ok(Ok(pair)) => pair, + Ok(Err(error)) => { + return Err(AuthError::network("localhost", error.to_string())); + } + Err(_) => return Err(wait_timeout(timeout)), + }; + // 一条连接最多读这么久请求头:慢客户端/半开连接不能把整个登录拖过时间预算。 + // 预算按 **accept 之后** 的剩余时间重算(用 accept 前那份会让总时长超发最多 5s)。 + let budget = REQUEST_READ_TIMEOUT + .min(deadline.saturating_duration_since(tokio::time::Instant::now())); + if budget.is_zero() { + return Err(wait_timeout(timeout)); + } + match tokio::time::timeout(budget, self.handle(&mut stream)).await { + Ok(result) => { + if let Some(code) = result? { + return Ok(code); + } + } + Err(_) => continue, + } + } + } + + /// 处理一条连接:`Some(code)` = 登录完成,`None` = 与本次登录无关,继续等。 + async fn handle(&self, stream: &mut TcpStream) -> AuthResult> { + let Some(target) = read_request_target(stream).await else { + return Ok(None); + }; + let (path, query) = match target.split_once('?') { + Some((path, query)) => (path, query), + None => (target.as_str(), ""), + }; + if path != REDIRECT_PATH { + respond(stream, 404, NOT_FOUND_PAGE).await; + return Ok(None); + } + let params = Query::parse(query); + // state 必须先校验,且**先于** error:错误响应同样要认 state(RFC 6749 §4.1.2.1 / + // RFC 9207)。否则任意一次 `GET /auth?error=access_denied`(state 随便给或不给)就能 + // 把这台机器上正在进行的登录静默中止 —— 不需要知道随机 state、不需要 code。 + if params.get("state") != Some(self.state.as_str()) { + respond(stream, 400, BAD_REQUEST_PAGE).await; + return Ok(None); + } + if let Some(error) = params.get("error") { + respond(stream, 200, DENIED_PAGE).await; + return Err(oauth_error(error, params.get("error_description"))); + } + let Some(code) = params.get("code").filter(|code| !code.is_empty()) else { + respond(stream, 400, BAD_REQUEST_PAGE).await; + return Ok(None); + }; + respond(stream, 200, SUCCESS_PAGE).await; + Ok(Some(code.to_string())) + } +} + +fn wait_timeout(timeout: Duration) -> AuthError { + AuthError::Timeout { + what: format!("等待浏览器回调(上限 {} 秒)", timeout.as_secs()), + } +} + +/// 授权页回的 `error` → 错误分流(`access_denied` 归取消,其余归拒绝)。 +fn oauth_error(code: &str, description: Option<&str>) -> AuthError { + if matches!(code, "access_denied" | "authorization_declined") { + return AuthError::Canceled; + } + AuthError::Denied { + code: code.to_string(), + description: description.unwrap_or("授权页没有给出原因").to_string(), + } +} + +/// 读请求行里的 target(`/auth?code=...`)。非 GET/HEAD、超长头、连接中断都给 `None`。 +async fn read_request_target(stream: &mut TcpStream) -> Option { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 1024]; + loop { + if let Some(end) = find_headers_end(&buffer) { + let head = String::from_utf8_lossy(&buffer[..end]).into_owned(); + let mut parts = head.lines().next()?.split(' '); + let method = parts.next()?; + if !method.eq_ignore_ascii_case("GET") && !method.eq_ignore_ascii_case("HEAD") { + return None; + } + return parts.next().map(str::to_string); + } + if buffer.len() > MAX_REQUEST_HEAD { + return None; + } + match stream.read(&mut chunk).await { + Ok(0) | Err(_) => return None, + Ok(read) => buffer.extend_from_slice(&chunk[..read]), + } + } +} + +fn find_headers_end(buffer: &[u8]) -> Option { + buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| position + 4) +} + +async fn respond(stream: &mut TcpStream, status: u16, page: &str) { + let response = format!( + "HTTP/1.1 {status} {}\r\nContent-Type: text/html; charset=utf-8\r\n\ +Content-Length: {}\r\nConnection: close\r\n\r\n{page}", + reason(status), + page.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.flush().await; +} + +fn reason(status: u16) -> &'static str { + match status { + 200 => "OK", + 400 => "Bad Request", + 404 => "Not Found", + _ => "Status", + } +} + +/// 回调 query 的最小解析(`k=v` 对,percent 解码)。 +struct Query(Vec<(String, String)>); + +impl Query { + fn parse(raw: &str) -> Self { + Self( + raw.split('&') + .filter(|pair| !pair.is_empty()) + .filter_map(|pair| { + let (key, value) = pair.split_once('=')?; + Some((decode(key), decode(value))) + }) + .collect(), + ) + } + + fn get(&self, key: &str) -> Option<&str> { + self.0 + .iter() + .find(|(name, _)| name == key) + .map(|(_, value)| value.as_str()) + } +} + +/// `percent` 解码(`+` 视为空格;坏转义原样保留)。 +fn decode(raw: &str) -> String { + let bytes = raw.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' if index + 2 < bytes.len() => { + let parsed = std::str::from_utf8(&bytes[index + 1..index + 3]) + .ok() + .and_then(|hex| u8::from_str_radix(hex, 16).ok()); + match parsed { + Some(byte) => { + out.push(byte); + index += 3; + } + None => { + out.push(b'%'); + index += 1; + } + } + } + b'+' => { + out.push(b' '); + index += 1; + } + byte => { + out.push(byte); + index += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +#[cfg(test)] +mod tests { + use super::{decode, oauth_error, Query}; + use crate::auth::error::AuthError; + + #[test] + fn query_decodes_percent_and_plus() { + let query = Query::parse("code=ab%2Fcd&state=s+1&empty="); + assert_eq!(query.get("code"), Some("ab/cd")); + assert_eq!(query.get("state"), Some("s 1")); + assert_eq!(query.get("empty"), Some("")); + assert_eq!(query.get("missing"), None); + } + + #[test] + fn decode_keeps_broken_escapes() { + assert_eq!(decode("100%"), "100%"); + assert_eq!(decode("%zz"), "%zz"); + assert_eq!(decode("a%20b"), "a b"); + } + + #[test] + fn access_denied_maps_to_canceled() { + assert_eq!(oauth_error("access_denied", None), AuthError::Canceled); + match oauth_error("invalid_request", Some("bad scope")) { + AuthError::Denied { code, description } => { + assert_eq!(code, "invalid_request"); + assert_eq!(description, "bad scope"); + } + other => panic!("应是 Denied,实际 {other:?}"), + } + } +} diff --git a/crates/core/src/auth/microsoft/endpoints.rs b/crates/core/src/auth/microsoft/endpoints.rs index f981a24..bea5f1c 100644 --- a/crates/core/src/auth/microsoft/endpoints.rs +++ b/crates/core/src/auth/microsoft/endpoints.rs @@ -1,8 +1,11 @@ //! 微软端点表:**直连**(不走镜像,承 mission Context 与 `analyse/04` §4 的端点族区分)。 //! -//! 两个端点族不能混用(`analyse/04` §4 的「第一个坑」): -//! - 设备码流:`login.microsoftonline.com/consumers/oauth2/v2.0/*`; -//! - HMCL 的浏览器授权码流走 `login.live.com`(本期不实现,故不在此表)。 +//! 两条流**共用同一个端点族**(`research/08` §2 硬性约束 1:租户只能用 `consumers`): +//! - 设备码流:`devicecode` + `token`; +//! - 授权码流:`authorize`(浏览器打开)+ 同一个 `token`(`grant_type=authorization_code`)。 +//! +//! 都走 `login.microsoftonline.com/consumers/oauth2/v2.0/*`:用 `common` 或 AAD 租户 id +//! 拿 `XboxLive.signin` 会直接被拒(`analyse/04` §4 的「第一个坑」)。 //! //! 端点集中成一张表是为了**测试可替换**:整表指向一个进程内 mock 服务器即可覆盖全链, //! 生产默认走 [`MicrosoftEndpoints::consumers`]。 @@ -12,7 +15,9 @@ pub struct MicrosoftEndpoints { /// 设备码申请(`POST`,表单 `client_id` + `scope`)。 pub device_code: String, - /// 换 token / 刷新 token(`POST`,表单)。 + /// 授权码流的授权页(`GET`,浏览器打开;`research/08` §3 方案 A)。 + pub authorize: String, + /// 换 token / 刷新 token / 授权码换 token(`POST`,表单)。 pub token: String, /// Xbox Live 用户认证(`POST` JSON)。 pub xbl_authenticate: String, @@ -35,6 +40,8 @@ impl MicrosoftEndpoints { Self { device_code: "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode" .to_string(), + authorize: "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize" + .to_string(), token: "https://login.microsoftonline.com/consumers/oauth2/v2.0/token".to_string(), xbl_authenticate: "https://user.auth.xboxlive.com/user/authenticate".to_string(), xsts_authorize: "https://xsts.auth.xboxlive.com/xsts/authorize".to_string(), @@ -54,6 +61,7 @@ impl MicrosoftEndpoints { let join = |path: &str| format!("{}/{path}", base.trim_end_matches('/')); Self { device_code: join("devicecode"), + authorize: join("authorize"), token: join("token"), xbl_authenticate: join("xbl"), xsts_authorize: join("xsts"), @@ -66,9 +74,10 @@ impl MicrosoftEndpoints { /// 所有端点(调试/诊断用)。 #[must_use] - pub fn all(&self) -> [&str; 8] { + pub fn all(&self) -> [&str; 9] { [ &self.device_code, + &self.authorize, &self.token, &self.xbl_authenticate, &self.xsts_authorize, @@ -95,11 +104,15 @@ mod tests { let endpoints = MicrosoftEndpoints::consumers(); assert!(endpoints.device_code.contains("login.microsoftonline.com")); assert!(endpoints.token.contains("/consumers/oauth2/v2.0/token")); + // 授权码流的授权页与设备码流同族同租户(`research/08` §2:只能用 consumers)。 + assert!(endpoints + .authorize + .contains("/consumers/oauth2/v2.0/authorize")); assert!(endpoints .xbl_authenticate .contains("user.auth.xboxlive.com")); assert!(endpoints.xsts_authorize.contains("xsts.auth.xboxlive.com")); - // 授权码流的 login.live.com 不在这张表里(本期不实现)。 + // 微软 OAuth 两流都不走 HMCL 的 login.live.com 端点族。 assert!(endpoints .all() .iter() @@ -110,6 +123,7 @@ mod tests { fn all_on_keeps_paths_distinct() { let endpoints = MicrosoftEndpoints::all_on("http://127.0.0.1:1234/"); assert_eq!(endpoints.device_code, "http://127.0.0.1:1234/devicecode"); + assert_eq!(endpoints.authorize, "http://127.0.0.1:1234/authorize"); assert_eq!( endpoints.entitlements_license, "http://127.0.0.1:1234/entitlements/license" diff --git a/crates/core/src/auth/microsoft/flow.rs b/crates/core/src/auth/microsoft/flow.rs index 4c1d21d..9cc8e0f 100644 --- a/crates/core/src/auth/microsoft/flow.rs +++ b/crates/core/src/auth/microsoft/flow.rs @@ -223,7 +223,7 @@ pub(crate) async fn refresh_tokens( tokens_from(&reply, &endpoints.token) } -fn tokens_from(reply: &Reply, url: &str) -> AuthResult { +pub(crate) fn tokens_from(reply: &Reply, url: &str) -> AuthResult { let access_token = reply .text("access_token") .ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 access_token")))?; @@ -237,7 +237,7 @@ fn tokens_from(reply: &Reply, url: &str) -> AuthResult { } /// OAuth 错误归一:**`error` 非空即失败**(HMCL 只在两个字段都非空时处理,我们收紧)。 -fn oauth_error(reply: &Reply, url: &str) -> AuthError { +pub(crate) fn oauth_error(reply: &Reply, url: &str) -> AuthError { let Some(code) = reply.text("error") else { return AuthError::network( url.split('?').next().unwrap_or(url), diff --git a/crates/core/src/auth/microsoft/mod.rs b/crates/core/src/auth/microsoft/mod.rs index ace4aee..78f0317 100644 --- a/crates/core/src/auth/microsoft/mod.rs +++ b/crates/core/src/auth/microsoft/mod.rs @@ -1,25 +1,35 @@ -//! 微软轨:device-code 全流程 + 会话/刷新语义(`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5)。 +//! 微软轨:device-code 与授权码(浏览器)两条登录流 + 会话/刷新语义 +//! (`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5、`research/08` §3)。 //! -//! 分四块,各管一件事: +//! 分五块,各管一件事: //! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测); //! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock); //! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层); +//! - [`authcode`] + [`callback`]:授权码流(授权 URL + 本地 HTTP 回调服务器 + code 换 token); //! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。 //! +//! 两条流只差「怎么拿到 MS access_token」:拿到之后都进同一个 [`chain::complete_chain`], +//! 落盘/刷新也共用 [`MicrosoftSession`],所以 GUI 账户页的两颗按钮不会长出两套凭据语义。 +//! //! # client id 从哪来 //! -//! device-code 流必须带一个**已在 Azure 注册的公共客户端 id**。HMCL 把它烘进 jar 的 +//! 两条流都必须带一个**已在 Azure 注册的公共客户端 id**(且租户是 `consumers`、 +//! 重定向 URI 登记为 `http://localhost`)。HMCL 把它烘进 jar 的 //! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由 //! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时 //! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`] 并说清要配什么—— //! 这比编一个假 id 让用户看到 `invalid_client` 诚实。 +pub mod authcode; +pub mod callback; pub mod chain; pub mod endpoints; pub mod flow; pub mod session; +pub use authcode::{AuthCodePolicy, AuthCodePrompt, RedirectUri}; +pub use callback::CallbackServer; pub use endpoints::MicrosoftEndpoints; pub use flow::{DeviceCodePrompt, PollPolicy}; pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA}; @@ -45,6 +55,7 @@ pub struct MicrosoftAuth { client_id: String, endpoints: MicrosoftEndpoints, poll: PollPolicy, + authcode: AuthCodePolicy, journal: Option, } @@ -62,6 +73,7 @@ impl MicrosoftAuth { client_id: client_id.into(), endpoints: MicrosoftEndpoints::consumers(), poll: PollPolicy::default(), + authcode: AuthCodePolicy::default(), journal: None, }) } @@ -131,6 +143,13 @@ impl MicrosoftAuth { self } + /// 换授权码流参数(回调超时 / 固定端口 / 绑定重试 / 固定 state;测试与 CLI 用)。 + #[must_use] + pub fn with_authcode_policy(mut self, policy: AuthCodePolicy) -> Self { + self.authcode = policy; + self + } + /// 当前端点表。 #[must_use] pub fn endpoints(&self) -> &MicrosoftEndpoints { @@ -177,6 +196,34 @@ impl MicrosoftAuth { chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await } + /// 授权码流登录(浏览器 + 本地回调,`research/08` §3 方案 A,GUI 的默认入口)。 + /// + /// `present` 在端口绑定之后、等待回调之前被调用:宿主在这一刻拉起系统默认浏览器 + /// (core 不做 UI,也不假设有 GUI——CLI 可以只把 URL 打到 stderr)。 + /// 失败时与 [`Self::device_code_login`] 一样往失败日志追加一条结构化记录(M38)。 + /// + /// # Errors + /// + /// 端口绑不上/网络失败、等回调超时、用户在授权页取消、OAuth 拒绝,以及整链的 + /// [`AuthError`](含白名单未批时 `login_with_xbox` 的 403 诊断)。 + pub async fn authcode_login(&self, present: F) -> AuthResult + where + F: FnOnce(&AuthCodePrompt), + { + let result = authcode::login( + &self.client, + &self.endpoints, + &self.client_id, + self.authcode.clone(), + present, + ) + .await; + if let Err(error) = &result { + self.record_failure("authcode_login", error); + } + result + } + /// 刷新:Live refresh → 整链重跑(**原样覆盖 refresh_token**)。 /// /// # Errors diff --git a/crates/core/src/auth/mod.rs b/crates/core/src/auth/mod.rs index 329030c..4ba53c1 100644 --- a/crates/core/src/auth/mod.rs +++ b/crates/core/src/auth/mod.rs @@ -1,4 +1,4 @@ -//! 账户体系:三轨(离线 UUID / 微软 device-code / authlib-injector)、凭据存储边界。 +//! 账户体系:三轨(离线 UUID / 微软 device-code 与授权码 / authlib-injector)、凭据存储边界。 //! //! 设计定稿:`DevDocs/PlanDoc/07-accounts.md`(决策 D9/Q21),协议细节来自 //! `DevDocs/analyse/04-authentication.md`(HMCL v3.17 拆解,逐报文对照)。 @@ -19,7 +19,8 @@ //! - [`account`]:`AccountId` / `AccountKind` / `AccountMeta` / `Credentials`(三轨统一抽象); //! - [`store`]:`AccountStore`(加载/增删/落盘,`data/auth/`); //! - [`offline`]:HMCL 兼容离线 UUID + 启动占位; -//! - [`microsoft`]:device-code 全链(XBL→XSTS→login_with_xbox→entitlements→profile)+ 刷新判定; +//! - [`microsoft`]:微软两条登录流(device-code / 授权码+本地回调)+ 共用后链 +//! (XBL→XSTS→login_with_xbox→entitlements→profile)+ 刷新判定; //! - [`authlib`]:标准 Yggdrasil 协议(authenticate/refresh/validate/invalidate,账户侧); //! - [`error`]:错误分流(取消 / 超时 / 被拒 / 凭据过期); //! - [`body`]:非 2xx 应答的正文诊断(分类 JSON/HTML/空体 + 脱敏摘要,M38); @@ -47,6 +48,8 @@ pub use account::{AccountId, AccountKind, AccountMeta, Credentials}; pub use body::{BodyKind, ResponseDetail}; pub use error::{AuthError, AuthResult}; pub use journal::{AuthFailureEvent, AuthJournal}; -pub use microsoft::{MicrosoftAuth, MicrosoftSession, RefreshDecision, RefreshReason}; +pub use microsoft::{ + AuthCodePolicy, AuthCodePrompt, MicrosoftAuth, MicrosoftSession, RefreshDecision, RefreshReason, +}; pub use secret::Secret; pub use store::{AccountStore, AuthPaths}; diff --git a/crates/core/tests/auth/main.rs b/crates/core/tests/auth/main.rs index 8f79b50..6fba1e5 100644 --- a/crates/core/tests/auth/main.rs +++ b/crates/core/tests/auth/main.rs @@ -4,6 +4,7 @@ //! - `offline_uuid`:离线 UUID 与 JDK(= HMCL 算法)**逐字节对拍**的已知向量; //! - `store_separation`:元数据/凭据分文件、原子写、写序、容错与孤儿清理; //! - `microsoft_flow`:device-code 全链(含 pending / slow_down / 取消 / 超时 / XErr); +//! - `microsoft_authcode`:授权码流(本地回调服务器 / 授权 URL 向量 / code 换链 / 403 诊断); //! - `microsoft_diagnostics`:非 2xx 正文诊断(JSON/HTML/空体分流)+ 失败日志落盘(M38); //! - `microsoft_refresh`:refresh 轮换、profile 变脸防线、validate 语义、404 二义; //! - `authlib_api`:Yggdrasil 四动作的报文级对接。 @@ -12,6 +13,7 @@ mod support; mod auth_failure_journal; mod authlib_api; +mod microsoft_authcode; mod microsoft_diagnostics; mod microsoft_flow; mod microsoft_refresh; diff --git a/crates/core/tests/auth/microsoft_authcode.rs b/crates/core/tests/auth/microsoft_authcode.rs new file mode 100644 index 0000000..303101c --- /dev/null +++ b/crates/core/tests/auth/microsoft_authcode.rs @@ -0,0 +1,295 @@ +//! 授权码流(`research/08` §3 方案 A):本地回调服务器 + 授权 URL + code 换链。 +//! +//! 覆盖验收口径:授权码换到 MS token → XBL → XSTS 全通;白名单未批前 +//! `login_with_xbox` 的 403 只要求**诊断文案正确**(M38 版),不绕过。 + +use std::time::Duration; + +use serde_json::json; +use unlucky_core::auth::microsoft::{ + AuthCodePolicy, AuthCodePrompt, CallbackServer, MicrosoftAuth, MicrosoftEndpoints, +}; +use unlucky_core::auth::AuthError; + +use crate::support::{full_routes, response_of, AuthMock, MockReply}; + +const CLIENT_ID: &str = "test-client-id"; + +fn policy(state: &str, timeout_ms: u64) -> AuthCodePolicy { + AuthCodePolicy { + callback_timeout: Duration::from_millis(timeout_ms), + bind_attempts: 3, + port: None, + state: Some(state.to_string()), + } +} + +/// 模拟浏览器:把 `code` 送到本地回调地址(真发一次 HTTP,走 localhost 解析)。 +async fn deliver(redirect_uri: &str, query: &str) { + let url = format!("{redirect_uri}?{query}"); + // 回调服务器可能在 accept 中,给一拍让绑定先落地。 + tokio::time::sleep(Duration::from_millis(10)).await; + let response = reqwest::get(&url).await.expect("浏览器回调"); + assert_eq!(response.status(), 200, "回调页应回 200"); + let page = response.text().await.expect("回调页正文"); + assert!(page.contains("登录完成"), "回调页应是给用户看的完成页"); +} + +#[tokio::test] +async fn authcode_login_completes_the_chain() { + let mock = AuthMock::start(full_routes()).await; + let auth = MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(&mock.base())) + .with_authcode_policy(policy("state-abc", 5_000)); + + let mut prompted: Option = None; + let session = auth + .authcode_login(|prompt| { + prompted = Some(prompt.clone()); + let redirect = prompt.redirect_uri.clone(); + tokio::spawn( + async move { deliver(&redirect, "code=the-auth-code&state=state-abc").await }, + ); + }) + .await + .expect("授权码登录成功"); + + // 1. 宿主拿到的是「授权 URL + 本地回调地址」,两者端口必须一致。 + let prompt = prompted.expect("必须回调展示授权 URL"); + let redirect = prompt.redirect_uri.clone(); + assert!(redirect.starts_with("http://localhost:"), "{redirect}"); + assert!(redirect.ends_with("/auth"), "{redirect}"); + assert!(prompt.authorization_url.contains("prompt=select_account")); + // 端点表在测试里整体指向 mock,所以授权页的路径是 `/authorize`(租户口径见向量单测)。 + assert!( + prompt.authorization_url.starts_with(&mock.base()) + && prompt + .authorization_url + .contains("/authorize?client_id=test-client-id"), + "{}", + prompt.authorization_url + ); + assert!(prompt.authorization_url.contains("state=state-abc")); + assert_eq!(prompt.timeout, Duration::from_millis(5_000)); + + // 2. code 换 token 的表单:grant_type + 原样重定向 URI(URI 严格一致那条坑)。 + let token_bodies = mock.bodies_on("/token"); + assert_eq!(token_bodies.len(), 1, "只该换一次 token"); + let body = &token_bodies[0]; + assert!(body.contains("grant_type=authorization_code"), "{body}"); + assert!(body.contains("code=the-auth-code"), "{body}"); + assert!( + body.contains("redirect_uri=http%3A%2F%2Flocalhost%3A") && !body.contains(&redirect), + "redirect_uri 必须是编码后的同一字面量:{body}" + ); + assert!( + body.contains("scope=XboxLive.signin+offline_access") + || body.contains("scope=XboxLive.signin%20offline_access"), + "{body}" + ); + // 授权 URL 里的 redirect_uri 与 token 请求里的必须是同一个端口。 + let encoded = redirect.replace(':', "%3A").replace('/', "%2F"); + assert!( + prompt.authorization_url.contains(&encoded), + "{}", + prompt.authorization_url + ); + assert!(body.contains(&encoded), "{body}"); + + // 3. 后链与会话:与设备码流同一套字段语义。 + assert_eq!(session.profile_name, "Steve"); + assert_eq!(session.access_token.expose(), "mc-access-token-value"); + assert_eq!(session.refresh_token.expose(), "live-refresh-token-value"); + assert!(!format!("{session:?}").contains("mc-access-token-value")); + assert_eq!(mock.bodies_on("/xbl").len(), 1); + assert_eq!(mock.bodies_on("/xsts").len(), 1); + mock.stop(); +} + +#[tokio::test] +async fn callback_server_ignores_unrelated_requests_and_bad_state() { + let server = CallbackServer::bind(None, 1, "state-ok") + .await + .expect("绑定"); + let port = server.port(); + let browser = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + let base = format!("http://localhost:{port}"); + // favicon / 浏览器预热:404 但绝不能结束等待。 + let favicon = reqwest::get(format!("{base}/favicon.ico")) + .await + .expect("favicon"); + assert_eq!(favicon.status(), 404); + // state 不匹配(旧标签页的迟到回调):400,继续等。 + let stale = reqwest::get(format!("{base}/auth?code=stale-code&state=state-old")) + .await + .expect("迟到的回调"); + assert_eq!(stale.status(), 400); + let good = reqwest::get(format!("{base}/auth?code=good-code&state=state-ok")) + .await + .expect("真回调"); + assert_eq!(good.status(), 200); + }); + + let code = server + .wait_for_code(Duration::from_secs(5)) + .await + .expect("应拿到 code"); + assert_eq!(code, "good-code"); + browser.await.expect("模拟浏览器任务"); +} + +#[tokio::test] +async fn foreign_state_cannot_cancel_the_login() { + // P2 回归:回调先校验 state,再处理 error(RFC 6749 §4.1.2.1 / RFC 9207)。 + // 修之前 `GET /auth?error=access_denied`(state 随便给或不给)就能把登录判成 Canceled。 + let server = CallbackServer::bind(None, 1, "state-ok") + .await + .expect("绑定"); + let port = server.port(); + let browser = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + let base = format!("http://localhost:{port}/auth"); + // 1. 连 state 都不给:与本次登录无关 → 400,继续等。 + let bare = reqwest::get(format!("{base}?error=access_denied")) + .await + .expect("伪造取消"); + assert_eq!(bare.status(), 400); + // 2. state 明显不匹配:同样忽略,绝不当成用户取消。 + let foreign = reqwest::get(format!("{base}?error=access_denied&state=not-our-state")) + .await + .expect("伪造取消带错 state"); + assert_eq!(foreign.status(), 400); + // 3. 真正带对 state 的回调仍然能完成登录。 + let good = reqwest::get(format!("{base}?code=good-code&state=state-ok")) + .await + .expect("真回调"); + assert_eq!(good.status(), 200); + }); + + let code = server + .wait_for_code(Duration::from_secs(5)) + .await + .expect("伪造的 access_denied 不能中止登录"); + assert_eq!(code, "good-code"); + browser.await.expect("模拟浏览器任务"); +} + +#[tokio::test] +async fn access_denied_with_our_state_is_still_canceled() { + // 另一半语义:用户真的在授权页点了取消(带本机 state)→ 仍然是 Canceled。 + let server = CallbackServer::bind(None, 1, "state-ok") + .await + .expect("绑定"); + let port = server.port(); + let browser = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + let url = format!("http://localhost:{port}/auth?error=access_denied&state=state-ok"); + let _ = reqwest::get(url).await; + }); + let error = server + .wait_for_code(Duration::from_secs(5)) + .await + .expect_err("本机 state 的取消要生效"); + assert_eq!(error, AuthError::Canceled); + browser.await.expect("模拟浏览器任务"); +} + +#[tokio::test] +async fn callback_timeout_is_reported() { + let auth = MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_authcode_policy(policy("state-abc", 30)); + let error = auth + .authcode_login(|_prompt| {}) + .await + .expect_err("没人回调就该超时"); + assert_eq!(error.code(), "timeout"); + assert!( + error.message().contains("浏览器回调"), + "{}", + error.message() + ); +} + +#[tokio::test] +async fn access_denied_on_the_callback_page_is_canceled() { + let auth = MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_authcode_policy(policy("state-abc", 5_000)); + let error = auth + .authcode_login(|prompt| { + let redirect = prompt.redirect_uri.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(10)).await; + let url = format!("{redirect}?error=access_denied&state=state-abc"); + let _ = reqwest::get(&url).await; + }); + }) + .await + .expect_err("用户取消"); + assert_eq!(error, AuthError::Canceled); +} + +#[tokio::test] +async fn busy_port_retries_then_reports_which_port() { + // 先占住一个端口,再让回调服务器去绑同一个:必须失败并说清是哪个端口。 + let occupied = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("占端口"); + let port = occupied.local_addr().expect("地址").port(); + let error = CallbackServer::bind(Some(port), 2, "s") + .await + .expect_err("端口已被占"); + assert_eq!(error.code(), "network"); + assert!( + error.message().contains(&port.to_string()), + "文案要指出端口:{}", + error.message() + ); + assert!(error.message().contains("重试 2 次"), "{}", error.message()); + // 同一台机器上换个端口就能绑上(重试机制不是把错误吞掉)。 + let ok = CallbackServer::bind(None, 1, "s").await.expect("内核分配"); + assert_ne!(ok.port(), port); +} + +#[tokio::test] +async fn login_with_xbox_403_keeps_the_m38_diagnostic() { + // 白名单未批的真机状态:授权码换到 token、XBL/XSTS 全通,最后一步 403。 + let mut routes = full_routes(); + let (_, login) = routes + .iter_mut() + .find(|(path, _)| *path == "/login_with_xbox") + .expect("路由表里有 login_with_xbox"); + *login = vec![MockReply::json_status( + 403, + json!({ "path": "/authentication/login_with_xbox", "errorType": "FORBIDDEN", + "error": "Invalid app registration" }), + )]; + let mock = AuthMock::start(routes).await; + let auth = MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(&mock.base())) + .with_authcode_policy(policy("state-abc", 5_000)); + + let error = auth + .authcode_login(|prompt| { + let redirect = prompt.redirect_uri.clone(); + tokio::spawn(async move { deliver(&redirect, "code=c&state=state-abc").await }); + }) + .await + .expect_err("403 必须冒出来"); + + let detail = response_of(&error); + assert_eq!(detail.status, 403); + assert_eq!(detail.detail_code(), "login_with_xbox_403"); + // 「授权码这条流本身是通的」——这正是验收止步的位置。 + assert_eq!(mock.bodies_on("/token").len(), 1); + assert_eq!(mock.bodies_on("/xbl").len(), 1); + assert_eq!(mock.bodies_on("/xsts").len(), 1); + let message = error.message(); + assert!(message.contains("Minecraft 登录被拒"), "{message}"); + assert!(message.contains("Invalid app registration"), "{message}"); + mock.stop(); +}