use authorize oauth2 instead of devicecode oauth2

This commit is contained in:
Julian Chang 2026-07-06 02:40:33 -04:00
parent c27688c8d4
commit 41801010a4
No known key found for this signature in database
12 changed files with 652 additions and 182 deletions

View file

@ -45,10 +45,13 @@ impl Component for Accounts {
_ => None, _ => None,
}; };
let Some(login) = login else { return }; let Some(login) = login else { return };
if login_code_already_handled(&login.user_code) { if login_code_already_handled(login.dedupe_key()) {
return; 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()); finish.mutate(login.clone());
pending_login.set(Some(login)); pending_login.set(Some(login));
}); });
@ -511,9 +514,18 @@ fn microsoft_dialog(
mut pending_login: State<Option<MinecraftLogin>>, mut pending_login: State<Option<MinecraftLogin>>,
mut handled_code: State<Option<String>>, mut handled_code: State<Option<String>>,
) -> impl IntoElement { ) -> impl IntoElement {
let code = login.user_code.clone(); let body = match &login {
let copy_code = code.clone(); MinecraftLogin::DeviceCode(flow) => {
let verification_uri = login.verification_uri.clone(); 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() OverlayPopup::new()
.on_close(move |()| { .on_close(move |()| {
@ -543,76 +555,14 @@ fn microsoft_dialog(
.font_weight(FontWeight::SEMI_BOLD) .font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()), .color(colors::fg_primary()),
) )
.child( .child(body)
label() .child(waiting_row())
.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( .child(
Button::new() Button::new()
.ghost() .ghost()
.on_press(move |_| { .on_press(move |_| {
pending_login.set(None); close_pending.set(None);
handled_code.set(None); close_handled.set(None);
}) })
.text("Cancel"), .text("Cancel"),
), ),
@ -621,6 +571,104 @@ fn microsoft_dialog(
.into_element() .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 { fn kind_badge(kind: AccountKind) -> impl IntoElement {
let (icon, text) = match kind { let (icon, text) = match kind {
AccountKind::Microsoft => (IconType::Globe01, "Microsoft"), AccountKind::Microsoft => (IconType::Globe01, "Microsoft"),

View file

@ -19,18 +19,27 @@ impl Component for SettingsLauncher {
let v = settings.discord_enabled; let v = settings.discord_enabled;
move || v move || v
}); });
let browser_login = use_state({
let v = settings.microsoft_login_use_browser;
move || v
});
let mut first = use_state(|| true); let mut first = use_state(|| true);
use_side_effect(move || { {
let enabled = *discord_rpc.read(); let settings = settings.clone();
if *first.peek() { use_side_effect(move || {
first.set(false); let discord = *discord_rpc.read();
return; let browser = *browser_login.read();
} if *first.peek() {
let mut next = settings.clone(); first.set(false);
next.discord_enabled = enabled; return;
dispatch.set_settings(next); }
}); 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 folder = data_dir.clone();
let open_folder = link_button().on_press(move |_| platform::open_url(&folder)); let open_folder = link_button().on_press(move |_| platform::open_url(&folder));
@ -43,6 +52,12 @@ impl Component for SettingsLauncher {
"Enable Discord Rich Presence.", "Enable Discord Rich Presence.",
toggle(discord_rpc), 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(section_header("FOLDERS AND FILES"))
.child(settings_row( .child(settings_row(
IconType::Folder, IconType::Folder,

View file

@ -34,9 +34,14 @@ impl Component for OnboardingAccount {
_ => None, _ => None,
}; };
let Some(login) = login else { return }; let Some(login) = login else { return };
if login_code_already_handled(&login.user_code) { if login_code_already_handled(login.dedupe_key()) {
return; 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()); finish.mutate(login.clone());
pending_login.set(Some(login)); pending_login.set(Some(login));
}); });
@ -205,9 +210,15 @@ fn microsoft_dialog(
login: MinecraftLogin, login: MinecraftLogin,
mut pending_login: State<Option<MinecraftLogin>>, mut pending_login: State<Option<MinecraftLogin>>,
) -> impl IntoElement { ) -> impl IntoElement {
let code = login.user_code.clone(); let body = match &login {
let copy_code = code.clone(); MinecraftLogin::DeviceCode(flow) => {
let verification_uri = login.verification_uri.clone(); 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() OverlayPopup::new()
.on_close(move |()| pending_login.set(None)) .on_close(move |()| pending_login.set(None))
@ -234,70 +245,8 @@ fn microsoft_dialog(
.font_weight(FontWeight::SEMI_BOLD) .font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()), .color(colors::fg_primary()),
) )
.child( .child(body)
label() .child(waiting_row())
.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( .child(
Button::new() Button::new()
.ghost() .ghost()
@ -308,3 +257,101 @@ fn microsoft_dialog(
) )
.into_element() .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()
}

View file

@ -1,7 +1,7 @@
use std::process::Command; use std::process::Command;
use oneclient_core::auth; use oneclient_core::auth::{self, MinecraftLogin};
use oneclient_core::{dev, logger, LauncherResult}; use oneclient_core::{dev, logger, LauncherResult};
#[tokio::main] #[tokio::main]
@ -11,11 +11,18 @@ async fn main() -> LauncherResult<()> {
let flow = auth::begin_microsoft_login().await?; let flow = auth::begin_microsoft_login().await?;
println!("{}\n", flow.message); match &flow {
println!("Verification URL: {}", flow.verification_uri); MinecraftLogin::DeviceCode(device) => {
println!("Enter this code: {}\n", flow.user_code); println!("{}\n", device.message);
println!("Verification URL: {}", device.verification_uri);
try_open_browser(&flow.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..."); println!("Waiting for you to sign in...");
let account = auth::finish_microsoft_login(flow).await?; let account = auth::finish_microsoft_login(flow).await?;

View file

@ -35,7 +35,7 @@ impl MinecraftAccount {
} }
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct MinecraftLogin { pub struct DeviceCodeLogin {
pub user_code: String, pub user_code: String,
pub device_code: String, pub device_code: String,
pub verification_uri: String, pub verification_uri: String,
@ -43,3 +43,32 @@ pub struct MinecraftLogin {
pub interval: u64, pub interval: u64,
pub message: String, 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,
}
}
}

View file

@ -6,6 +6,7 @@ use crate::http::RequestError;
pub enum MinecraftAuthStep { pub enum MinecraftAuthStep {
DeviceCodeRequest, DeviceCodeRequest,
DeviceCodePoll, DeviceCodePoll,
AuthCodeExchange,
RefreshToken, RefreshToken,
XblAuthenticate, XblAuthenticate,
XstsAuthorize, XstsAuthorize,
@ -46,6 +47,14 @@ pub enum MinecraftAuthError {
DeviceAuthorizationExpired, DeviceAuthorizationExpired,
#[error("device authorization failed: {error}")] #[error("device authorization failed: {error}")]
DeviceAuthorizationFailed { error: String }, 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")] #[error("failed to read user hash from Xbox token")]
HashError, HashError,
#[error("Minecraft authentication error {error_code} during MSA step {step:?}: {message}")] #[error("Minecraft authentication error {error_code} during MSA step {step:?}: {message}")]

View file

@ -7,7 +7,8 @@ mod data;
pub use error::{AuthError, MinecraftAuthError, MinecraftAuthStep}; pub use error::{AuthError, MinecraftAuthError, MinecraftAuthStep};
pub use offline::{offline_account, offline_uuid, validate_offline_username}; pub use offline::{offline_account, offline_uuid, validate_offline_username};
pub use store::CredentialsStore; 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; use uuid::Uuid;
@ -16,14 +17,60 @@ use crate::state::LauncherState;
pub async fn begin_microsoft_login() -> LauncherResult<MinecraftLogin> { pub async fn begin_microsoft_login() -> LauncherResult<MinecraftLogin> {
let state = LauncherState::get()?; let state = LauncherState::get()?;
let mut auth = state.auth.lock().await; let use_browser = state.settings.read().microsoft_login_use_browser;
auth.begin_microsoft_login(&state.services).await
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<MinecraftAccount> { pub async fn finish_microsoft_login(flow: MinecraftLogin) -> LauncherResult<MinecraftAccount> {
let state = LauncherState::get()?; 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<MinecraftAccount> { pub async fn add_offline_account(username: String) -> LauncherResult<MinecraftAccount> {

View file

@ -2,31 +2,43 @@
use std::future::Future; use std::future::Future;
use std::time::Duration; use std::time::Duration;
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use base64::Engine;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use reqwest::Client; use reqwest::Client;
use serde::de::Error as DeError; use serde::de::Error as DeError;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use sha2::{Digest, Sha256};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use url::Url;
use uuid::Uuid; use uuid::Uuid;
use super::data::{AccountKind, MinecraftAccount, MinecraftLogin}; use super::data::{AccountKind, BrowserLogin, DeviceCodeLogin, MinecraftAccount};
use super::error::{MinecraftAuthError, MinecraftAuthStep}; use super::error::{MinecraftAuthError, MinecraftAuthStep};
use crate::constants::{MICROSOFT_CLIENT_ID, MINECRAFT_SCOPES}; use crate::constants::{MICROSOFT_CLIENT_ID, MINECRAFT_SCOPES};
const DEVICE_CODE_URL: &str = const DEVICE_CODE_URL: &str =
"https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"; "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 TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token";
const AUTH_STEPS: u64 = 5; const AUTH_STEPS: u64 = 5;
pub async fn begin_login(client: &Client) -> Result<MinecraftLogin, MinecraftAuthError> { const BROWSER_LOGIN_TIMEOUT: Duration = Duration::from_secs(5 * 60);
pub async fn begin_device_login(client: &Client) -> Result<DeviceCodeLogin, MinecraftAuthError> {
request_device_code(client).await request_device_code(client).await
} }
pub async fn finish_login( pub async fn finish_device_login(
client: &Client, client: &Client,
flow: &MinecraftLogin, flow: &DeviceCodeLogin,
on_progress: impl Fn(&str, u64, u64), on_progress: impl Fn(&str, u64, u64),
) -> Result<MinecraftAccount, MinecraftAuthError> { ) -> Result<MinecraftAccount, MinecraftAuthError> {
on_progress("Waiting for sign-in", 0, AUTH_STEPS); 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 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<MinecraftAccount, MinecraftAuthError> {
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<String, MinecraftAuthError> {
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#"<div class="ring"></div>"#,
"Signing you in",
"Finish signing in with Microsoft in this window.",
),
RedirectPage::Success => (
"#45de2b",
r#"<svg viewBox="0 0 24 24" class="glyph"><path d="M20 6 9 17l-5-5"/></svg>"#,
"You're signed in!",
"You can close this tab and return to OneClient.",
),
RedirectPage::Failed => (
"#ff0000",
r#"<svg viewBox="0 0 24 24" class="glyph"><path d="M18 6 6 18M6 6l12 12"/></svg>"#,
"Sign-in failed",
"Something went wrong. Close this tab and try again from OneClient.",
),
};
format!(
r##"<!doctype html><html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OneClient</title>
<style>
*{{box-sizing:border-box;margin:0;padding:0}}
:root{{--accent:{accent}}}
html,body{{height:100%}}
body{{
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
color:#111827;
background:#ffffff;
display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px;
}}
.card{{
position:relative;width:100%;max-width:420px;text-align:center;
padding:44px 36px 40px;border-radius:20px;
background:#ffffff;
border:1px solid #e5e7eb;
box-shadow:0 10px 30px rgba(17,24,39,.10);
animation:rise .5s cubic-bezier(.2,.8,.2,1) both;
}}
.badge{{width:76px;height:76px;margin:0 auto 26px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
background:color-mix(in srgb, var(--accent) 12%, transparent);
border:1px solid color-mix(in srgb, var(--accent) 40%, transparent)}}
.glyph{{width:34px;height:34px;fill:none;stroke:var(--accent);stroke-width:2.4;
stroke-linecap:round;stroke-linejoin:round;animation:pop .4s .15s cubic-bezier(.2,1.4,.3,1) both}}
.ring{{width:34px;height:34px;border-radius:50%;
border:3px solid rgba(37,103,216,.22);border-top-color:#2567d8;animation:spin .8s linear infinite}}
h1{{font-size:21px;font-weight:600;color:#111827;margin-bottom:10px}}
p{{font-size:14px;line-height:1.55;color:#6b7280}}
@keyframes spin{{to{{transform:rotate(360deg)}}}}
@keyframes rise{{from{{opacity:0;transform:translateY(10px)}}to{{opacity:1;transform:none}}}}
@keyframes pop{{from{{opacity:0;transform:scale(.6)}}to{{opacity:1;transform:none}}}}
</style></head>
<body>
<main class="card">
<div class="badge">{glyph}</div>
<h1>{heading}</h1>
<p>{detail}</p>
</main>
</body></html>"##
)
}
async fn exchange_auth_code(
client: &Client,
code: &str,
pending: &PendingBrowserLogin,
) -> Result<MsaToken, MinecraftAuthError> {
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( pub async fn refresh_microsoft_account(
client: &Client, client: &Client,
creds: &MinecraftAccount, creds: &MinecraftAccount,
@ -45,7 +309,7 @@ pub async fn refresh_microsoft_account(
Ok(account) Ok(account)
} }
async fn request_device_code(client: &Client) -> Result<MinecraftLogin, MinecraftAuthError> { async fn request_device_code(client: &Client) -> Result<DeviceCodeLogin, MinecraftAuthError> {
let body = [ let body = [
("client_id", MICROSOFT_CLIENT_ID), ("client_id", MICROSOFT_CLIENT_ID),
("scope", MINECRAFT_SCOPES), ("scope", MINECRAFT_SCOPES),
@ -69,7 +333,7 @@ async fn request_device_code(client: &Client) -> Result<MinecraftLogin, Minecraf
async fn poll_device_token( async fn poll_device_token(
client: &Client, client: &Client,
flow: &MinecraftLogin, flow: &DeviceCodeLogin,
) -> Result<MsaToken, MinecraftAuthError> { ) -> Result<MsaToken, MinecraftAuthError> {
let deadline = Utc::now() + chrono::TimeDelta::seconds(flow.expires_in as i64); let deadline = Utc::now() + chrono::TimeDelta::seconds(flow.expires_in as i64);
let mut interval_secs = flow.interval.max(5); let mut interval_secs = flow.interval.max(5);

View file

@ -5,7 +5,7 @@ use uuid::Uuid;
use super::error::AuthError; use super::error::AuthError;
use super::offline::{offline_account, validate_offline_username}; 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::paths;
use crate::state::LauncherServices; use crate::state::LauncherServices;
use crate::LauncherResult; use crate::LauncherResult;
@ -57,30 +57,28 @@ impl CredentialsStore {
self.users.get(&id) self.users.get(&id)
} }
pub async fn begin_microsoft_login( pub async fn finish_device_login(
&mut self, &mut self,
services: &LauncherServices, flow: &DeviceCodeLogin,
) -> LauncherResult<MinecraftLogin> {
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,
services: &LauncherServices, services: &LauncherServices,
) -> LauncherResult<MinecraftAccount> { ) -> LauncherResult<MinecraftAccount> {
let client = services.requester.http(); let client = services.requester.http();
let progress_id = Uuid::new_v4(); let progress_id = Uuid::new_v4();
let notifier = services.notifier.clone(); 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); notifier.send_progress(&progress_id, label, current, total);
}) })
.await .await
.map_err(AuthError::from)?; .map_err(AuthError::from)?;
self.commit_account(account, services).await
}
pub async fn commit_account(
&mut self,
account: MinecraftAccount,
services: &LauncherServices,
) -> LauncherResult<MinecraftAccount> {
self.users.insert(account.id, account.clone()); self.users.insert(account.id, account.clone());
if self.default_user.is_none() { if self.default_user.is_none() {

View file

@ -229,6 +229,7 @@ pub async fn ephemeral_state() -> LauncherResult<Arc<LauncherState>> {
}, },
settings: parking_lot::RwLock::new(settings), settings: parking_lot::RwLock::new(settings),
auth: tokio::sync::Mutex::new(CredentialsStore::default()), auth: tokio::sync::Mutex::new(CredentialsStore::default()),
microsoft_logins: tokio::sync::Mutex::new(std::collections::HashMap::new()),
java: crate::java::JavaManager, java: crate::java::JavaManager,
metadata: tokio::sync::Mutex::new(crate::metadata::MetadataStore::new()), metadata: tokio::sync::Mutex::new(crate::metadata::MetadataStore::new()),
bundles: Arc::new(crate::bundles::BundlesManager::new()), bundles: Arc::new(crate::bundles::BundlesManager::new()),

View file

@ -28,6 +28,7 @@ pub struct LauncherSettings {
pub auto_update: bool, pub auto_update: bool,
pub enable_gamemode: bool, pub enable_gamemode: bool,
pub discord_enabled: bool, pub discord_enabled: bool,
pub microsoft_login_use_browser: bool,
pub max_concurrent_requests: usize, pub max_concurrent_requests: usize,
pub global_game_settings: GameSettingsProfile, pub global_game_settings: GameSettingsProfile,
pub allow_parallel_running_clusters: bool, pub allow_parallel_running_clusters: bool,
@ -58,6 +59,7 @@ impl Default for LauncherSettings {
log_debug: false, log_debug: false,
auto_update: true, auto_update: true,
discord_enabled: true, discord_enabled: true,
microsoft_login_use_browser: true,
enable_gamemode: false, enable_gamemode: false,
max_concurrent_requests: 25, max_concurrent_requests: 25,
global_game_settings: GameSettingsProfile::default_global_profile(), global_game_settings: GameSettingsProfile::default_global_profile(),

View file

@ -1,10 +1,11 @@
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use oneclient_db::DbPool; use oneclient_db::DbPool;
use tokio::sync::{Mutex, OnceCell}; use tokio::sync::{Mutex, OnceCell};
use tracing::instrument; use tracing::instrument;
use crate::auth::CredentialsStore; use crate::auth::{CredentialsStore, PendingBrowserLogin};
use crate::bundles::BundlesManager; use crate::bundles::BundlesManager;
use crate::http::RequestClient; use crate::http::RequestClient;
use crate::images::ImageCacheStore; use crate::images::ImageCacheStore;
@ -31,6 +32,7 @@ pub struct LauncherState {
pub services: LauncherServices, pub services: LauncherServices,
pub settings: parking_lot::RwLock<LauncherSettings>, pub settings: parking_lot::RwLock<LauncherSettings>,
pub auth: Mutex<CredentialsStore>, pub auth: Mutex<CredentialsStore>,
pub microsoft_logins: Mutex<HashMap<String, PendingBrowserLogin>>,
pub java: JavaManager, pub java: JavaManager,
pub metadata: Mutex<MetadataStore>, pub metadata: Mutex<MetadataStore>,
pub bundles: Arc<BundlesManager>, pub bundles: Arc<BundlesManager>,
@ -63,6 +65,7 @@ impl LauncherState {
services, services,
settings: parking_lot::RwLock::new(settings), settings: parking_lot::RwLock::new(settings),
auth: Mutex::new(auth), auth: Mutex::new(auth),
microsoft_logins: Mutex::new(HashMap::new()),
java, java,
metadata: Mutex::new(MetadataStore::new()), metadata: Mutex::new(MetadataStore::new()),
bundles: Arc::new(BundlesManager::new()), bundles: Arc::new(BundlesManager::new()),