Files
Unlucky-Launcher/crates/core/src/auth/microsoft/callback.rs
T
AskaEth 8194e38bcf feat(core): 加 MSA 授权码流(本地回调服务器)并接到账户页
research/08 方案 A:GUI 的微软登录改走授权码流(HMCL 主流做法),设备码流保留给
CLI/无浏览器场景。两条流只差「怎么拿到 MS access_token」,拿到之后共用
chain::complete_chain 与 MicrosoftSession,落盘/刷新语义没有第二套。

core(crates/core/src/auth/microsoft/)
- endpoints:新增 authorize(…/consumers/oauth2/v2.0/authorize),与 token 同租户。
- authcode:RedirectUri 是 http://localhost:{port}/auth 的唯一来源(授权 URL、
  token 请求体、实际监听三处共用,坑 5);AuthCodePolicy(回调超时 300s / 绑定重试 3 次 /
  固定端口 / 固定 state);authorization_url(response_type=code + prompt=select_account +
  scope=XboxLive.signin%20offline_access);exchange_code 复用 flow 的 oauth_error/tokens_from。
- callback:本地 HTTP/1.1 回调服务器。内核分配端口(占用按次数退避重试);非 /auth 路径
  (favicon、预连)404 后继续等;state 不匹配 400 后继续等;error=access_denied 归 Canceled;
  单条连接读头超时 5s,半开连接拖不垮时间预算。
- MicrosoftAuth::authcode_login(present):绑端口 → present(宿主开浏览器)→ 等回调 →
  换 token → 整链;失败与设备码流一样落 M38 失败日志。

app(crates/app)
- AppCommand::StartBrowserLogin + host::accounts::start_browser_login;设备码/授权码共用
  LoginJob 取消槽位、收尾入库(store_session)与面板 DTO。
- host::opener 用系统默认浏览器打开授权 URL(Windows rundll32 url.dll,FileProtocolHandler)。
- 账户页:主按钮「微软登录」走授权码流,「设备码登录」作备用;DeviceLoginView::browser_waiting
  在面板里给出可复制的授权链接(user_code 留空)。

测试
- crates/core/tests/auth/microsoft_authcode.rs:授权 URL 向量(租户/编码/prompt)、
  URI 一致性(授权 URL 与 token 表单同字面量)、mock 全链、favicon+陈旧 state 后仍拿到 code、
  端口占用重试文案、超时、access_denied→Canceled、login_with_xbox 403 的 M38 诊断
  (detail_code=login_with_xbox_403,真机白名单未批时验收止步于此)。
- 单测:authcode 编码/向量、callback query 解码与错误分流、DTO browser_waiting、conv 面板。

未做/未验证:真机完整登录需 Azure 公共客户端 id(ULL_MSA_CLIENT_ID)与 Minecraft API 许可,
本机只能到「授权页 302 到 login.live.com 且 redirect_uri/scope/prompt 原样保留」这一步。

AskaEth <i@yeij.top>
2026-11-01 08:33:11 +08:00

337 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 授权码流的本地回调服务器(`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 后继续等**:旧标签页的回调不该打断新的登录;
//! 真正的超时由调用方的时间预算兜底。
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)),
};
// 一条连接最多读这么久请求头:慢客户端/半开连接不能把整个登录拖过时间预算。
let budget = REQUEST_READ_TIMEOUT.min(remaining);
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);
if let Some(error) = params.get("error") {
respond(stream, 200, DENIED_PAGE).await;
return Err(oauth_error(error, params.get("error_description")));
}
if params.get("state") != Some(self.state.as_str()) {
respond(stream, 400, BAD_REQUEST_PAGE).await;
return Ok(None);
}
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:?}"),
}
}
}