Merge branch 'feat/p3-msa-authorization-code-flow-browser-l'
This commit is contained in:
commit
f9079f8ab0
22 files changed
+1271
-80
No files matched your search
@@ -56,6 +56,14 @@ pub(crate) fn wire_pages(weak: Weak<Inner>) {
|
||||
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 || {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String>) -> 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");
|
||||
|
||||
@@ -65,7 +65,7 @@ pub enum CoreEvent {
|
||||
DiagnosisReady(DiagnosisReport),
|
||||
/// 账户列表刷新
|
||||
AccountsChanged(Vec<AccountView>),
|
||||
/// 设备码登录状态刷新
|
||||
/// 微软登录状态刷新
|
||||
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),
|
||||
|
||||
@@ -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<AccountView> {
|
||||
@@ -103,7 +108,7 @@ pub fn remove(env: &HostEnv, key: &str) -> Result<String, String> {
|
||||
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, String> {
|
||||
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<MicrosoftSession>) {
|
||||
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<String, String> {
|
||||
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::*;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -25,6 +25,7 @@ mod job;
|
||||
mod launch;
|
||||
mod logs;
|
||||
mod meter;
|
||||
mod opener;
|
||||
mod settings;
|
||||
mod versions;
|
||||
|
||||
|
||||
@@ -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<String>) {
|
||||
// `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<String>) {
|
||||
("open", vec![url.to_string()])
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
fn launcher(url: &str) -> (&'static str, Vec<String>) {
|
||||
("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/");
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,7 @@ pub struct AppState {
|
||||
pub download_query: String,
|
||||
/// 账户列表(三轨统一视图;凭据不在其中)
|
||||
pub accounts: Vec<AccountView>,
|
||||
/// 微软设备码登录状态
|
||||
/// 微软登录状态
|
||||
pub device_login: DeviceLoginView,
|
||||
/// Java 页快照(发行版 / 回退链 / 运行时)
|
||||
pub java: JavaPageView,
|
||||
|
||||
@@ -238,7 +238,7 @@ export struct UiAccount {
|
||||
active: bool,
|
||||
}
|
||||
|
||||
/// 设备码登录面板(用户码/链接不是凭据,可直接展示)。
|
||||
/// 微软登录面板(用户码 / 授权链接都不是凭据,可直接展示)。
|
||||
export struct UiDeviceCode {
|
||||
visible: bool,
|
||||
busy: bool,
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -60,7 +60,7 @@ impl std::fmt::Display for AccountId {
|
||||
pub enum AccountKind {
|
||||
/// 离线(本地生成的 UUID,不联网)。
|
||||
Offline,
|
||||
/// 微软(device-code)。
|
||||
/// 微软(device-code / 授权码浏览器)。
|
||||
Microsoft,
|
||||
/// authlib-injector 第三方认证服务器(标准 Yggdrasil 协议)。
|
||||
AuthlibInjector,
|
||||
|
||||
@@ -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<u16>,
|
||||
/// CSRF 用的 `state`(`None` = 每次随机生成)。
|
||||
pub state: Option<String>,
|
||||
}
|
||||
|
||||
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<OAuthTokens> {
|
||||
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<F>(
|
||||
client: &Client,
|
||||
endpoints: &MicrosoftEndpoints,
|
||||
client_id: &str,
|
||||
policy: AuthCodePolicy,
|
||||
present: F,
|
||||
) -> AuthResult<MicrosoftSession>
|
||||
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 必须不同");
|
||||
}
|
||||
}
|
||||
@@ -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 = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\
|
||||
<title>登录完成</title></head><body style=\"font-family:system-ui;padding:40px\">\
|
||||
<h1>登录完成</h1><p>可以关闭此窗口,回到启动器继续。</p></body></html>";
|
||||
const DENIED_PAGE: &str = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\
|
||||
<title>登录未完成</title></head><body style=\"font-family:system-ui;padding:40px\">\
|
||||
<h1>登录未完成</h1><p>授权没有完成,可以回到启动器重试。</p></body></html>";
|
||||
const BAD_REQUEST_PAGE: &str = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">\
|
||||
<title>回调无效</title></head><body style=\"font-family:system-ui;padding:40px\">\
|
||||
<h1>回调无效</h1><p>这个链接不属于本次登录。</p></body></html>";
|
||||
const NOT_FOUND_PAGE: &str = "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">\
|
||||
<title>Not Found</title></head><body><h1>Not Found</h1></body></html>";
|
||||
|
||||
/// 本地回调服务器(一个实例只服务一次登录)。
|
||||
#[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<u16>,
|
||||
attempts: u8,
|
||||
state: impl Into<String>,
|
||||
) -> AuthResult<Self> {
|
||||
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<String> {
|
||||
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<Option<String>> {
|
||||
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<String> {
|
||||
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<usize> {
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -223,7 +223,7 @@ pub(crate) async fn refresh_tokens(
|
||||
tokens_from(&reply, &endpoints.token)
|
||||
}
|
||||
|
||||
fn tokens_from(reply: &Reply, url: &str) -> AuthResult<OAuthTokens> {
|
||||
pub(crate) fn tokens_from(reply: &Reply, url: &str) -> AuthResult<OAuthTokens> {
|
||||
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<OAuthTokens> {
|
||||
}
|
||||
|
||||
/// 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),
|
||||
|
||||
@@ -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<AuthJournal>,
|
||||
}
|
||||
|
||||
@@ -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<F>(&self, present: F) -> AuthResult<MicrosoftSession>
|
||||
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
|
||||
|
||||
@@ -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};
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AuthCodePrompt> = 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();
|
||||
}
|
||||
Reference in new issue
Block a user