From 41801010a4db91a46b1e8f866392e89039d0cc6e Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Mon, 6 Jul 2026 02:40:33 -0400 Subject: [PATCH] use authorize oauth2 instead of devicecode oauth2 --- .../oneclient_app/src/view/app/accounts.rs | 190 +++++++----- .../src/view/app/settings/launcher.rs | 35 ++- .../src/view/onboarding/account.rs | 183 +++++++----- packages/oneclient_core/examples/auth_msa.rs | 19 +- packages/oneclient_core/src/auth/data.rs | 31 +- packages/oneclient_core/src/auth/error.rs | 9 + packages/oneclient_core/src/auth/mod.rs | 57 +++- packages/oneclient_core/src/auth/msa.rs | 276 +++++++++++++++++- packages/oneclient_core/src/auth/store.rs | 26 +- packages/oneclient_core/src/dev.rs | 1 + .../oneclient_core/src/settings/launcher.rs | 2 + packages/oneclient_core/src/state.rs | 5 +- 12 files changed, 652 insertions(+), 182 deletions(-) diff --git a/packages/oneclient_app/src/view/app/accounts.rs b/packages/oneclient_app/src/view/app/accounts.rs index da8e86fe..ec4a886c 100644 --- a/packages/oneclient_app/src/view/app/accounts.rs +++ b/packages/oneclient_app/src/view/app/accounts.rs @@ -45,10 +45,13 @@ impl Component for Accounts { _ => None, }; let Some(login) = login else { return }; - if login_code_already_handled(&login.user_code) { + if login_code_already_handled(login.dedupe_key()) { return; } - handled_code.set(Some(login.user_code.clone())); + handled_code.set(Some(login.dedupe_key().to_string())); + if let Some(url) = login.browser_url() { + platform::open_url(url); + } finish.mutate(login.clone()); pending_login.set(Some(login)); }); @@ -511,9 +514,18 @@ fn microsoft_dialog( mut pending_login: State>, mut handled_code: State>, ) -> impl IntoElement { - let code = login.user_code.clone(); - let copy_code = code.clone(); - let verification_uri = login.verification_uri.clone(); + let body = match &login { + MinecraftLogin::DeviceCode(flow) => { + device_code_dialog_body(flow.user_code.clone(), flow.verification_uri.clone()) + .into_element() + } + MinecraftLogin::Browser(flow) => { + browser_dialog_body(flow.auth_url.clone()).into_element() + } + }; + + let mut close_pending = pending_login; + let mut close_handled = handled_code; OverlayPopup::new() .on_close(move |()| { @@ -543,76 +555,14 @@ fn microsoft_dialog( .font_weight(FontWeight::SEMI_BOLD) .color(colors::fg_primary()), ) - .child( - label() - .text("Enter this code on the Microsoft sign-in page:") - .font_size(13.) - .color(colors::fg_secondary()), - ) - .child( - rect() - .width(Size::fill()) - .center() - .padding(Gaps::new_symmetric(20., 16.)) - .corner_radius(CornerRadius::new_all(14.)) - .background(colors::component_bg()) - .border(border_all_color(1., colors::brand())) - .child( - label() - .text(code) - .font_size(48.) - .font_weight(FontWeight::BOLD) - .color(colors::fg_primary()), - ), - ) - .child( - rect() - .horizontal() - .width(Size::fill()) - .main_align(Alignment::Center) - .spacing(8.) - .child( - Button::new() - .secondary() - .on_press(move |_| { - if let Err(err) = Clipboard::set(copy_code.clone()) { - tracing::warn!("clipboard copy failed: {err:?}"); - } - }) - .child(Icon::new(IconType::Copy01).size(16.)) - .text("Copy code"), - ) - .child( - Button::new() - .primary() - .on_press(move |_| platform::open_url(&verification_uri)) - .child(Icon::new(IconType::LinkExternal01).size(16.)) - .text("Open in browser"), - ), - ) - .child( - rect() - .horizontal() - .cross_align(Alignment::Center) - .spacing(6.) - .child( - Icon::new(IconType::Loading02) - .size(13.) - .color(colors::brand()), - ) - .child( - label() - .text("Waiting for you to finish signing in...") - .font_size(12.) - .color(colors::fg_secondary()), - ), - ) + .child(body) + .child(waiting_row()) .child( Button::new() .ghost() .on_press(move |_| { - pending_login.set(None); - handled_code.set(None); + close_pending.set(None); + close_handled.set(None); }) .text("Cancel"), ), @@ -621,6 +571,104 @@ fn microsoft_dialog( .into_element() } +fn waiting_row() -> impl IntoElement { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .child( + Icon::new(IconType::Loading02) + .size(13.) + .color(colors::brand()), + ) + .child( + label() + .text("Waiting for you to finish signing in...") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn browser_dialog_body(auth_url: String) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(18.) + .child( + label() + .text("Your browser has opened the Microsoft sign-in page. Complete sign-in there and you'll be brought back automatically.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + Button::new() + .primary() + .on_press(move |_| platform::open_url(&auth_url)) + .child(Icon::new(IconType::LinkExternal01).size(16.)) + .text("Open in browser again"), + ) + .into_element() +} + +fn device_code_dialog_body(code: String, verification_uri: String) -> impl IntoElement { + let copy_code = code.clone(); + rect() + .vertical() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(18.) + .child( + label() + .text("Enter this code on the Microsoft sign-in page:") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .width(Size::fill()) + .center() + .padding(Gaps::new_symmetric(20., 16.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::brand())) + .child( + label() + .text(code) + .font_size(48.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::Center) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| { + if let Err(err) = Clipboard::set(copy_code.clone()) { + tracing::warn!("clipboard copy failed: {err:?}"); + } + }) + .child(Icon::new(IconType::Copy01).size(16.)) + .text("Copy code"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| platform::open_url(&verification_uri)) + .child(Icon::new(IconType::LinkExternal01).size(16.)) + .text("Open in browser"), + ), + ) + .into_element() +} + fn kind_badge(kind: AccountKind) -> impl IntoElement { let (icon, text) = match kind { AccountKind::Microsoft => (IconType::Globe01, "Microsoft"), diff --git a/packages/oneclient_app/src/view/app/settings/launcher.rs b/packages/oneclient_app/src/view/app/settings/launcher.rs index 19a2aabd..c015d426 100644 --- a/packages/oneclient_app/src/view/app/settings/launcher.rs +++ b/packages/oneclient_app/src/view/app/settings/launcher.rs @@ -19,18 +19,27 @@ impl Component for SettingsLauncher { let v = settings.discord_enabled; move || v }); + let browser_login = use_state({ + let v = settings.microsoft_login_use_browser; + move || v + }); let mut first = use_state(|| true); - use_side_effect(move || { - let enabled = *discord_rpc.read(); - if *first.peek() { - first.set(false); - return; - } - let mut next = settings.clone(); - next.discord_enabled = enabled; - dispatch.set_settings(next); - }); + { + let settings = settings.clone(); + use_side_effect(move || { + let discord = *discord_rpc.read(); + let browser = *browser_login.read(); + if *first.peek() { + first.set(false); + return; + } + let mut next = settings.clone(); + next.discord_enabled = discord; + next.microsoft_login_use_browser = browser; + dispatch.set_settings(next); + }); + } let folder = data_dir.clone(); let open_folder = link_button().on_press(move |_| platform::open_url(&folder)); @@ -43,6 +52,12 @@ impl Component for SettingsLauncher { "Enable Discord Rich Presence.", toggle(discord_rpc), )) + .child(settings_row( + IconType::Globe01, + "Browser sign-in", + "Sign in to Microsoft by opening your browser instead of entering a code.", + toggle(browser_login), + )) .child(section_header("FOLDERS AND FILES")) .child(settings_row( IconType::Folder, diff --git a/packages/oneclient_app/src/view/onboarding/account.rs b/packages/oneclient_app/src/view/onboarding/account.rs index 0c9027ea..e050d245 100644 --- a/packages/oneclient_app/src/view/onboarding/account.rs +++ b/packages/oneclient_app/src/view/onboarding/account.rs @@ -34,9 +34,14 @@ impl Component for OnboardingAccount { _ => None, }; let Some(login) = login else { return }; - if login_code_already_handled(&login.user_code) { + if login_code_already_handled(login.dedupe_key()) { return; } + // Browser flow: open the system browser straight away so the user + // lands on the Microsoft sign-in page without copying a code. + if let Some(url) = login.browser_url() { + platform::open_url(url); + } finish.mutate(login.clone()); pending_login.set(Some(login)); }); @@ -205,9 +210,15 @@ fn microsoft_dialog( login: MinecraftLogin, mut pending_login: State>, ) -> impl IntoElement { - let code = login.user_code.clone(); - let copy_code = code.clone(); - let verification_uri = login.verification_uri.clone(); + let body = match &login { + MinecraftLogin::DeviceCode(flow) => { + device_code_dialog_body(flow.user_code.clone(), flow.verification_uri.clone()) + .into_element() + } + MinecraftLogin::Browser(flow) => { + browser_dialog_body(flow.auth_url.clone()).into_element() + } + }; OverlayPopup::new() .on_close(move |()| pending_login.set(None)) @@ -234,70 +245,8 @@ fn microsoft_dialog( .font_weight(FontWeight::SEMI_BOLD) .color(colors::fg_primary()), ) - .child( - label() - .text("Enter this code on the Microsoft sign-in page:") - .font_size(13.) - .color(colors::fg_secondary()), - ) - .child( - rect() - .width(Size::fill()) - .center() - .padding(Gaps::new_symmetric(20., 16.)) - .corner_radius(CornerRadius::new_all(14.)) - .background(colors::component_bg()) - .border(border_all_color(1., colors::brand())) - .child( - label() - .text(code) - .font_size(48.) - .font_weight(FontWeight::BOLD) - .color(colors::fg_primary()), - ), - ) - .child( - rect() - .horizontal() - .width(Size::fill()) - .main_align(Alignment::Center) - .spacing(8.) - .child( - Button::new() - .secondary() - .on_press(move |_| { - if let Err(err) = Clipboard::set(copy_code.clone()) { - tracing::warn!("clipboard copy failed: {err:?}"); - } - }) - .child(Icon::new(IconType::Copy01).size(16.)) - .text("Copy code"), - ) - .child( - Button::new() - .primary() - .on_press(move |_| platform::open_url(&verification_uri)) - .child(Icon::new(IconType::LinkExternal01).size(16.)) - .text("Open in browser"), - ), - ) - .child( - rect() - .horizontal() - .cross_align(Alignment::Center) - .spacing(6.) - .child( - Icon::new(IconType::Loading02) - .size(13.) - .color(colors::brand()), - ) - .child( - label() - .text("Waiting for you to finish signing in...") - .font_size(12.) - .color(colors::fg_secondary()), - ), - ) + .child(body) + .child(waiting_row()) .child( Button::new() .ghost() @@ -308,3 +257,101 @@ fn microsoft_dialog( ) .into_element() } + +fn waiting_row() -> impl IntoElement { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .child( + Icon::new(IconType::Loading02) + .size(13.) + .color(colors::brand()), + ) + .child( + label() + .text("Waiting for you to finish signing in...") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn browser_dialog_body(auth_url: String) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(18.) + .child( + label() + .text("Your browser has opened the Microsoft sign-in page. Complete sign-in there and you'll be brought back automatically.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + Button::new() + .primary() + .on_press(move |_| platform::open_url(&auth_url)) + .child(Icon::new(IconType::LinkExternal01).size(16.)) + .text("Open in browser again"), + ) + .into_element() +} + +fn device_code_dialog_body(code: String, verification_uri: String) -> impl IntoElement { + let copy_code = code.clone(); + rect() + .vertical() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(18.) + .child( + label() + .text("Enter this code on the Microsoft sign-in page:") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .width(Size::fill()) + .center() + .padding(Gaps::new_symmetric(20., 16.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::brand())) + .child( + label() + .text(code) + .font_size(48.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::Center) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| { + if let Err(err) = Clipboard::set(copy_code.clone()) { + tracing::warn!("clipboard copy failed: {err:?}"); + } + }) + .child(Icon::new(IconType::Copy01).size(16.)) + .text("Copy code"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| platform::open_url(&verification_uri)) + .child(Icon::new(IconType::LinkExternal01).size(16.)) + .text("Open in browser"), + ), + ) + .into_element() +} diff --git a/packages/oneclient_core/examples/auth_msa.rs b/packages/oneclient_core/examples/auth_msa.rs index 721fcc8b..2eaa4964 100644 --- a/packages/oneclient_core/examples/auth_msa.rs +++ b/packages/oneclient_core/examples/auth_msa.rs @@ -1,7 +1,7 @@ use std::process::Command; -use oneclient_core::auth; +use oneclient_core::auth::{self, MinecraftLogin}; use oneclient_core::{dev, logger, LauncherResult}; #[tokio::main] @@ -11,11 +11,18 @@ async fn main() -> LauncherResult<()> { let flow = auth::begin_microsoft_login().await?; - println!("{}\n", flow.message); - println!("Verification URL: {}", flow.verification_uri); - println!("Enter this code: {}\n", flow.user_code); - - try_open_browser(&flow.verification_uri); + match &flow { + MinecraftLogin::DeviceCode(device) => { + println!("{}\n", device.message); + println!("Verification URL: {}", device.verification_uri); + println!("Enter this code: {}\n", device.user_code); + try_open_browser(&device.verification_uri); + } + MinecraftLogin::Browser(browser) => { + println!("Opening browser to sign in...\n"); + try_open_browser(&browser.auth_url); + } + } println!("Waiting for you to sign in..."); let account = auth::finish_microsoft_login(flow).await?; diff --git a/packages/oneclient_core/src/auth/data.rs b/packages/oneclient_core/src/auth/data.rs index f466031c..b29cb80c 100644 --- a/packages/oneclient_core/src/auth/data.rs +++ b/packages/oneclient_core/src/auth/data.rs @@ -35,7 +35,7 @@ impl MinecraftAccount { } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct MinecraftLogin { +pub struct DeviceCodeLogin { pub user_code: String, pub device_code: String, pub verification_uri: String, @@ -43,3 +43,32 @@ pub struct MinecraftLogin { pub interval: u64, pub message: String, } + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct BrowserLogin { + pub auth_url: String, + pub state: String, + pub redirect_uri: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum MinecraftLogin { + DeviceCode(DeviceCodeLogin), + Browser(BrowserLogin), +} + +impl MinecraftLogin { + pub fn dedupe_key(&self) -> &str { + match self { + MinecraftLogin::DeviceCode(flow) => &flow.user_code, + MinecraftLogin::Browser(flow) => &flow.state, + } + } + + pub fn browser_url(&self) -> Option<&str> { + match self { + MinecraftLogin::Browser(flow) => Some(&flow.auth_url), + MinecraftLogin::DeviceCode(_) => None, + } + } +} diff --git a/packages/oneclient_core/src/auth/error.rs b/packages/oneclient_core/src/auth/error.rs index 6710df0c..2dfae500 100644 --- a/packages/oneclient_core/src/auth/error.rs +++ b/packages/oneclient_core/src/auth/error.rs @@ -6,6 +6,7 @@ use crate::http::RequestError; pub enum MinecraftAuthStep { DeviceCodeRequest, DeviceCodePoll, + AuthCodeExchange, RefreshToken, XblAuthenticate, XstsAuthorize, @@ -46,6 +47,14 @@ pub enum MinecraftAuthError { DeviceAuthorizationExpired, #[error("device authorization failed: {error}")] DeviceAuthorizationFailed { error: String }, + #[error("failed to start the loopback redirect server: {0}")] + LoopbackBind(String), + #[error("browser sign-in was cancelled or timed out before completing")] + BrowserAuthorizationExpired, + #[error("this browser sign-in is no longer active")] + BrowserLoginNotFound, + #[error("browser authorization failed: {error}")] + BrowserAuthorizationFailed { error: String }, #[error("failed to read user hash from Xbox token")] HashError, #[error("Minecraft authentication error {error_code} during MSA step {step:?}: {message}")] diff --git a/packages/oneclient_core/src/auth/mod.rs b/packages/oneclient_core/src/auth/mod.rs index bfe494b0..8e2827ca 100644 --- a/packages/oneclient_core/src/auth/mod.rs +++ b/packages/oneclient_core/src/auth/mod.rs @@ -7,7 +7,8 @@ mod data; pub use error::{AuthError, MinecraftAuthError, MinecraftAuthStep}; pub use offline::{offline_account, offline_uuid, validate_offline_username}; pub use store::CredentialsStore; -pub use data::{AccountKind, MinecraftAccount, MinecraftLogin}; +pub use data::{AccountKind, BrowserLogin, DeviceCodeLogin, MinecraftAccount, MinecraftLogin}; +pub use msa::PendingBrowserLogin; use uuid::Uuid; @@ -16,14 +17,60 @@ use crate::state::LauncherState; pub async fn begin_microsoft_login() -> LauncherResult { let state = LauncherState::get()?; - let mut auth = state.auth.lock().await; - auth.begin_microsoft_login(&state.services).await + let use_browser = state.settings.read().microsoft_login_use_browser; + + if use_browser { + let (login, pending) = msa::begin_browser_login().await.map_err(AuthError::from)?; + state + .microsoft_logins + .lock() + .await + .insert(login.state.clone(), pending); + Ok(MinecraftLogin::Browser(login)) + } else { + let client = state.services.requester.http(); + let flow = msa::begin_device_login(client).await.map_err(AuthError::from)?; + Ok(MinecraftLogin::DeviceCode(flow)) + } } pub async fn finish_microsoft_login(flow: MinecraftLogin) -> LauncherResult { let state = LauncherState::get()?; - let mut auth = state.auth.lock().await; - auth.finish_microsoft_login(&flow, &state.services).await + + match flow { + MinecraftLogin::DeviceCode(flow) => { + let mut auth = state.auth.lock().await; + auth.finish_device_login(&flow, &state.services).await + } + MinecraftLogin::Browser(login) => { + let pending = state + .microsoft_logins + .lock() + .await + .remove(&login.state) + .ok_or(AuthError::Minecraft( + MinecraftAuthError::BrowserLoginNotFound, + ))?; + + let client = state.services.requester.http(); + let progress_id = Uuid::new_v4(); + let notifier = state.services.notifier.clone(); + let account = msa::finish_browser_login(client, pending, |label, current, total| { + notifier.send_progress(&progress_id, label, current, total); + }) + .await + .map_err(AuthError::from)?; + + let mut auth = state.auth.lock().await; + auth.commit_account(account, &state.services).await + } + } +} + +pub async fn cancel_microsoft_login(state_token: &str) { + if let Ok(state) = LauncherState::get() { + state.microsoft_logins.lock().await.remove(state_token); + } } pub async fn add_offline_account(username: String) -> LauncherResult { diff --git a/packages/oneclient_core/src/auth/msa.rs b/packages/oneclient_core/src/auth/msa.rs index 919ffffa..ff3884aa 100644 --- a/packages/oneclient_core/src/auth/msa.rs +++ b/packages/oneclient_core/src/auth/msa.rs @@ -2,31 +2,43 @@ use std::future::Future; use std::time::Duration; +use base64::prelude::BASE64_URL_SAFE_NO_PAD; +use base64::Engine; use chrono::{DateTime, Utc}; use reqwest::Client; use serde::de::Error as DeError; use serde::Deserialize; use serde_json::json; +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use url::Url; use uuid::Uuid; -use super::data::{AccountKind, MinecraftAccount, MinecraftLogin}; +use super::data::{AccountKind, BrowserLogin, DeviceCodeLogin, MinecraftAccount}; use super::error::{MinecraftAuthError, MinecraftAuthStep}; use crate::constants::{MICROSOFT_CLIENT_ID, MINECRAFT_SCOPES}; const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"; +const AUTHORIZE_URL: &str = + "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"; + const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; const AUTH_STEPS: u64 = 5; -pub async fn begin_login(client: &Client) -> Result { +const BROWSER_LOGIN_TIMEOUT: Duration = Duration::from_secs(5 * 60); + + +pub async fn begin_device_login(client: &Client) -> Result { request_device_code(client).await } -pub async fn finish_login( +pub async fn finish_device_login( client: &Client, - flow: &MinecraftLogin, + flow: &DeviceCodeLogin, on_progress: impl Fn(&str, u64, u64), ) -> Result { on_progress("Waiting for sign-in", 0, AUTH_STEPS); @@ -34,6 +46,258 @@ pub async fn finish_login( account_from_msa_token(client, msa, on_progress).await } +pub struct PendingBrowserLogin { + listener: TcpListener, + pkce_verifier: String, + redirect_uri: String, + csrf_state: String, +} + +pub async fn begin_browser_login() -> Result<(BrowserLogin, PendingBrowserLogin), MinecraftAuthError> +{ + let listener = TcpListener::bind("127.0.0.1:0") + .await + .map_err(|err| MinecraftAuthError::LoopbackBind(err.to_string()))?; + let port = listener + .local_addr() + .map_err(|err| MinecraftAuthError::LoopbackBind(err.to_string()))? + .port(); + let redirect_uri = format!("http://127.0.0.1:{port}"); + + let pkce_verifier = random_token(); + let code_challenge = pkce_challenge(&pkce_verifier); + let csrf_state = random_token(); + + let mut url = Url::parse(AUTHORIZE_URL).expect("authorize url is valid"); + url.query_pairs_mut() + .append_pair("client_id", MICROSOFT_CLIENT_ID) + .append_pair("response_type", "code") + .append_pair("redirect_uri", &redirect_uri) + .append_pair("response_mode", "query") + .append_pair("scope", MINECRAFT_SCOPES) + .append_pair("state", &csrf_state) + .append_pair("code_challenge", &code_challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("prompt", "select_account"); + + let login = BrowserLogin { + auth_url: url.to_string(), + state: csrf_state.clone(), + redirect_uri: redirect_uri.clone(), + }; + let pending = PendingBrowserLogin { + listener, + pkce_verifier, + redirect_uri, + csrf_state, + }; + + Ok((login, pending)) +} + +pub async fn finish_browser_login( + client: &Client, + pending: PendingBrowserLogin, + on_progress: impl Fn(&str, u64, u64), +) -> Result { + on_progress("Waiting for sign-in", 0, AUTH_STEPS); + let code = tokio::time::timeout(BROWSER_LOGIN_TIMEOUT, wait_for_redirect(&pending)) + .await + .map_err(|_| MinecraftAuthError::BrowserAuthorizationExpired)??; + + let msa = exchange_auth_code(client, &code, &pending).await?; + account_from_msa_token(client, msa, on_progress).await +} + +async fn wait_for_redirect(pending: &PendingBrowserLogin) -> Result { + loop { + let (mut stream, _) = pending + .listener + .accept() + .await + .map_err(|err| MinecraftAuthError::LoopbackBind(err.to_string()))?; + + let mut buf = [0u8; 2048]; + let read = stream.read(&mut buf).await.unwrap_or(0); + let request = String::from_utf8_lossy(&buf[..read]); + let Some(target) = request.split_whitespace().nth(1) else { + respond(&mut stream, RedirectPage::Waiting).await; + continue; + }; + + if !target.contains('?') { + respond(&mut stream, RedirectPage::Waiting).await; + continue; + } + + let full = format!("http://127.0.0.1{target}"); + let parsed = Url::parse(&full).ok(); + let mut code = None; + let mut state = None; + let mut error = None; + if let Some(parsed) = &parsed { + for (key, value) in parsed.query_pairs() { + match key.as_ref() { + "code" => code = Some(value.into_owned()), + "state" => state = Some(value.into_owned()), + "error" => error = Some(value.into_owned()), + "error_description" => error = Some(value.into_owned()), + _ => {} + } + } + } + + if let Some(error) = error { + respond(&mut stream, RedirectPage::Failed).await; + return Err(MinecraftAuthError::BrowserAuthorizationFailed { error }); + } + + if state.as_deref() != Some(pending.csrf_state.as_str()) { + respond(&mut stream, RedirectPage::Failed).await; + continue; + } + + if let Some(code) = code { + respond(&mut stream, RedirectPage::Success).await; + return Ok(code); + } + + respond(&mut stream, RedirectPage::Waiting).await; + } +} + +#[derive(Clone, Copy)] +enum RedirectPage { + Waiting, + Success, + Failed, +} + +async fn respond(stream: &mut tokio::net::TcpStream, page: RedirectPage) { + let body = redirect_page_html(page); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.flush().await; +} + +fn redirect_page_html(page: RedirectPage) -> String { + let (accent, glyph, heading, detail) = match page { + RedirectPage::Waiting => ( + "#2567d8", + // Spinning ring. + r#"
"#, + "Signing you in", + "Finish signing in with Microsoft in this window.", + ), + RedirectPage::Success => ( + "#45de2b", + r#""#, + "You're signed in!", + "You can close this tab and return to OneClient.", + ), + RedirectPage::Failed => ( + "#ff0000", + r#""#, + "Sign-in failed", + "Something went wrong. Close this tab and try again from OneClient.", + ), + }; + + format!( + r##" + + +OneClient + + +
+
{glyph}
+

{heading}

+

{detail}

+
+"## + ) +} + +async fn exchange_auth_code( + client: &Client, + code: &str, + pending: &PendingBrowserLogin, +) -> Result { + let body = [ + ("client_id", MICROSOFT_CLIENT_ID), + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", pending.redirect_uri.as_str()), + ("code_verifier", pending.pkce_verifier.as_str()), + ("scope", MINECRAFT_SCOPES), + ]; + + let res = auth_retry(|| { + client + .post(TOKEN_URL) + .header("Accept", "application/json") + .form(&body) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::AuthCodeExchange, + source, + })?; + + parse_json_response(res, MinecraftAuthStep::AuthCodeExchange) + .await + .map(MsaToken::from_response) +} + +fn random_token() -> String { + let mut bytes = [0u8; 32]; + bytes[..16].copy_from_slice(Uuid::new_v4().as_bytes()); + bytes[16..].copy_from_slice(Uuid::new_v4().as_bytes()); + BASE64_URL_SAFE_NO_PAD.encode(bytes) +} + +fn pkce_challenge(verifier: &str) -> String { + let digest = Sha256::digest(verifier.as_bytes()); + BASE64_URL_SAFE_NO_PAD.encode(digest) +} + pub async fn refresh_microsoft_account( client: &Client, creds: &MinecraftAccount, @@ -45,7 +309,7 @@ pub async fn refresh_microsoft_account( Ok(account) } -async fn request_device_code(client: &Client) -> Result { +async fn request_device_code(client: &Client) -> Result { let body = [ ("client_id", MICROSOFT_CLIENT_ID), ("scope", MINECRAFT_SCOPES), @@ -69,7 +333,7 @@ async fn request_device_code(client: &Client) -> Result Result { let deadline = Utc::now() + chrono::TimeDelta::seconds(flow.expires_in as i64); let mut interval_secs = flow.interval.max(5); diff --git a/packages/oneclient_core/src/auth/store.rs b/packages/oneclient_core/src/auth/store.rs index 69f3cea4..a9c7b8e4 100644 --- a/packages/oneclient_core/src/auth/store.rs +++ b/packages/oneclient_core/src/auth/store.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use super::error::AuthError; use super::offline::{offline_account, validate_offline_username}; -use super::data::{AccountKind, MinecraftAccount, MinecraftLogin}; +use super::data::{AccountKind, DeviceCodeLogin, MinecraftAccount}; use crate::paths; use crate::state::LauncherServices; use crate::LauncherResult; @@ -57,30 +57,28 @@ impl CredentialsStore { self.users.get(&id) } - pub async fn begin_microsoft_login( + pub async fn finish_device_login( &mut self, - services: &LauncherServices, - ) -> LauncherResult { - let client = services.requester.http(); - Ok(super::msa::begin_login(client) - .await - .map_err(AuthError::from)?) - } - - pub async fn finish_microsoft_login( - &mut self, - flow: &MinecraftLogin, + flow: &DeviceCodeLogin, services: &LauncherServices, ) -> LauncherResult { let client = services.requester.http(); let progress_id = Uuid::new_v4(); let notifier = services.notifier.clone(); - let account = super::msa::finish_login(client, flow, |label, current, total| { + let account = super::msa::finish_device_login(client, flow, |label, current, total| { notifier.send_progress(&progress_id, label, current, total); }) .await .map_err(AuthError::from)?; + self.commit_account(account, services).await + } + + pub async fn commit_account( + &mut self, + account: MinecraftAccount, + services: &LauncherServices, + ) -> LauncherResult { self.users.insert(account.id, account.clone()); if self.default_user.is_none() { diff --git a/packages/oneclient_core/src/dev.rs b/packages/oneclient_core/src/dev.rs index 5d27799d..1a60a38d 100644 --- a/packages/oneclient_core/src/dev.rs +++ b/packages/oneclient_core/src/dev.rs @@ -229,6 +229,7 @@ pub async fn ephemeral_state() -> LauncherResult> { }, settings: parking_lot::RwLock::new(settings), auth: tokio::sync::Mutex::new(CredentialsStore::default()), + microsoft_logins: tokio::sync::Mutex::new(std::collections::HashMap::new()), java: crate::java::JavaManager, metadata: tokio::sync::Mutex::new(crate::metadata::MetadataStore::new()), bundles: Arc::new(crate::bundles::BundlesManager::new()), diff --git a/packages/oneclient_core/src/settings/launcher.rs b/packages/oneclient_core/src/settings/launcher.rs index 9991984f..cd930f99 100644 --- a/packages/oneclient_core/src/settings/launcher.rs +++ b/packages/oneclient_core/src/settings/launcher.rs @@ -28,6 +28,7 @@ pub struct LauncherSettings { pub auto_update: bool, pub enable_gamemode: bool, pub discord_enabled: bool, + pub microsoft_login_use_browser: bool, pub max_concurrent_requests: usize, pub global_game_settings: GameSettingsProfile, pub allow_parallel_running_clusters: bool, @@ -58,6 +59,7 @@ impl Default for LauncherSettings { log_debug: false, auto_update: true, discord_enabled: true, + microsoft_login_use_browser: true, enable_gamemode: false, max_concurrent_requests: 25, global_game_settings: GameSettingsProfile::default_global_profile(), diff --git a/packages/oneclient_core/src/state.rs b/packages/oneclient_core/src/state.rs index c7e46360..01a9d52a 100644 --- a/packages/oneclient_core/src/state.rs +++ b/packages/oneclient_core/src/state.rs @@ -1,10 +1,11 @@ +use std::collections::HashMap; use std::sync::Arc; use oneclient_db::DbPool; use tokio::sync::{Mutex, OnceCell}; use tracing::instrument; -use crate::auth::CredentialsStore; +use crate::auth::{CredentialsStore, PendingBrowserLogin}; use crate::bundles::BundlesManager; use crate::http::RequestClient; use crate::images::ImageCacheStore; @@ -31,6 +32,7 @@ pub struct LauncherState { pub services: LauncherServices, pub settings: parking_lot::RwLock, pub auth: Mutex, + pub microsoft_logins: Mutex>, pub java: JavaManager, pub metadata: Mutex, pub bundles: Arc, @@ -63,6 +65,7 @@ impl LauncherState { services, settings: parking_lot::RwLock::new(settings), auth: Mutex::new(auth), + microsoft_logins: Mutex::new(HashMap::new()), java, metadata: Mutex::new(MetadataStore::new()), bundles: Arc::new(BundlesManager::new()),