Skip to content

Commit

Permalink
feat: Add auth_titlehub example
Browse files Browse the repository at this point in the history
  • Loading branch information
tuxuser committed Oct 4, 2024
1 parent ed8f937 commit a0ac621
Show file tree
Hide file tree
Showing 2 changed files with 248 additions and 0 deletions.
3 changes: 3 additions & 0 deletions examples/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ name = "auth_minecraft"
[[bin]]
name = "auth_halo"

[[bin]]
name = "auth_titlehub"

[[bin]]
name = "xbl_signed_request"

Expand Down
245 changes: 245 additions & 0 deletions examples/src/bin/auth_titlehub.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
//! Download savegames for a specific title
//!
use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;

use async_trait::async_trait;
use reqwest::Url;
use serde::Deserialize;
use tokio::{io::{AsyncReadExt, AsyncWriteExt}, net::TcpListener};
use xal::client_params::CLIENT_WINDOWS;
use xal::cvlib::CorrelationVector;
use xal::oauth2::{RedirectUrl, Scope};
use xal::{
AccessTokenPrefix, AuthPromptCallback, AuthPromptData, Error, RequestSigner, XalAppParameters
};
use xal::extensions::CorrelationVectorReqwestBuilder;
use xal::extensions::SigningReqwestBuilder;
use xal::extensions::JsonExDeserializeMiddleware;
use xal::extensions::LoggingReqwestRequestHandler;
use xal::extensions::LoggingReqwestResponseHandler;
use xal_examples::auth_main;

#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct PagingInfo {
pub total_items: usize,
pub continuation_token: Option<String>,
}

#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct BlobMetadata {
pub file_name: String,
pub display_name: String,
pub etag: String,
pub client_file_time: String,
pub size: usize
}

#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct BlobsResponse {
pub blobs: Vec<BlobMetadata>,
pub paging_info: PagingInfo,
}

#[derive(Deserialize, Debug)]
pub struct SavegameAtoms {
pub atoms: HashMap<String,String>,
}

// Replace with your own Azure Client parameters
const CLIENT_ID: &'static str = "388ea51c-0b25-4029-aae2-17df49d23905";
const REDIRECT_URL: &'static str = "http://localhost:8080/auth/callback";
const CLIENT_SECRET: Option<&'static str> = None;

pub struct HttpCallbackHandler {
bind_host: String,
redirect_url_base: String,
}

#[async_trait]
impl AuthPromptCallback for HttpCallbackHandler {
async fn call(
&self,
cb_data: AuthPromptData,
) -> Result<Option<Url>, Box<dyn std::error::Error>> {
let prompt = cb_data.prompt();
println!("{prompt}\n");

let listener = TcpListener::bind(&self.bind_host).await?;
println!("HTTP Server listening, waiting for connection...");

let (mut socket, addr) = listener.accept().await?;
println!("Connection received from {addr:?}");

let mut buf = [0u8; 1024];

if socket.read(&mut buf).await? == 0 {
return Err("Failed reading http request".into());
}

socket.write_all(b"HTTP/1.1 200 OK\n\r\n\r").await?;

let http_req = std::str::from_utf8(&buf)?;
println!("HTTP REQ: {http_req}");

let path = http_req.split(' ').nth(1).unwrap();
println!("Path: {path}");

Ok(Some(Url::parse(&format!(
"{}{}",
self.redirect_url_base, path
))?))
}
}

#[tokio::main]
async fn main() -> Result<(), Error> {
eprintln!("NOTE: --flow authorization-code required!");
let ts = auth_main(
XalAppParameters {
client_id: CLIENT_ID.into(),
title_id: None,
auth_scopes: vec![
Scope::new("Xboxlive.signin".into()),
Scope::new("Xboxlive.offline_access".into()),
],
redirect_uri: Some(RedirectUrl::new(REDIRECT_URL.into()).unwrap()),
client_secret: CLIENT_SECRET.map(|x| x.to_string()),
},
CLIENT_WINDOWS(),
"RETAIL".into(),
AccessTokenPrefix::D,
HttpCallbackHandler {
bind_host: "127.0.0.1:8080".into(),
redirect_url_base: "http://localhost:8080".into(),
},
)
.await?;

let xsts_token = ts
.authorization_token
.ok_or(Error::GeneralError("No XSTS token was acquired".into()))?;
xsts_token.check_validity()?;

let xuid = xsts_token
.clone()
.display_claims
.ok_or(Error::GeneralError("No DisplayClaims".into()))?
.xui
.first()
.ok_or(Error::GeneralError("No xui node".into()))?
.get("xid")
.ok_or(Error::GeneralError("No X(U)ID".into()))?
.to_owned();

// Create new instances of Correlation vector and request signer
let mut cv = CorrelationVector::new();
let mut signer = RequestSigner::new();

let client = reqwest::Client::new();

let pfn = "Microsoft.ProjectSpark-Dakota_8wekyb3d8bbwe";
let scid = "d3d00100-7976-472f-a3f7-bc1760d19e14";

let mut target_dir = PathBuf::new();
target_dir.push(&pfn);
target_dir.push(&xuid);

if !target_dir.exists() {
std::fs::create_dir_all(&target_dir)?;
}

let metadata = client
.get(format!("https://titlestorage.xboxlive.com/connectedstorage/users/xuid({xuid})/scids/{scid}"))
.header("x-xbl-contract-version", "107")
.header("x-xbl-pfn", pfn)
.header("Accept-Language", "en-US")
.header("Authorization", xsts_token.authorization_header_value())
.add_cv(&mut cv)?
.sign(&mut signer, None)
.await?
.log()
.await?
.send()
.await?
.log()
.await?
.json_ex::<BlobsResponse>()
.await?;

println!("metadata: {metadata:?}");

println!("Found {} blobs", metadata.blobs.len());
for blob in metadata.blobs {
println!("Fetching blob: {blob:?}");
let atoms = client
.get(format!("https://titlestorage.xboxlive.com/connectedstorage/users/xuid({xuid})/scids/{scid}/{}", blob.file_name))
.header("x-xbl-contract-version", "107")
.header("x-xbl-pfn", pfn)
.header("Accept-Language", "en-US")
.header("Authorization", xsts_token.authorization_header_value())
.add_cv(&mut cv)?
.sign(&mut signer, None)
.await?
.log()
.await?
.send()
.await?
.log()
.await?
.json_ex::<SavegameAtoms>()
.await?;

println!("{atoms:?}");

println!("* Found {} atoms", atoms.atoms.len());
for (key, value) in atoms.atoms.iter() {
println!("Fetching atom {key} -> {value}");
let filedata = client
.get(format!("https://titlestorage.xboxlive.com/connectedstorage/users/xuid({xuid})/scids/{scid}/{value}"))
.header("x-xbl-contract-version", "107")
.header("x-xbl-pfn", pfn)
.header("Accept-Language", "en-US")
.header("Authorization", xsts_token.authorization_header_value())
.add_cv(&mut cv)?
.sign(&mut signer, None)
.await?
.log()
.await?
.send()
.await?
.log()
.await?
.bytes()
.await?;

let filepath = target_dir.join(&value);
let mut filehandle = std::fs::File::create(filepath)?;
filehandle.write_all(&filedata)?;
}
}
Ok(())
}


#[cfg(test)]
mod tests
{
use super::*;

#[test]
fn deserialize_blob_response() {
let data = r#"{"blobs":[{"fileName":"save_container,savedgame","displayName":"Save Data","etag":"\"0x8DCA185D3F40E2A\"","clientFileTime":"2024-07-11T08:45:22.5700000Z","size":6745}],"pagingInfo":{"totalItems":1,"continuationToken":null}}"#;
let _: BlobsResponse = serde_json::from_str(data).expect("Failed to deserialize");
}

#[test]
fn deserialize_savegame_atoms() {
let data = r#"{"atoms":{"save_data":"296FB351-5F1E-4CCB-8B2B-533C23BC19EB,binary"}}"#;
let _: SavegameAtoms = serde_json::from_str(data).expect("Failed to deserialize");
}
}

0 comments on commit a0ac621

Please sign in to comment.