--- a/crates/xodus-cli/src/commands.rs +++ b/crates/xodus-cli/src/commands.rs @@ -6,6 +6,7 @@ pub mod login; pub mod logout; pub mod profile; +pub mod cloud_token; pub mod social; pub mod game_activity; pub mod achievements; --- a/crates/xodus-cli/src/main.rs +++ b/crates/xodus-cli/src/main.rs @@ -70,6 +70,8 @@ #[arg(long, default_value_t = false)] json: bool, }, + #[command(about = "Get an Xbox Cloud Gaming streaming token for the stored account")] + CloudToken, #[command(about = "Read Xbox gamertag and gamerpic for the stored account")] Profile { #[arg(long, default_value_t = false)] @@ -251,6 +253,7 @@ args.command, SubCommand::Account { .. } | SubCommand::Profile { .. } + | SubCommand::CloudToken | SubCommand::Social { .. } | SubCommand::GameActivity { .. } | SubCommand::Achievements { .. } @@ -317,6 +320,7 @@ } }, SubCommand::Profile { json } => commands::profile::run(&tokens, json).await, + SubCommand::CloudToken => commands::cloud_token::run(&tokens).await, SubCommand::Social { .. } => commands::social::run(&tokens).await, SubCommand::GameActivity { title_id, json } => commands::game_activity::run(&tokens, title_id, json).await, SubCommand::Achievements { title_id, json } => commands::achievements::run(&tokens, title_id, json).await, --- /dev/null +++ b/crates/xodus-cli/src/commands/cloud_token.rs @@ -0,0 +1,226 @@ +use std::{process::ExitCode, time::Duration}; +use serde_json::{Value, json}; +use xodus::{models::{live::ExchangeUserTokenOutcome, secrets::Token, soap}, tokens::TokenManager}; + +const GSSV_RELYING_PARTY: &str = "http://gssv.xboxlive.com/"; +const OFFERINGS: [&str; 2] = ["xgpuweb", "xgpuwebf2p"]; + +async fn ticket(tokens: &TokenManager, client: &reqwest::Client, target: &str, policy: soap::PolicyReference) -> Result<(String, String), &'static str> { + let (account, device, user) = super::session::prepare(tokens, client).await.map_err(|stage| { + super::session::diagnostic(stage); stage + })?; + let outcome = super::session::exchange(|| async { xodus::api::live::exchange_user_token(client, user.clone(), account.clone(), device.clone(), + None, Some("Silent".into()), "{d6d5a677-0872-4ab0-9442-bb792fce85c5}".into(), + &[(target.into(), Some(policy.clone()))]) + .await.map_err(|error| super::session::classify(&error)) }).await?; + let response = match outcome { + ExchangeUserTokenOutcome::Issued(soap::BodyContent::RequestSecurityTokenResponseCollection(mut collection)) if !collection.security_tokens.is_empty() => collection.security_tokens.remove(0), + ExchangeUserTokenOutcome::Issued(soap::BodyContent::RequestSecurityTokenResponse(response)) => *response, + _ => return Err("msa-response"), + }; + let kind = response.token_type.clone(); + if kind == "urn:passport:loginprooftoken" { + let shape = match (&response.requested_security_token.binary_security_token, &response.requested_security_token.encrypted_data) { + (Some(token), _) if !token.value.is_empty() => "binary", + (_, Some(_)) => "encrypted", + _ => "empty", + }; + eprintln!("NEXUS_SESSION_DIAGNOSTIC_V1 login-proof-{shape}"); + return match response.requested_security_token.binary_security_token { + Some(token) if !token.value.is_empty() => Ok((account, token.value)), + _ => Err("msa-ticket"), + }; + } + let compact = matches!(kind.as_str(), "urn:passport:compact" | "urn:passport:delegationcompact") + && response.requested_security_token.binary_security_token.is_some(); + if !compact { + let slug: String = kind.chars().map(|c| if c.is_ascii_alphabetic() { c.to_ascii_lowercase() } else { '-' }).take(40).collect(); + eprintln!("NEXUS_SESSION_DIAGNOSTIC_V1 ticket-type-{slug}"); + return Err("msa-ticket"); + } + let Token::Compact(ticket) = Token::try_from(response).map_err(|_| "msa-ticket")? else { return Err("msa-ticket"); }; + Ok((account, ticket)) +} + +async fn xsts(tokens: &TokenManager, client: &reqwest::Client) -> Result<(String, String), &'static str> { + let (account, ticket) = ticket(tokens, client, "user.auth.xboxlive.com", soap::PolicyReference::mbi_ssl()).await?; + let user = xodus::api::xbox::authenticate_xbox_user(client, ticket).await.map_err(|_| "xbox-user")?; + let xsts = xodus::api::xbox::request_xsts_token(client, user.token, GSSV_RELYING_PARTY).await.map_err(|_| "xsts")?; + Ok((account, xsts.token)) +} + +async fn transfer_token(tokens: &TokenManager, client: &reqwest::Client) -> Result { + let policy = soap::PolicyReference { uri: "PURPOSE_XBOX_CLOUD_CONSOLE_TRANSFER_TOKEN".into(), val: String::default() }; + Ok(ticket(tokens, client, "http://Passport.NET/purpose", policy).await?.1) +} + +fn regions(value: &Value) -> Vec { + value["offeringSettings"]["regions"].as_array().map(|list| list.iter().filter_map(|region| { + let name = region["name"].as_str()?; + let base = reqwest::Url::parse(region["baseUri"].as_str()?).ok()?; + let host = base.host_str()?; + if base.scheme() != "https" || !host.ends_with(".xboxlive.com") || !base.username().is_empty() { return None; } + Some(json!({ "name": name, "baseUri": base.as_str().trim_end_matches('/'), "isDefault": region["isDefault"].as_bool().unwrap_or(false) })) + }).take(32).collect()).unwrap_or_default() +} + +fn offering_response(body: &[u8]) -> Result { + let value: Value = serde_json::from_slice(body).map_err(|_| "gssv-json")?; + value["gsToken"].as_str().filter(|token| !token.is_empty() && token.len() <= 16384).ok_or("gs-token")?; + Ok(value) +} + +async fn request_offering(client: &reqwest::Client, endpoint: &str, xsts: &str, offering: &str) -> Result { + let response = client.post(endpoint) + .header("x-gssv-client", "XboxComBrowser").header("Cache-Control", "no-store, must-revalidate, no-cache") + .json(&json!({ "token": xsts, "offeringId": offering })).send().await.map_err(|_| "gssv-request")?; + let body = super::profile::bounded(response, 256 * 1024).await.map_err(|_| "gssv-response")?; + offering_response(&body) +} + +async fn select_offering(mut request: F) -> Result<(&'static str, Value), String> +where + F: FnMut(&'static str) -> Fut, + Fut: std::future::Future>, +{ + let mut failures = Vec::new(); + for offering in OFFERINGS { + match request(offering).await { + Ok(value) => return Ok((offering, value)), + Err(stage) => failures.push(format!("{offering}:{stage}")), + } + } + Err(format!("gssv-login {}", failures.join(" "))) +} + +async fn login(tokens: &TokenManager) -> Result { + let client = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()) + .timeout(super::session::REQUEST_TIMEOUT).user_agent("xodus-nexus-cloud/1").build().map_err(|_| "http-client")?; + let (account, xsts) = xsts(tokens, &client).await?; + let (offering, value) = select_offering(|offering| { + let endpoint = format!("https://{offering}.gssv-play-prod.xboxlive.com/v2/login/user"); + let client = &client; + let xsts = &xsts; + async move { request_offering(client, &endpoint, xsts, offering).await } + }).await?; + eprintln!("NEXUS_SESSION_DIAGNOSTIC_V1 gssv-login-ok-{}", if offering == "xgpuweb" { "gamepass" } else { "free" }); + let transfer = transfer_token(tokens, &client).await; + eprintln!("NEXUS_SESSION_DIAGNOSTIC_V1 transfer-token-{}", if transfer.is_ok() { "ok" } else { "missing" }); + if tokens.get_user().map_err(|_| "account-changed")?.username != account { return Err("account-changed".into()); } + Ok(json!({ "schema": 1, "account": account, "offering": offering, "gsToken": value["gsToken"], "userToken": transfer.ok(), + "durationInSeconds": value["durationInSeconds"].as_u64().unwrap_or(0), "market": value["market"].as_str().unwrap_or_default(), + "regions": regions(&value) })) +} + +pub async fn run(tokens: &TokenManager) -> ExitCode { + match tokio::time::timeout(Duration::from_secs(45), login(tokens)).await { + Ok(Ok(value)) => { println!("{value}"); ExitCode::SUCCESS } + Ok(Err(stage)) => { eprintln!("Xbox Cloud Gaming sign-in failed ({stage})."); ExitCode::FAILURE } + Err(_) => { eprintln!("Xbox Cloud Gaming sign-in timed out."); ExitCode::FAILURE } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn only_https_xboxlive_regions_are_kept() { + let value = json!({"offeringSettings":{"regions":[ + {"name":"WestEurope","baseUri":"https://weu.core.gssv-play-prod.xboxlive.com/","isDefault":true}, + {"name":"Evil","baseUri":"https://evil.test/"}, + {"name":"Plain","baseUri":"http://eus.core.gssv-play-prod.xboxlive.com"}]}}); + let list = regions(&value); + assert_eq!(list.len(), 1); + assert_eq!(list[0]["baseUri"], "https://weu.core.gssv-play-prod.xboxlive.com"); + } + #[tokio::test] + async fn cloud_fallback_first_success_does_not_call_second_offering() { + let mut calls = Vec::new(); + let (offering, value) = select_offering(|name| { + calls.push(name); + std::future::ready(Ok(json!({"gsToken":"synthetic"}))) + }).await.unwrap(); + assert_eq!(calls, vec!["xgpuweb"]); + assert_eq!(offering, "xgpuweb"); + assert_eq!(value["gsToken"], "synthetic"); + } + + #[tokio::test] + async fn cloud_fallback_request_failure_tries_second_offering() { + let mut calls = Vec::new(); + let (offering, _) = select_offering(|name| { + calls.push(name); + std::future::ready(if name == "xgpuweb" { Err("gssv-request") } + else { Ok(json!({"gsToken":"synthetic"})) }) + }).await.unwrap(); + assert_eq!(calls, OFFERINGS); + assert_eq!(offering, "xgpuwebf2p"); + } + + #[tokio::test] + async fn cloud_fallback_invalid_json_or_token_tries_second_offering() { + let invalid = vec![b"not-json".to_vec(), b"{}".to_vec(), + br#"{"gsToken":""}"#.to_vec(), br#"{"gsToken":17}"#.to_vec(), + serde_json::to_vec(&json!({"gsToken":"x".repeat(16385)})).unwrap()]; + for body in invalid { + let mut calls = Vec::new(); + let (offering, _) = select_offering(|name| { + calls.push(name); + std::future::ready(if name == "xgpuweb" { offering_response(&body) } + else { offering_response(br#"{"gsToken":"synthetic"}"#) }) + }).await.unwrap(); + assert_eq!(calls, OFFERINGS); + assert_eq!(offering, "xgpuwebf2p"); + } + assert!(offering_response(&serde_json::to_vec(&json!({"gsToken":"x".repeat(16384)})).unwrap()).is_ok()); + } + + #[tokio::test] + async fn cloud_fallback_all_failures_report_only_fixed_stages() { + let mut calls = Vec::new(); + let error = select_offering(|name| { + calls.push(name); + std::future::ready(offering_response(b"private response containing a token or URL")) + }).await.unwrap_err(); + assert_eq!(calls, OFFERINGS); + assert_eq!(error, "gssv-login xgpuweb:gssv-json xgpuwebf2p:gssv-json"); + } + + async fn http_fixture(status: u16, body: Vec) -> (String, tokio::task::JoinHandle<()>) { + use tokio::{io::{AsyncReadExt, AsyncWriteExt}, net::TcpListener}; + let server = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = server.local_addr().unwrap(); + let task = tokio::spawn(async move { + let (mut socket, _) = server.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buffer = [0u8; 4096]; + loop { + let n = socket.read(&mut buffer).await.unwrap(); + assert!(n > 0 && request.len() + n < 16384); + request.extend_from_slice(&buffer[..n]); + if let Some(end) = request.windows(4).position(|part| part == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&request[..end]).to_ascii_lowercase(); + let length: usize = headers.lines().find_map(|line| line.strip_prefix("content-length:").map(str::trim)).unwrap_or("0").parse().unwrap(); + if request.len() >= end + 4 + length { break; } + } + } + let header = format!("HTTP/1.1 {status} Fixture\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); + if socket.write_all(header.as_bytes()).await.is_ok() { let _ = socket.write_all(&body).await; } + }); + (format!("http://{address}/login"), task) + } + + #[tokio::test] + async fn cloud_fallback_http_refusal_or_oversized_body_reaches_second_offering() { + let client = reqwest::Client::builder().no_proxy().timeout(Duration::from_secs(15)).build().unwrap(); + for (status, body) in [(403, b"Forbidden".to_vec()), (200, vec![b'x'; 256 * 1024 + 1])] { + let (first, first_task) = http_fixture(status, body).await; + let (second, second_task) = http_fixture(200, br#"{"gsToken":"synthetic"}"#.to_vec()).await; + let (offering, _) = select_offering(|name| request_offering(&client, + if name == "xgpuweb" { &first } else { &second }, "synthetic-xsts", name)).await.unwrap(); + assert_eq!(offering, "xgpuwebf2p"); + first_task.await.unwrap(); second_task.await.unwrap(); + } + } + +}