use std::future::Future; use std::time::Duration; use base64::Engine; use base64::prelude::BASE64_URL_SAFE_NO_PAD; use chrono::{DateTime, Utc}; use reqwest::Client; use serde::Deserialize; use serde::de::Error as DeError; 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, BrowserLogin, DeviceCodeLogin, MinecraftAccount}; use super::error::{MinecraftAuthError, MinecraftAuthStep, friendly_xbox_error}; use oneclient_common::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; #[tracing::instrument(level = "debug", skip_all)] pub async fn begin_device_login(client: &Client) -> Result { request_device_code(client).await } #[tracing::instrument(level = "debug", skip_all)] pub async fn finish_dual_login( client: &Client, pending: PendingBrowserLogin, device: &DeviceCodeLogin, on_progress: impl Fn(&str, u64, u64), ) -> Result { tracing::info!("waiting for Microsoft sign-in (browser or device code)"); on_progress("Waiting for sign-in", 0, AUTH_STEPS); let deadline = Duration::from_secs(device.expires_in); let msa = tokio::time::timeout(deadline, race_msa_token(client, &pending, device)) .await .map_err(|_| MinecraftAuthError::DeviceAuthorizationExpired)??; account_from_msa_token(client, msa, on_progress).await } #[tracing::instrument(level = "debug", skip_all)] async fn race_msa_token( client: &Client, pending: &PendingBrowserLogin, device: &DeviceCodeLogin, ) -> Result { let browser = browser_msa_token(client, pending); let device = poll_device_token(client, device); tokio::pin!(browser, device); let mut browser_done = false; let mut device_done = false; let mut last_err: Option = None; loop { tokio::select! { res = &mut browser, if !browser_done => match res { Ok(token) => return Ok(token), Err(err) => { browser_done = true; if device_done { return Err(err); } last_err = Some(err); } }, res = &mut device, if !device_done => match res { Ok(token) => return Ok(token), Err(err) => { device_done = true; if browser_done { return Err(err); } last_err = Some(err); } }, else => return Err(last_err.unwrap_or(MinecraftAuthError::DeviceAuthorizationExpired)), } } } pub struct PendingBrowserLogin { listener: TcpListener, pkce_verifier: String, redirect_uri: String, csrf_state: String, } #[tracing::instrument(level = "debug", skip_all)] 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)) } #[tracing::instrument(level = "debug", skip_all)] async fn browser_msa_token( client: &Client, pending: &PendingBrowserLogin, ) -> Result { let code = wait_for_redirect(pending).await?; exchange_auth_code(client, &code, pending).await } #[tracing::instrument(level = "debug", skip_all)] 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 { tracing::warn!(%error, "browser authorization failed"); 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", 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}

"## ) } #[tracing::instrument(level = "debug", skip_all)] 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) } #[tracing::instrument(level = "debug", skip_all, fields(username = %creds.username))] pub async fn refresh_microsoft_account( client: &Client, creds: &MinecraftAccount, ) -> Result { tracing::debug!("refreshing Microsoft account token"); let msa = refresh_msa_token(client, &creds.refresh_token).await?; let mut account = account_from_msa_token(client, msa, |_, _, _| {}).await?; account.id = creds.id; Ok(account) } #[tracing::instrument(level = "debug", skip_all)] async fn request_device_code(client: &Client) -> Result { let body = [ ("client_id", MICROSOFT_CLIENT_ID), ("scope", MINECRAFT_SCOPES), ]; let res = auth_retry(|| { client .post(DEVICE_CODE_URL) .header("Accept", "application/json") .form(&body) .send() }) .await .map_err(|source| MinecraftAuthError::RequestError { step: MinecraftAuthStep::DeviceCodeRequest, source, })?; parse_json_response(res, MinecraftAuthStep::DeviceCodeRequest).await } #[tracing::instrument(level = "debug", skip_all)] async fn poll_device_token( client: &Client, flow: &DeviceCodeLogin, ) -> Result { let deadline = Utc::now() + chrono::TimeDelta::seconds(flow.expires_in as i64); let mut interval_secs = flow.interval.max(5); loop { if Utc::now() >= deadline { return Err(MinecraftAuthError::DeviceAuthorizationExpired); } let body = [ ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), ("client_id", MICROSOFT_CLIENT_ID), ("device_code", flow.device_code.as_str()), ]; let res = auth_retry(|| { client .post(TOKEN_URL) .header("Accept", "application/json") .form(&body) .send() }) .await .map_err(|source| MinecraftAuthError::RequestError { step: MinecraftAuthStep::DeviceCodePoll, source, })?; let status = res.status(); let text = res .text() .await .map_err(|source| MinecraftAuthError::RequestError { step: MinecraftAuthStep::DeviceCodePoll, source, })?; if status.is_success() { let raw: MsaTokenResponse = serde_json::from_str(&text).map_err(|source| { MinecraftAuthError::DeserializeError { step: MinecraftAuthStep::DeviceCodePoll, raw: text, source, status_code: status, } })?; return Ok(MsaToken::from_response(raw)); } if let Ok(err) = serde_json::from_str::(&text) { match err.error.as_str() { "authorization_pending" => { tokio::time::sleep(Duration::from_secs(interval_secs)).await; continue; } "slow_down" => { interval_secs = interval_secs.saturating_add(5); tokio::time::sleep(Duration::from_secs(interval_secs)).await; continue; } "expired_token" => return Err(MinecraftAuthError::DeviceAuthorizationExpired), other => { return Err(MinecraftAuthError::DeviceAuthorizationFailed { error: err.error_description.unwrap_or_else(|| other.to_string()), }); } } } return Err(MinecraftAuthError::DeserializeError { step: MinecraftAuthStep::DeviceCodePoll, raw: text, source: DeError::custom("unexpected device code poll response"), status_code: status, }); } } #[tracing::instrument(level = "debug", skip_all)] async fn refresh_msa_token( client: &Client, refresh_token: &str, ) -> Result { let body = [ ("client_id", MICROSOFT_CLIENT_ID), ("refresh_token", refresh_token), ("grant_type", "refresh_token"), ("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::RefreshToken, source, })?; parse_json_response(res, MinecraftAuthStep::RefreshToken) .await .map(MsaToken::from_response) } struct MsaToken { access_token: String, refresh_token: String, expires_in: u64, obtained_at: DateTime, } impl MsaToken { fn from_response(raw: MsaTokenResponse) -> Self { Self { access_token: raw.access_token, refresh_token: raw.refresh_token, expires_in: raw.expires_in, obtained_at: Utc::now(), } } } #[tracing::instrument(level = "debug", skip_all)] async fn account_from_msa_token( client: &Client, msa: MsaToken, on_progress: impl Fn(&str, u64, u64), ) -> Result { let rps_ticket = format!("d={}", msa.access_token); on_progress("Authenticating with Xbox Live", 1, AUTH_STEPS); let xbl = xbl_authenticate(client, &rps_ticket).await?; on_progress("Authorizing access (XSTS)", 2, AUTH_STEPS); let xsts = xsts_authorize(client, &xbl.token).await?; let xsts_token = xsts.token.ok_or(MinecraftAuthError::HashError)?; let uhs: &str = xsts .display_claims .as_ref() .and_then(|c| c.xui.first()) .map(|x| x.uhs.as_str()) .ok_or(MinecraftAuthError::HashError)?; on_progress("Requesting Minecraft token", 3, AUTH_STEPS); let minecraft_token = minecraft_login(client, uhs, &xsts_token).await?; on_progress("Verifying game ownership", 4, AUTH_STEPS); minecraft_entitlements(client, &minecraft_token.access_token).await?; let profile = minecraft_profile(client, &minecraft_token.access_token).await?; on_progress("Loading profile", AUTH_STEPS, AUTH_STEPS); Ok(MinecraftAccount { id: profile.id.unwrap_or_default(), username: profile.name, access_token: minecraft_token.access_token, refresh_token: msa.refresh_token, #[allow(clippy::cast_possible_wrap)] expires: msa.obtained_at + chrono::TimeDelta::seconds(msa.expires_in as i64), kind: AccountKind::Microsoft, }) } #[tracing::instrument(level = "debug", skip_all)] async fn xbl_authenticate( client: &Client, rps_ticket: &str, ) -> Result { let res = auth_retry(|| { client .post("https://user.auth.xboxlive.com/user/authenticate") .header("Accept", "application/json") .header("x-xbl-contract-version", "1") .json(&json!({ "Properties": { "AuthMethod": "RPS", "SiteName": "user.auth.xboxlive.com", "RpsTicket": rps_ticket }, "RelyingParty": "http://auth.xboxlive.com", "TokenType": "JWT" })) .send() }) .await .map_err(|source| MinecraftAuthError::RequestError { step: MinecraftAuthStep::XblAuthenticate, source, })?; let token: XboxTokenResponse = parse_json_response(res, MinecraftAuthStep::XblAuthenticate).await?; check_xbox_error(MinecraftAuthStep::XblAuthenticate, &token)?; Ok(XblUserToken { token: token.token.ok_or(MinecraftAuthError::DeserializeError { step: MinecraftAuthStep::XblAuthenticate, raw: String::new(), source: DeError::custom("missing Token"), status_code: reqwest::StatusCode::OK, })?, }) } #[tracing::instrument(level = "debug", skip_all)] async fn xsts_authorize( client: &Client, user_token: &str, ) -> Result { let res = auth_retry(|| { client .post("https://xsts.auth.xboxlive.com/xsts/authorize") .header("Accept", "application/json") .header("x-xbl-contract-version", "1") .json(&json!({ "Properties": { "SandboxId": "RETAIL", "UserTokens": [user_token] }, "RelyingParty": "rp://api.minecraftservices.com/", "TokenType": "JWT" })) .send() }) .await .map_err(|source| MinecraftAuthError::RequestError { step: MinecraftAuthStep::XstsAuthorize, source, })?; let token: XboxTokenResponse = parse_json_response(res, MinecraftAuthStep::XstsAuthorize).await?; check_xbox_error(MinecraftAuthStep::XstsAuthorize, &token)?; Ok(token) } #[derive(Deserialize)] struct MinecraftToken { access_token: String, } #[tracing::instrument(level = "debug", skip_all)] async fn minecraft_login( client: &Client, uhs: &str, xsts_token: &str, ) -> Result { let res = auth_retry(|| { client .post("https://api.minecraftservices.com/authentication/login_with_xbox") .header("Accept", "application/json") .json(&json!({ "identityToken": format!("XBL3.0 x={uhs};{xsts_token}"), })) .send() }) .await .map_err(|source| MinecraftAuthError::RequestError { step: MinecraftAuthStep::MinecraftToken, source, })?; parse_json_response(res, MinecraftAuthStep::MinecraftToken).await } #[derive(Deserialize)] struct MinecraftProfile { id: Option, name: String, } #[tracing::instrument(level = "debug", skip_all)] async fn minecraft_profile( client: &Client, token: &str, ) -> Result { let res = auth_retry(|| { client .get("https://api.minecraftservices.com/minecraft/profile") .header("Accept", "application/json") .bearer_auth(token) .send() }) .await .map_err(|source| MinecraftAuthError::RequestError { step: MinecraftAuthStep::MinecraftProfile, source, })?; parse_json_response(res, MinecraftAuthStep::MinecraftProfile).await } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct MinecraftEntitlements {} #[tracing::instrument(level = "debug", skip_all)] async fn minecraft_entitlements( client: &Client, token: &str, ) -> Result { let res = auth_retry(|| { client .get("https://api.minecraftservices.com/entitlements/mcstore") .header("Accept", "application/json") .bearer_auth(token) .send() }) .await .map_err(|source| MinecraftAuthError::RequestError { step: MinecraftAuthStep::MinecraftEntitlements, source, })?; parse_json_response(res, MinecraftAuthStep::MinecraftEntitlements).await } #[derive(Deserialize)] struct OAuthErrorResponse { error: String, error_description: Option, } #[derive(Deserialize)] struct MsaTokenResponse { access_token: String, refresh_token: String, expires_in: u64, } struct XblUserToken { token: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "PascalCase")] struct XboxTokenResponse { #[serde(default)] #[serde(rename = "XErr")] x_err: u64, #[serde(default)] #[serde(rename = "Message")] message: String, #[serde(rename = "Redirect", default)] redirect: Option, #[serde(default)] token: Option, #[serde(default)] display_claims: Option, } #[derive(Debug, Deserialize)] struct DisplayClaims { #[serde(default)] xui: Vec, } #[derive(Debug, Deserialize)] struct XuiClaim { uhs: String, } fn check_xbox_error( step: MinecraftAuthStep, token: &XboxTokenResponse, ) -> Result<(), MinecraftAuthError> { if token.x_err == 0 { return Ok(()); } let message = friendly_xbox_error(token.x_err) .map(String::from) .unwrap_or_else(|| { if token.message.is_empty() { format!("Xbox rejected sign-in (error {}).", token.x_err) } else { token.message.clone() } }); Err(MinecraftAuthError::XboxError { step, error_code: token.x_err, message, redirect: token.redirect.clone(), }) } async fn parse_json_response Deserialize<'de>>( res: reqwest::Response, step: MinecraftAuthStep, ) -> Result { let status = res.status(); let text = res .text() .await .map_err(|source| MinecraftAuthError::RequestError { step, source })?; if !status.is_success() { tracing::error!(sentry = false, ?step, %status, body = %text, "MSA endpoint returned a non-success status"); } serde_json::from_str(&text).map_err(|source| { if !status.is_success() { MinecraftAuthError::ServiceError { step, status_code: status, } } else { MinecraftAuthError::DeserializeError { step, raw: text, source, status_code: status, } } }) } async fn auth_retry(request_fn: impl Fn() -> F) -> Result where F: Future>, { const RETRY_COUNT: usize = 5; const RETRY_WAIT: Duration = Duration::from_millis(250); let mut resp = request_fn().await; for i in 0..RETRY_COUNT { match &resp { Ok(_) => break, Err(err) if (err.is_connect() || err.is_timeout()) && i < RETRY_COUNT - 1 => { tokio::time::sleep(RETRY_WAIT).await; resp = request_fn().await; } _ => break, } } resp }