From a49c793cdc39a6f4596ed81744897b42295a606f Mon Sep 17 00:00:00 2001 From: tuxuser <462620+tuxuser@users.noreply.github.com> Date: Sun, 13 Nov 2022 04:40:00 +0100 Subject: [PATCH 01/13] Initial code import --- Cargo.toml | 36 ++- src/app_params.rs | 152 +++++++++++++ src/authenticator.rs | 401 +++++++++++++++++++++++++++++++++ src/bin/auth-cli.rs | 146 ++++++++++++ src/bin/auth-webview.rs | 200 +++++++++++++++++ src/lib.rs | 20 +- src/models.rs | 338 ++++++++++++++++++++++++++++ src/request_signer.rs | 481 ++++++++++++++++++++++++++++++++++++++++ src/utils.rs | 32 +++ 9 files changed, 1792 insertions(+), 14 deletions(-) create mode 100644 src/app_params.rs create mode 100644 src/authenticator.rs create mode 100644 src/bin/auth-cli.rs create mode 100644 src/bin/auth-webview.rs create mode 100644 src/models.rs create mode 100644 src/request_signer.rs create mode 100644 src/utils.rs diff --git a/Cargo.toml b/Cargo.toml index ce5a6b5..3f44d5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,44 @@ [package] name = "xal" version = "0.1.0" -edition = "2021" +edition = "2018" description = "Xbox Authentication library" license = "MIT" repository = "https://github.com/OpenXbox/xal-rs" homepage = "https://openxbox.org" +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + [dependencies] +reqwest = { version = "0.11", features = ["json"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +cvlib = "0.1.2" +filetime_type = "0.1" +base64 = "0.13.0" +chrono = "0.4" +josekit = "0.8" +uuid = { version = "1", features = ["v4"] } +oauth2 = "4.3" + +# common for bins +tokio = { version = "1", features = ["full"], optional = true } + +# auth_webview +tauri = { version = "1.1.1", optional = true } +wry = { version = "0.21.1", optional = true } + +[dev-dependencies] +hex-literal = "0.3.4" + +[features] +webview = ["dep:tauri", "dep:wry"] +tokio = ["dep:tokio"] + +[[bin]] +name = "auth-cli" +required-features = ["tokio"] + +[[bin]] +name = "auth-webview" +required-features = ["webview"] diff --git a/src/app_params.rs b/src/app_params.rs new file mode 100644 index 0000000..7d10cbc --- /dev/null +++ b/src/app_params.rs @@ -0,0 +1,152 @@ +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] +pub enum DeviceType { + IOS, + ANDROID, + WIN32, +} + +impl FromStr for DeviceType { + type Err = Box; + + fn from_str(s: &str) -> Result { + let enm = match s.to_lowercase().as_ref() { + "android" => DeviceType::ANDROID, + "ios" => DeviceType::IOS, + "win32" => DeviceType::WIN32, + val => { + return Err(format!("Unhandled device type: '{}'", val).into()); + } + }; + Ok(enm) + } +} + +impl ToString for DeviceType { + fn to_string(&self) -> String { + let str = match self { + DeviceType::ANDROID => "Android", + DeviceType::IOS => "iOS", + DeviceType::WIN32 => "Win32", + }; + str.to_owned() + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct XalAppParameters { + pub app_id: String, + pub title_id: String, + pub redirect_uri: String, +} + +impl XalAppParameters { + pub fn xbox_app_beta() -> Self { + Self { + app_id: "000000004415494b".into(), + title_id: "177887386".into(), + redirect_uri: "ms-xal-000000004415494b://auth".into(), + } + } + + pub fn xbox_app() -> Self { + Self { + app_id: "000000004c12ae6f".into(), + title_id: "328178078".into(), + redirect_uri: "ms-xal-000000004c12ae6f://auth".into(), + } + } + + pub fn gamepass() -> Self { + Self { + app_id: "000000004c20a908".into(), + title_id: "1016898439".into(), + redirect_uri: "ms-xal-000000004c20a908://auth".into(), + } + } + + pub fn gamepass_beta() -> Self { + Self { + app_id: "000000004c20a908".into(), + title_id: "1016898439".into(), + redirect_uri: "ms-xal-public-beta-000000004c20a908://auth".into(), + } + } + + /// Family settings is somewhat special + /// Uses default oauth20_desktop.srf redirect uri + pub fn family_settings() -> Self { + Self { + app_id: "00000000482C8F49".into(), + title_id: "1618633878".into(), + redirect_uri: "https://login.live.com/oauth20_desktop.srf".into(), + } + } +} + +impl Default for XalAppParameters { + fn default() -> Self { + Self::gamepass_beta() + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct XalClientParameters { + pub user_agent: String, + pub device_type: DeviceType, + pub client_version: String, + pub query_display: String, +} + +impl XalClientParameters { + pub fn ios() -> Self { + Self { + user_agent: "XAL iOS 2021.11.20211021.000".into(), + device_type: DeviceType::IOS, + client_version: "15.6.1".into(), + query_display: "ios_phone".into(), + } + } + + pub fn android() -> Self { + Self { + user_agent: "XAL Android 2020.07.20200714.000".into(), + device_type: DeviceType::ANDROID, + client_version: "8.0.0".into(), + query_display: "android_phone".into(), + } + } +} + +impl Default for XalClientParameters { + fn default() -> Self { + Self::android() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn devicetype_enum_into() { + assert_eq!(DeviceType::WIN32.to_string(), "Win32"); + assert_eq!(DeviceType::ANDROID.to_string(), "Android"); + assert_eq!(DeviceType::IOS.to_string(), "iOS"); + } + + #[test] + fn str_into_devicetype_enum() { + assert_eq!(DeviceType::from_str("win32").unwrap(), DeviceType::WIN32); + assert_eq!(DeviceType::from_str("Win32").unwrap(), DeviceType::WIN32); + assert_eq!(DeviceType::from_str("WIN32").unwrap(), DeviceType::WIN32); + assert_eq!( + DeviceType::from_str("android").unwrap(), + DeviceType::ANDROID + ); + assert_eq!(DeviceType::from_str("ios").unwrap(), DeviceType::IOS); + assert!(DeviceType::from_str("androidx").is_err()); + } +} diff --git a/src/authenticator.rs b/src/authenticator.rs new file mode 100644 index 0000000..b2b3527 --- /dev/null +++ b/src/authenticator.rs @@ -0,0 +1,401 @@ +use crate::app_params::XalAppParameters; + +use super::{ + app_params::{DeviceType, XalClientParameters}, + models::request, + models::response, + request_signer::{self, SigningReqwestBuilder}, +}; +use base64; +use cvlib; +use oauth2::{ + basic::{ + BasicErrorResponse, BasicRevocationErrorResponse, BasicTokenIntrospectionResponse, + BasicTokenType, + }, + reqwest::async_http_client, + url, AccessToken, AuthType, AuthUrl, AuthorizationCode, Client as OAuthClient, ClientId, + EmptyExtraTokenFields, ExtraTokenFields, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, + RefreshToken, Scope, StandardRevocableToken, TokenResponse, TokenType, TokenUrl, +}; +use reqwest; +use std::time::Duration; +use url::Url; +use uuid; + +type Error = Box; +type Result = std::result::Result; + +pub type SpecialTokenResponse = response::WindowsLiveTokenResponse; +type SpecialClient = OAuthClient< + BasicErrorResponse, + SpecialTokenResponse, + BasicTokenType, + BasicTokenIntrospectionResponse, + StandardRevocableToken, + BasicRevocationErrorResponse, +>; + +impl TokenResponse for response::WindowsLiveTokenResponse +where + EF: ExtraTokenFields, + BasicTokenType: TokenType, +{ + /// + /// REQUIRED. The access token issued by the authorization server. + /// + fn access_token(&self) -> &AccessToken { + &self.access_token + } + /// + /// REQUIRED. The type of the token issued as described in + /// [Section 7.1](https://tools.ietf.org/html/rfc6749#section-7.1). + /// Value is case insensitive and deserialized to the generic `TokenType` parameter. + /// But in this particular case as the service is non compliant, it has a default value + /// + fn token_type(&self) -> &BasicTokenType { + match &self.token_type { + Some(t) => t, + None => &BasicTokenType::Bearer, + } + } + /// + /// RECOMMENDED. The lifetime in seconds of the access token. For example, the value 3600 + /// denotes that the access token will expire in one hour from the time the response was + /// generated. If omitted, the authorization server SHOULD provide the expiration time via + /// other means or document the default value. + /// + fn expires_in(&self) -> Option { + self.expires_in.map(Duration::from_secs) + } + /// + /// OPTIONAL. The refresh token, which can be used to obtain new access tokens using the same + /// authorization grant as described in + /// [Section 6](https://tools.ietf.org/html/rfc6749#section-6). + /// + fn refresh_token(&self) -> Option<&RefreshToken> { + self.refresh_token.as_ref() + } + /// + /// OPTIONAL, if identical to the scope requested by the client; otherwise, REQUIRED. The + /// scipe of the access token as described by + /// [Section 3.3](https://tools.ietf.org/html/rfc6749#section-3.3). If included in the response, + /// this space-delimited field is parsed into a `Vec` of individual scopes. If omitted from + /// the response, this field is `None`. + /// + fn scopes(&self) -> Option<&Vec> { + self.scopes.as_ref() + } +} + +#[derive(Debug)] +pub struct XalAuthenticator { + device_id: uuid::Uuid, + app_params: XalAppParameters, + client_params: XalClientParameters, + ms_cv: cvlib::CorrelationVector, + client: reqwest::Client, + client2: SpecialClient, + request_signer: request_signer::RequestSigner, +} + +impl Default for XalAuthenticator { + fn default() -> Self { + let client_params = XalClientParameters::default(); + let app_params = XalAppParameters::default(); + let client_id = ClientId::new(app_params.app_id.clone()); + let client_secret = None; + + let auth_url = AuthUrl::new("https://login.live.com/oauth20_authorize.srf".into()) + .expect("Invalid authorization endpoint URL"); + let token_url = TokenUrl::new("https://login.live.com/oauth20_token.srf".into()) + .expect("Invalid token endpoint URL"); + let redirect_url = + RedirectUrl::new(app_params.redirect_uri.clone()).expect("Invalid redirect URL"); + + let client2 = OAuthClient::new(client_id, client_secret, auth_url, Some(token_url)) + .set_auth_type(AuthType::RequestBody) + .set_redirect_uri(redirect_url); + + Self { + device_id: uuid::Uuid::new_v4(), + app_params, + client_params, + ms_cv: cvlib::CorrelationVector::new(), + client: reqwest::Client::new(), + client2, + request_signer: request_signer::RequestSigner::default(), + } + } +} + +impl XalAuthenticator { + pub fn get_code_challenge() -> (PkceCodeChallenge, PkceCodeVerifier) { + PkceCodeChallenge::new_random_sha256() + } + + pub fn generate_random_state() -> String { + let state = uuid::Uuid::new_v4().hyphenated().to_string(); + + base64::encode(state) + } +} + +impl XalAuthenticator { + pub fn app_params(&self) -> XalAppParameters { + self.app_params.clone() + } + + pub fn client_params(&self) -> XalClientParameters { + self.client_params.clone() + } + + pub fn get_redirect_uri(&self) -> Url { + self.client2.redirect_url().unwrap().url().to_owned() + } + + fn next_cv(&mut self) -> String { + self.ms_cv.increment(); + self.ms_cv.to_string() + } + + pub async fn exchange_code_for_token( + &mut self, + authorization_code: &str, + code_verifier: PkceCodeVerifier, + ) -> Result { + let code = AuthorizationCode::new(authorization_code.into()); + let token = self + .client2 + .exchange_code(code) + .set_pkce_verifier(code_verifier) + .add_extra_param("scope", "service::user.auth.xboxlive.com::MBI_SSL") + .request_async(async_http_client) + .await?; + + Ok(token) + } + + pub async fn exchange_refresh_token_for_xcloud_transfer_token( + &mut self, + refresh_token: &RefreshToken, + ) -> Result { + let form_body = request::WindowsLiveTokenRequest { + client_id: &self.app_params.app_id.clone(), + grant_type: "refresh_token", + scope: + "service::http://Passport.NET/purpose::PURPOSE_XBOX_CLOUD_CONSOLE_TRANSFER_TOKEN", + refresh_token: Some(refresh_token.secret()), + code: None, + code_verifier: None, + redirect_uri: None, + }; + + self.client + .post("https://login.live.com/oauth20_token.srf") + .header("MS-CV", self.next_cv()) + .form(&form_body) + .send() + .await? + .json::() + .await + .map_err(|e| e.into()) + } + + pub async fn refresh_token( + &mut self, + refresh_token: &RefreshToken, + ) -> Result { + let token = self + .client2 + .exchange_refresh_token(refresh_token) + .add_scope(Scope::new( + "service::user.auth.xboxlive.com::MBI_SSL".into(), + )) + .request_async(async_http_client) + .await?; + + Ok(token) + } +} + +impl XalAuthenticator { + pub async fn get_endpoints(&self) -> Result { + let resp = self + .client + .get("https://title.mgt.xboxlive.com/titles/default/endpoints") + .header("x-xbl-contract-version", "1") + .query(&[("type", 1)]) + .send() + .await? + .json::() + .await?; + + Ok(resp) + } + + pub async fn get_device_token(&mut self) -> Result { + let client_uuid: String = match self.client_params.device_type { + // {decf45e4-945d-4379-b708-d4ee92c12d99} + DeviceType::ANDROID => [ + "{".to_string(), + self.device_id.hyphenated().to_string(), + "}".to_string(), + ] + .concat(), + + // DECF45E4-945D-4379-B708-D4EE92C12D99 + DeviceType::IOS => self.device_id.hyphenated().to_string().to_uppercase(), + // Unknown + _ => self.device_id.hyphenated().to_string(), + }; + + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("x-xbl-contract-version", "1".parse()?); + headers.insert("MS-CV", self.next_cv().parse()?); + + let json_body = request::XADRequest { + relying_party: "http://auth.xboxlive.com", + token_type: "JWT", + properties: request::XADProperties { + auth_method: "ProofOfPossession", + id: client_uuid.as_str(), + device_type: &self.client_params.device_type.to_string(), + version: &self.client_params.client_version, + proof_key: self.request_signer.get_proof_key(), + }, + }; + + self.client + .post("https://device.auth.xboxlive.com/device/authenticate") + .headers(headers) + .json(&json_body) + .sign(&self.request_signer, None)? + .send() + .await? + .json::() + .await + .map_err(|e| e.into()) + } + + /// Sisu authentication + /// Returns tuple: + /// 1. Part: Response that contains authorization URL + /// 2. Part: Session ID from response headers (X-SessionId) + pub async fn do_sisu_authentication( + &mut self, + device_token: &str, + code_challenge: PkceCodeChallenge, + state: &str, + ) -> Result<(response::SisuAuthenticationResponse, String)> { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("x-xbl-contract-version", "1".parse()?); + headers.insert("MS-CV", self.next_cv().parse()?); + + let json_body = request::SisuAuthenticationRequest { + app_id: &self.app_params.app_id, + title_id: &self.app_params.title_id, + redirect_uri: &self.app_params.redirect_uri, + device_token, + sandbox: "RETAIL", + token_type: "code", + offers: vec!["service::user.auth.xboxlive.com::MBI_SSL"], + query: request::SisuQuery { + display: &self.client_params.query_display, + code_challenge: code_challenge.as_str(), + code_challenge_method: code_challenge.method(), + state, + }, + }; + + let resp = self + .client + .post("https://sisu.xboxlive.com/authenticate") + .headers(headers) + .json(&json_body) + .sign(&self.request_signer, None)? + .send() + .await?; + + let session_id = resp + .headers() + .get("X-SessionId") + .ok_or("Failed to fetch session id")? + .to_str()? + .to_owned(); + + let resp_json = resp.json::().await?; + + Ok((resp_json, session_id)) + } + + pub async fn do_sisu_authorization( + &mut self, + sisu_session_id: &str, + access_token: &str, + device_token: &str, + ) -> Result { + let json_body = request::SisuAuthorizationRequest { + access_token: &format!("t={}", access_token), + app_id: &self.app_params.app_id.clone(), + device_token, + sandbox: "RETAIL", + site_name: "user.auth.xboxlive.com", + session_id: sisu_session_id, + proof_key: self.request_signer.get_proof_key(), + }; + + self.client + .post("https://sisu.xboxlive.com/authorize") + .header("MS-CV", self.next_cv()) + .json(&json_body) + .sign(&self.request_signer, None)? + .send() + .await? + .json::() + .await + .map_err(|e| e.into()) + } + + pub async fn do_xsts_authorization( + &mut self, + device_token: &str, + title_token: &str, + user_token: &str, + relying_party: &str, + ) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("x-xbl-contract-version", "1".parse()?); + headers.insert("MS-CV", self.next_cv().parse()?); + + let json_body = request::XSTSRequest { + relying_party, + token_type: "JWT", + properties: request::XSTSProperties { + sandbox_id: "RETAIL", + device_token, + title_token, + user_tokens: vec![user_token], + }, + }; + + self.client + .post("https://xsts.auth.xboxlive.com/xsts/authorize") + .headers(headers) + .json(&json_body) + .sign(&self.request_signer, None)? + .send() + .await? + .json::() + .await + .map_err(|e| e.into()) + } +} + +#[cfg(test)] +mod test { + #[test] + fn test() { + assert_eq!(true, true); + } +} diff --git a/src/bin/auth-cli.rs b/src/bin/auth-cli.rs new file mode 100644 index 0000000..f66b55a --- /dev/null +++ b/src/bin/auth-cli.rs @@ -0,0 +1,146 @@ +use chrono::Utc; +use reqwest::Url; +use std::io; +use xal::authenticator::XalAuthenticator; +use xal::oauth2::PkceCodeVerifier; +use xal::utils::TokenStore; + +const TOKENS_FILEPATH: &str = "tokens.json"; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut xal = XalAuthenticator::default(); + + if let Ok(mut ts) = TokenStore::load(TOKENS_FILEPATH) { + let refreshed_xcoud = xal + .exchange_refresh_token_for_xcloud_transfer_token(&ts.xcloud_transfer_token.into()) + .await?; + println!("{:?}", refreshed_xcoud); + + ts.xcloud_transfer_token = refreshed_xcoud; + ts.updated = Utc::now(); + ts.save(TOKENS_FILEPATH)?; + + return Ok(()); + } + + let (code_challenge, code_verifier) = XalAuthenticator::get_code_challenge(); + + println!("Getting device token..."); + let device_token = xal.get_device_token().await?; + println!("Device token={:?}", device_token); + + let state = XalAuthenticator::generate_random_state(); + + println!("Fetching SISU authentication URL..."); + let (sisu_response, sisu_session_id) = xal + .do_sisu_authentication(&device_token.token_data.token, code_challenge, &state) + .await?; + + println!( + r#"!!! ACTION REQUIRED !!! +Navigate to this URL and authenticate: {} +When finished, paste the Redirect URL and hit [ENTER]"#, + sisu_response.msa_oauth_redirect + ); + + let mut redirect_uri = String::new(); + let _ = io::stdin().read_line(&mut redirect_uri)?; + + // Check if redirect URI has expected scheme + println!("Checking redirect URI..."); + let expected_scheme = xal.get_redirect_uri().scheme().to_owned(); + if !redirect_uri.starts_with(&expected_scheme) { + return Err(format!( + "Invalid redirect URL, expecting scheme: {}", + expected_scheme + ) + .into()); + } + + // Parse redirect URI + let parsed_url = Url::parse(&redirect_uri)?; + // Extract query parameters {code, state} + let mut code_query: Option = None; + let mut state_query: Option = None; + + for i in parsed_url.query_pairs() { + if i.0 == "code" { + code_query = Some(i.1.into_owned()) + } else if i.0 == "state" { + state_query = Some(i.1.into_owned()) + } + } + + println!("Verifying state..."); + if let Some(returned_state) = &state_query { + let valid_state = &state == returned_state; + println!( + "State valid: {} ({} vs. {})", + valid_state, state, returned_state + ); + } else { + println!("WARN: No state query returned!"); + } + + if let Some(authorization_code) = code_query { + println!("Authorization Code: {}", &authorization_code); + let local_code_verifier = PkceCodeVerifier::new(code_verifier.secret().clone()); + + println!("Getting WL tokens..."); + let wl_token = xal + .exchange_code_for_token(&authorization_code, local_code_verifier) + .await + .expect("Failed exchanging code for token"); + let wl_token_clone = wl_token.clone(); + println!("WL={:?}", wl_token); + + println!("Attempting SISU authorization..."); + let auth_response = xal + .do_sisu_authorization( + &sisu_session_id, + wl_token.access_token.secret(), + &device_token.token_data.token, + ) + .await?; + println!("SISU={:?}", auth_response); + + println!("Getting GSSV token..."); + // Fetch GSSV (gamestreaming) token + let gssv_token = xal + .do_xsts_authorization( + &auth_response.device_token, + &auth_response.title_token.token_data.token, + &auth_response.user_token.token_data.token, + "http://gssv.xboxlive.com/", + ) + .await?; + println!("GSSV={:?}", gssv_token); + + println!("Getting XCloud transfer token..."); + // Fetch XCloud transfer token + let transfer_token = xal + .exchange_refresh_token_for_xcloud_transfer_token( + &wl_token + .refresh_token + .expect("Failed to unwrap refresh token"), + ) + .await?; + println!("Transfer token={:?}", transfer_token); + + let ts = TokenStore { + app_params: xal.app_params(), + client_params: xal.client_params(), + wl_token: wl_token_clone, + sisu_tokens: auth_response, + gssv_token, + xcloud_transfer_token: transfer_token, + updated: Utc::now(), + }; + ts.save(TOKENS_FILEPATH)? + } else { + println!("No authorization code fetched :("); + } + + Ok(()) +} diff --git a/src/bin/auth-webview.rs b/src/bin/auth-webview.rs new file mode 100644 index 0000000..52f59a0 --- /dev/null +++ b/src/bin/auth-webview.rs @@ -0,0 +1,200 @@ +// Copyright 2020-2022 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use chrono::Utc; +use tauri::async_runtime; +use wry::{ + application::{ + event::{Event, StartCause, WindowEvent}, + event_loop::{ControlFlow, EventLoop}, + window::WindowBuilder, + }, + webview::{Url, WebViewBuilder}, +}; +use xal::oauth2::PkceCodeVerifier; +use xal::{authenticator::XalAuthenticator, utils::TokenStore}; + +const TOKENS_FILEPATH: &str = "tokens.json"; + +async fn continue_auth( + xal: &mut XalAuthenticator, + code_verifier: &PkceCodeVerifier, + authorization_code: &str, + sisu_session_id: &str, + device_token: &str, +) -> std::result::Result<(), Box> { + println!("Authorization Code: {}", &authorization_code); + let local_code_verifier = PkceCodeVerifier::new(code_verifier.secret().clone()); + let wl_token = xal + .exchange_code_for_token(authorization_code, local_code_verifier) + .await + .expect("Failed exchanging code for token"); + let wl_token_clone = wl_token.clone(); + println!("WL={:?}", wl_token); + + let auth_response = xal + .do_sisu_authorization( + sisu_session_id, + wl_token.access_token.secret(), + device_token, + ) + .await?; + println!("SISU={:?}", auth_response); + + // Fetch GSSV (gamestreaming) token + let gssv_token = xal + .do_xsts_authorization( + &auth_response.device_token, + &auth_response.title_token.token_data.token, + &auth_response.user_token.token_data.token, + "http://gssv.xboxlive.com/", + ) + .await?; + println!("GSSV={:?}", gssv_token); + + // Fetch XCloud transfer token + let transfer_token = xal + .exchange_refresh_token_for_xcloud_transfer_token( + &wl_token + .refresh_token + .expect("Failed to unwrap refresh token"), + ) + .await?; + println!("Transfer token={:?}", transfer_token); + + let ts = TokenStore { + app_params: xal.app_params(), + client_params: xal.client_params(), + wl_token: wl_token_clone, + sisu_tokens: auth_response, + gssv_token, + xcloud_transfer_token: transfer_token, + updated: Utc::now(), + }; + ts.save(TOKENS_FILEPATH) +} + +enum UserEvent { + Navigation(String), +} + +fn main() -> wry::Result<()> { + let mut xal = XalAuthenticator::default(); + + if let Ok(mut ts) = TokenStore::load(TOKENS_FILEPATH) { + let refreshed_xcoud = async_runtime::block_on( + xal.exchange_refresh_token_for_xcloud_transfer_token(&ts.xcloud_transfer_token.into()), + ) + .expect("Failed to exchange refresh token for fresh XCloud transfer token"); + + println!("{:?}", refreshed_xcoud); + ts.xcloud_transfer_token = refreshed_xcoud; + ts.updated = Utc::now(); + ts.save(TOKENS_FILEPATH) + .expect("Failed to save refreshed XCloud token"); + + return Ok(()); + } + + let (code_challenge, code_verifier) = XalAuthenticator::get_code_challenge(); + let device_token = + async_runtime::block_on(xal.get_device_token()).expect("Failed to fetch device token"); + + println!("Device token={:?}", device_token); + + let state = XalAuthenticator::generate_random_state(); + + let (sisu_response, sisu_session_id) = async_runtime::block_on(xal.do_sisu_authentication( + &device_token.token_data.token, + code_challenge, + &state, + )) + .unwrap(); + + let redirect_uri = xal.get_redirect_uri(); + let auth_url = sisu_response.msa_oauth_redirect; + + let event_loop: EventLoop = EventLoop::with_user_event(); + let proxy = event_loop.create_proxy(); + let window = WindowBuilder::new() + .with_title("Hello World") + .build(&event_loop) + .unwrap(); + + let webview = WebViewBuilder::new(window) + .unwrap() + // tell the webview to load the custom protocol + .with_url(&auth_url)? + .with_devtools(true) + .with_navigation_handler(move |uri: String| { + proxy.send_event(UserEvent::Navigation(uri)).is_ok() + }) + .build()?; + + event_loop.run(move |event, _, control_flow| { + *control_flow = ControlFlow::Wait; + + match event { + Event::NewEvents(StartCause::Init) => println!("Wry application started!"), + Event::WindowEvent { + event: WindowEvent::Moved { .. }, + .. + } => { + let _ = webview.evaluate_script("console.log('hello');"); + } + Event::UserEvent(UserEvent::Navigation(uri)) => { + if uri.starts_with(redirect_uri.scheme()) { + let url = Url::parse(&uri).expect("Failed to parse redirect URL"); + + let mut code_query: Option = None; + let mut state_query: Option = None; + + for i in url.query_pairs() { + if i.0 == "code" { + code_query = Some(i.1.into_owned()) + } else if i.0 == "state" { + state_query = Some(i.1.into_owned()) + } + } + + if let Some(returned_state) = &state_query { + let valid_state = &state == returned_state; + println!( + "State valid: {} ({} vs. {})", + valid_state, state, returned_state + ); + } else { + println!("WARN: No state query returned!"); + } + + if let Some(authorization_code) = code_query { + match async_runtime::block_on(continue_auth( + &mut xal, + &code_verifier, + &authorization_code, + &sisu_session_id, + &device_token.token_data.token, + )) { + Ok(_) => { + println!("SISU authentication succeeded! :)"); + } + Err(err) => { + println!("Failed SISU auth :( - details: {}", err); + } + } + } else { + println!("No authorization code fetched :("); + } + + *control_flow = ControlFlow::Exit; + } + } + Event::WindowEvent { + event: WindowEvent::CloseRequested, + .. + } => *control_flow = ControlFlow::Exit, + _ => (), + } + }); +} diff --git a/src/lib.rs b/src/lib.rs index 7d12d9a..a63513a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,8 @@ -pub fn add(left: usize, right: usize) -> usize { - left + right -} +pub use cvlib; +pub use oauth2; -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +pub mod app_params; +pub mod authenticator; +pub mod models; +pub mod request_signer; +pub mod utils; diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..12e3957 --- /dev/null +++ b/src/models.rs @@ -0,0 +1,338 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Serialize, Deserialize, Copy, Clone, PartialEq, Eq)] +pub enum SigningAlgorithm { + ES256, + ES384, + ES521, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct SigningPolicy { + pub version: i32, + pub supported_algorithms: Vec, + pub max_body_bytes: usize, +} + +impl Default for SigningPolicy { + fn default() -> Self { + Self { + version: 1, + supported_algorithms: vec![SigningAlgorithm::ES256], + max_body_bytes: 8192, + } + } +} + +pub mod request { + use josekit::jwk::Jwk; + + use super::{Deserialize, Serialize}; + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct XADProperties<'a> { + pub auth_method: &'a str, + pub id: &'a str, + pub device_type: &'a str, + pub version: &'a str, + pub proof_key: Jwk, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct XADRequest<'a> { + pub relying_party: &'a str, + pub token_type: &'a str, + pub properties: XADProperties<'a>, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct SisuQuery<'a> { + pub display: &'a str, + pub code_challenge: &'a str, + pub code_challenge_method: &'a str, + pub state: &'a str, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct SisuAuthenticationRequest<'a> { + pub app_id: &'a str, + pub title_id: &'a str, + pub redirect_uri: &'a str, + pub device_token: &'a str, + pub sandbox: &'a str, + pub token_type: &'a str, + pub offers: Vec<&'a str>, + pub query: SisuQuery<'a>, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct SisuAuthorizationRequest<'a> { + pub access_token: &'a str, + pub app_id: &'a str, + pub device_token: &'a str, + pub sandbox: &'a str, + pub site_name: &'a str, + pub session_id: &'a str, + pub proof_key: Jwk, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct WindowsLiveTokenRequest<'a> { + pub client_id: &'a str, + pub refresh_token: Option<&'a str>, + pub grant_type: &'a str, + pub scope: &'a str, + pub redirect_uri: Option<&'a str>, + pub code: Option<&'a str>, + pub code_verifier: Option<&'a str>, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct XSTSProperties<'a> { + pub sandbox_id: &'a str, + pub device_token: &'a str, + pub title_token: &'a str, + pub user_tokens: Vec<&'a str>, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct XSTSRequest<'a> { + pub relying_party: &'a str, + pub token_type: &'a str, + pub properties: XSTSProperties<'a>, + } +} + +pub mod response { + use oauth2::{ + basic::BasicTokenType, helpers, AccessToken, ExtraTokenFields, RefreshToken, Scope, + }; + + use super::{Deserialize, HashMap, Serialize, SigningPolicy}; + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct TokenData { + pub issue_instant: String, + pub not_after: String, + pub token: String, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct XADDisplayClaims { + /// {"xdi": {"did": "F.....", "dcs": "0"}} + pub xdi: HashMap, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct XADResponse { + #[serde(flatten)] + pub token_data: TokenData, + pub display_claims: XADDisplayClaims, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct XATDisplayClaims { + pub xti: HashMap, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct XATResponse { + #[serde(flatten)] + pub token_data: TokenData, + pub display_claims: XATDisplayClaims, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct XAUDisplayClaims { + pub xui: Vec>, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct XAUResponse { + #[serde(flatten)] + pub token_data: TokenData, + pub display_claims: XAUDisplayClaims, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct XSTSDisplayClaims { + pub xui: Vec>, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct XSTSResponse { + #[serde(flatten)] + pub token_data: TokenData, + pub display_claims: XSTSDisplayClaims, + } + + impl XSTSResponse { + pub fn userhash(&self) -> String { + self.display_claims.xui[0]["uhs"].clone() + } + pub fn authorization_header_value(&self) -> String { + format!("XBL3.0 x={};{}", self.userhash(), self.token_data.token) + } + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct SisuAuthenticationResponse { + pub msa_oauth_redirect: String, + pub msa_request_parameters: HashMap, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct SisuAuthorizationResponse { + pub device_token: String, + pub title_token: XATResponse, + pub user_token: XAUResponse, + pub authorization_token: XSTSResponse, + pub web_page: String, + pub sandbox: String, + pub use_modern_gamertag: Option, + } + + #[derive(Debug, Serialize, Deserialize, Clone)] + pub struct WindowsLiveTokenResponse { + pub token_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_in: Option, + #[serde(rename = "scope")] + #[serde(deserialize_with = "helpers::deserialize_space_delimited_vec")] + #[serde(serialize_with = "helpers::serialize_space_delimited_vec")] + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub scopes: Option>, + pub access_token: AccessToken, + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, + pub user_id: String, + + #[serde(bound = "EF: ExtraTokenFields")] + #[serde(flatten)] + pub extra_fields: EF, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct XCloudTokenResponse { + pub lpt: String, + pub refresh_token: String, + pub user_id: String, + } + + impl From for RefreshToken { + fn from(t: XCloudTokenResponse) -> Self { + Self::new(t.refresh_token) + } + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct TitleEndpointCertificate { + pub thumbprint: String, + pub is_issuer: Option, + pub root_cert_index: i32, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct TitleEndpointsResponse { + pub end_points: Vec, + pub signature_policies: Vec, + pub certs: Vec, + pub root_certs: Vec, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct TitleEndpoint { + pub protocol: String, + pub host: String, + pub host_type: String, + pub path: Option, + pub relying_party: Option, + pub sub_relying_party: Option, + pub token_type: Option, + pub signature_policy_index: Option, + pub server_cert_index: Option>, + } +} + +#[cfg(test)] +mod test { + use super::{response, SigningAlgorithm, SigningPolicy}; + use serde_json; + + #[test] + fn deserialize_xsts() { + let data = r#" + { + "IssueInstant": "2010-10-10T03:06:35.5251155Z", + "NotAfter": "2999-10-10T19:06:35.5251155Z", + "Token": "123456789", + "DisplayClaims": { + "xui": [ + { + "gtg": "e", + "xid": "2669321029139235", + "uhs": "abcdefg", + "agg": "Adult", + "usr": "", + "utr": "", + "prv": "" + } + ] + } + } + "#; + + let bla: response::XSTSResponse = + serde_json::from_str(data).expect("BUG: Failed to deserialize XSTS response"); + + assert_eq!(bla.userhash(), "abcdefg"); + assert_eq!( + bla.authorization_header_value(), + "XBL3.0 x=abcdefg;123456789" + ); + assert_eq!(bla.token_data.token, "123456789".to_owned()); + assert_eq!(bla.display_claims.xui[0].get("gtg"), Some(&"e".to_owned())); + assert_ne!( + bla.display_claims.xui[0].get("uhs"), + Some(&"invalid".to_owned()) + ); + } + + #[test] + fn deserialize_signing_policy() { + let json_resp = r#"{ + "Version": 99, + "SupportedAlgorithms": ["ES521"], + "MaxBodyBytes": 1234 + }"#; + + let deserialized: SigningPolicy = + serde_json::from_str(json_resp).expect("Failed to deserialize SigningPolicy"); + + assert_eq!(deserialized.version, 99); + assert_eq!(deserialized.max_body_bytes, 1234); + assert_eq!( + deserialized.supported_algorithms, + vec![SigningAlgorithm::ES521] + ) + } +} diff --git a/src/request_signer.rs b/src/request_signer.rs new file mode 100644 index 0000000..536bbb7 --- /dev/null +++ b/src/request_signer.rs @@ -0,0 +1,481 @@ +use crate::models::SigningPolicy; + +use filetime_type::FileTime; +use oauth2::url::Position; +use super::models; +use base64::{self, DecodeError}; +use chrono::prelude::*; +use josekit::{ + self, + jwk::{alg::ec::EcKeyPair, Jwk}, +}; +use reqwest::{self, Method}; +use std::{option::Option, str::FromStr}; + +type Error = Box; +type Result = std::result::Result; + +#[derive(Debug)] +pub struct XboxWebSignatureBytes { + signing_policy_version: Vec, + timestamp: Vec, + signed_digest: Vec, +} + +impl From<&XboxWebSignatureBytes> for Vec { + fn from(obj: &XboxWebSignatureBytes) -> Self { + let mut bytes: Vec = Vec::new(); + bytes.extend_from_slice(obj.signing_policy_version.as_slice()); + bytes.extend_from_slice(obj.timestamp.as_slice()); + bytes.extend_from_slice(obj.signed_digest.as_slice()); + + bytes + } +} + +impl FromStr for XboxWebSignatureBytes { + type Err = DecodeError; + + fn from_str(s: &str) -> std::result::Result { + let bytes = base64::decode(s)?; + Ok(bytes.into()) + } +} +impl From> for XboxWebSignatureBytes { + fn from(bytes: Vec) -> Self { + Self { + signing_policy_version: bytes[..4].to_vec(), + timestamp: bytes[4..12].to_vec(), + signed_digest: bytes[12..].to_vec(), + } + } +} + +impl ToString for XboxWebSignatureBytes { + fn to_string(&self) -> String { + let bytes: Vec = self.into(); + base64::encode(bytes) + } +} + +#[derive(Debug)] +pub struct HttpRequestToSign { + method: String, + path_and_query: String, + authorization: String, + body: Vec, +} + +impl From for HttpRequestToSign { + fn from(request: reqwest::Request) -> Self { + let url = request.url(); + + let auth_header_val = match request.headers().get(reqwest::header::AUTHORIZATION) { + Some(val) => val + .to_str() + .expect("Failed serializing Authentication header to string"), + None => "", + }; + + let body = match *request.method() { + Method::GET => { + vec![] + } + Method::POST => request + .body() + .expect("Failed to get body from HTTP request") + .as_bytes() + .expect("Failed to convert HTTP body to bytes") + .to_vec(), + _ => panic!("Unhandled HTTP method: {:?}", request.method()), + }; + + HttpRequestToSign { + method: request.method().to_string().to_uppercase(), + path_and_query: url[Position::BeforePath..].to_owned(), + authorization: auth_header_val.to_owned(), + body, + } + } +} + +#[derive(Debug)] +pub struct RequestSigner { + pub keypair: EcKeyPair, + pub signing_policy: models::SigningPolicy, +} + +impl Default for RequestSigner { + fn default() -> Self { + Self::new(SigningPolicy::default()) + } +} + +pub trait SigningReqwestBuilder { + fn sign( + self, + signer: &RequestSigner, + timestamp: Option>, + ) -> Result; +} + +impl SigningReqwestBuilder for reqwest::RequestBuilder { + fn sign( + self, + signer: &RequestSigner, + timestamp: Option>, + ) -> Result { + match self.try_clone() { + Some(rb) => { + let request = rb.build()?; + // Fallback to Utc::now() internally + let signed = signer.sign_request(request, timestamp)?; + let body_bytes = signed + .body() + .ok_or("Failed getting request body")? + .as_bytes() + .ok_or("Failed getting bytes from request body")? + .to_vec(); + let headers = signed.headers().to_owned(); + + Ok(self.headers(headers).body(body_bytes)) + } + None => Err("Failed to clone RequestBuilder for signing".into()), + } + } +} + +impl RequestSigner { + pub fn new(policy: models::SigningPolicy) -> Self { + Self { + keypair: josekit::jws::ES256.generate_key_pair().unwrap(), + signing_policy: policy, + } + } + + pub fn get_proof_key(&self) -> Jwk { + let mut jwk = self.keypair.to_jwk_public_key(); + jwk.set_key_use("sig"); + + jwk + } + + pub fn sign_request( + &self, + request: reqwest::Request, + timestamp: Option>, + ) -> Result { + let mut clone_request = request.try_clone().unwrap(); + // Gather data from request used for signing + let to_sign = request.into(); + + // Create signature + let signature = self + .sign( + self.signing_policy.version, + timestamp.unwrap_or_else(Utc::now), + &to_sign, + ) + .expect("Signing request failed!"); + + // Replace request body with byte representation (so signature creation is deterministic) + clone_request.body_mut().replace(to_sign.body.into()); + + // Assign Signature-header in request + clone_request + .headers_mut() + .insert("Signature", signature.to_string().parse()?); + + Ok(clone_request) + } + + /// Sign + pub fn sign( + &self, + signing_policy_version: i32, + timestamp: DateTime, + request: &HttpRequestToSign, + ) -> Result { + self.sign_raw( + signing_policy_version, + timestamp, + request.method.to_owned(), + request.path_and_query.to_owned(), + request.authorization.to_owned(), + &request.body, + ) + } + + fn sign_raw( + &self, + signing_policy_version: i32, + timestamp: DateTime, + method: String, + path_and_query: String, + authorization: String, + body: &[u8], + ) -> Result { + let signer = josekit::jws::ES256.signer_from_jwk(&self.keypair.to_jwk_private_key())?; + + let filetime_bytes = FileTime::from(timestamp).filetime().to_be_bytes(); + let signing_policy_version_bytes = signing_policy_version.to_be_bytes(); + + // Assemble the message to sign + let message = self + .assemble_message_data( + &signing_policy_version_bytes, + &filetime_bytes, + method, + path_and_query, + authorization, + body, + self.signing_policy.max_body_bytes, + ) + .expect("Failed to assemble message data !"); + + // Sign the message + let signed_digest: Vec = signer.sign(&message)?; + + // Return final signature + Ok(XboxWebSignatureBytes { + signing_policy_version: signing_policy_version_bytes.to_vec(), + timestamp: filetime_bytes.to_vec(), + signed_digest, + }) + } + + pub fn verify_request(&self, request: reqwest::Request) -> Result<()> { + let signature = request + .try_clone() + .ok_or("Failed to clone request")? + .headers() + .get("Signature") + .ok_or("Failed to get signature header")? + .to_str()? + .to_owned(); + + self.verify( + XboxWebSignatureBytes::from_str(&signature)?, + &request.into(), + ) + } + + pub fn verify( + &self, + signature: XboxWebSignatureBytes, + request: &HttpRequestToSign, + ) -> Result<()> { + let verifier = josekit::jws::ES256.verifier_from_jwk(&self.keypair.to_jwk_public_key())?; + let message = self.assemble_message_data( + &signature.signing_policy_version, + &signature.timestamp, + request.method.to_owned(), + request.path_and_query.to_owned(), + request.authorization.to_owned(), + &request.body, + self.signing_policy.max_body_bytes, + )?; + verifier + .verify(&message, &signature.signed_digest) + .map_err(|err| err.into()) + } + + #[allow(clippy::too_many_arguments)] + fn assemble_message_data( + &self, + signing_policy_version: &[u8], + timestamp: &[u8], + method: String, + path_and_query: String, + authorization: String, + body: &[u8], + max_body_bytes: usize, + ) -> Result> { + const NULL_BYTE: &[u8; 1] = &[0x00]; + + let mut data = Vec::::new(); + // Signature version + null + data.extend_from_slice(signing_policy_version); + data.extend_from_slice(NULL_BYTE); + + // Timestamp + null + data.extend_from_slice(timestamp); + data.extend_from_slice(NULL_BYTE); + + // Method (uppercase) + null + data.extend_from_slice(method.to_uppercase().as_bytes()); + data.extend_from_slice(NULL_BYTE); + + // Path and query + null + data.extend_from_slice(path_and_query.as_bytes()); + data.extend_from_slice(NULL_BYTE); + + // Authorization (even if an empty string) + data.extend_from_slice(authorization.as_bytes()); + data.extend_from_slice(NULL_BYTE); + + // Body + let body_size_to_hash = std::cmp::min(max_body_bytes, body.len()); + data.extend_from_slice(&body[..body_size_to_hash]); + data.extend_from_slice(NULL_BYTE); + + Ok(data) + } +} + +#[cfg(test)] +mod test { + use std::str::FromStr; + + use super::{ + reqwest, FileTime, HttpRequestToSign, RequestSigner, SigningReqwestBuilder, + XboxWebSignatureBytes, + }; + use chrono::prelude::*; + use hex_literal::hex; + use reqwest::{Body, Client}; + + fn get_request_signer() -> RequestSigner { + const PRIVATE_KEY_PEM: &str = "-----BEGIN EC PRIVATE KEY-----\n + MHcCAQEEIObr5IVtB+DQcn25+R9n4K/EyUUSbVvxIJY7WhVeELUuoAoGCCqGSM49\n + AwEHoUQDQgAEOKyCQ9qH5U4lZcS0c5/LxIyKvOpKe0l3x4Eg5OgDbzezKNLRgT28\n + fd4Fq3rU/1OQKmx6jSq0vTB5Ao/48m0iGg==\n + -----END EC PRIVATE KEY-----\n"; + + RequestSigner { + keypair: josekit::jws::ES256 + .key_pair_from_pem(PRIVATE_KEY_PEM) + .unwrap(), + signing_policy: Default::default(), + } + } + + #[test] + fn sign() { + let signer = get_request_signer(); + let dt = Utc.timestamp_opt(1586999965, 0).unwrap(); + + let request = HttpRequestToSign { + method: "POST".to_owned(), + path_and_query: "/path?query=1".to_owned(), + authorization: "XBL3.0 x=userid;jsonwebtoken".to_owned(), + body: b"thebodygoeshere".to_vec(), + }; + + let signature = signer + .sign_raw( + 1, + dt, + request.method.to_owned(), + request.path_and_query.to_owned(), + request.authorization.to_owned(), + &request.body, + ) + .expect("Signing failed!"); + + signer + .verify(signature, &request) + .expect("Verification failed") + } + + #[test] + fn data_to_hash() { + let signer = get_request_signer(); + let signing_policy_version: i32 = 1; + let ts_bytes = FileTime::from(Utc.timestamp_opt(1586999965, 0).unwrap()).filetime().to_be_bytes(); + + let message_data = signer + .assemble_message_data( + &signing_policy_version.to_be_bytes(), + &ts_bytes, + "POST".to_owned(), + "/path?query=1".to_owned(), + "XBL3.0 x=userid;jsonwebtoken".to_owned(), + "thebodygoeshere".as_bytes(), + 8192, + ) + .expect("Failed to assemble message data"); + + assert_eq!( + message_data, + hex!("000000010001d6138d10f7cc8000504f5354002f706174683f71756572793d310058424c332e3020783d7573657269643b6a736f6e776562746f6b656e00746865626f6479676f65736865726500").to_vec() + ); + } + + #[test] + fn sign_reqwest() { + let signer = get_request_signer(); + let timestamp = Utc.timestamp_opt(1586999965, 0).unwrap(); + + let client = reqwest::Client::new(); + let mut request = client + .post("https://example.com/path") + .query(&[("query", 1)]) + .header( + reqwest::header::AUTHORIZATION, + "XBL3.0 x=userid;jsonwebtoken", + ) + .body("thebodygoeshere") + .build() + .unwrap(); + + request = signer + .sign_request(request, Some(timestamp)) + .expect("FAILED signing request"); + + let signature = request.headers().get("Signature"); + + assert!(signature.is_some()); + assert!(signer.verify_request(request).is_ok()); + } + + #[test] + fn verify_real_request() { + let pem_priv_key = r#"-----BEGIN PRIVATE KEY----- + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgYhW3PQAibijp6X71 + Uua4a45KoHHpQZaUIef+gPeWOu2hRANCAAQYlLUACGI9jDRlJAkMIXyRxmQoBza1 + FZcA3pjD6j+ExFAECR1HP8lSIVEICL6BA95LdCQ8/xvI4F8rP10drPl3 + -----END PRIVATE KEY-----"#; + + let signer = RequestSigner { + keypair: josekit::jws::ES256.key_pair_from_pem(pem_priv_key).unwrap(), + signing_policy: Default::default(), + }; + + let request = HttpRequestToSign { + method: "POST".to_owned(), + path_and_query: "/device/authenticate".to_owned(), + authorization: "".to_owned(), + body: br#"{"RelyingParty":"http://auth.xboxlive.com","TokenType":"JWT","Properties":{"AuthMethod":"ProofOfPossession","Id":"{e51d4344-196a-4550-9e27-f6c5006a9949}","DeviceType":"Android","Version":"8.0.0","ProofKey":{"kty":"EC","alg":"ES256","crv":"P-256","x":"GJS1AAhiPYw0ZSQJDCF8kcZkKAc2tRWXAN6Yw-o_hMQ","y":"UAQJHUc_yVIhUQgIvoED3kt0JDz_G8jgXys_XR2s-Xc","use":"sig"}}}"#.to_vec(), + }; + let signature = XboxWebSignatureBytes::from_str("AAAAAQHY4xgs5DyIujFG5E5MZ4D1xjd9Up+H4AKLoyBHd95MAUZcabUN//Y/gijed4vvKtlfp4Cd4dJzVhpK0m+sYZcYRqQjBEKAZw==") + .expect("Failed to deserialize into XboxWebSignatureBytes"); + + assert!(signer.verify(signature, &request).is_ok()); + } + + #[test] + fn build_signed_get_request() { + let signer = get_request_signer(); + let request = Client::new() + .get("https://example.com") + .sign(&signer, None) + .expect("Failed to sign HTTP GET request") + .build(); + + assert!(request.is_ok()); + } + + #[test] + fn build_signed_post_request() { + let signer = get_request_signer(); + let request = Client::new() + .post("https://example.com") + .body(Body::from(b"somedata".to_vec())) + .sign(&signer, None) + .expect("Failed to sign HTTP POST request") + .build(); + + assert!(request.is_ok()); + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..572a765 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,32 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::fs; + +use crate::authenticator::SpecialTokenResponse; +use crate::{ + app_params::{XalAppParameters, XalClientParameters}, + models::response::{SisuAuthorizationResponse, XCloudTokenResponse, XSTSResponse}, +}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct TokenStore { + pub app_params: XalAppParameters, + pub client_params: XalClientParameters, + pub wl_token: SpecialTokenResponse, + pub sisu_tokens: SisuAuthorizationResponse, + pub gssv_token: XSTSResponse, + pub xcloud_transfer_token: XCloudTokenResponse, + pub updated: DateTime, +} + +impl TokenStore { + pub fn load(filepath: &str) -> Result> { + let s = fs::read_to_string(filepath)?; + serde_json::from_str(&s).map_err(|e| e.into()) + } + + pub fn save(&self, filepath: &str) -> Result<(), Box> { + let s = serde_json::to_string_pretty(self)?; + fs::write(filepath, s).map_err(|e| e.into()) + } +} From 63cfb65e67b0341769a409136bab5a4138baf9b1 Mon Sep 17 00:00:00 2001 From: tuxuser <462620+tuxuser@users.noreply.github.com> Date: Mon, 14 Nov 2022 01:23:50 +0100 Subject: [PATCH 02/13] Major refactoring TODO - Documentation - lib.rs samples --- .gitignore | 22 +- Cargo.toml | 37 +- examples/Cargo.toml | 36 + examples/src/bin/auth_azure.rs | 72 ++ examples/src/bin/auth_cli.rs | 10 + examples/src/bin/auth_webview.rs | 115 +++ examples/src/lib.rs | 149 ++++ src/app_params.rs | 152 ---- src/authenticator.rs | 1256 +++++++++++++++++++++++------- src/bin/auth-cli.rs | 146 ---- src/bin/auth-webview.rs | 200 ----- src/error.rs | 71 ++ src/extensions.rs | 150 ++++ src/flows.rs | 563 +++++++++++++ src/lib.rs | 31 +- src/models.rs | 743 ++++++++++++++---- src/request_signer.rs | 624 ++++++++++----- src/tokenstore.rs | 126 +++ src/utils.rs | 32 - xal-rs.code-workspace | 13 + 20 files changed, 3367 insertions(+), 1181 deletions(-) create mode 100644 examples/Cargo.toml create mode 100644 examples/src/bin/auth_azure.rs create mode 100644 examples/src/bin/auth_cli.rs create mode 100644 examples/src/bin/auth_webview.rs create mode 100644 examples/src/lib.rs delete mode 100644 src/app_params.rs delete mode 100644 src/bin/auth-cli.rs delete mode 100644 src/bin/auth-webview.rs create mode 100644 src/error.rs create mode 100644 src/extensions.rs create mode 100644 src/flows.rs create mode 100644 src/tokenstore.rs delete mode 100644 src/utils.rs create mode 100644 xal-rs.code-workspace diff --git a/.gitignore b/.gitignore index 4fffb2f..d6c8968 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,20 @@ -/target -/Cargo.lock +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Temporary json files +*.json + +# Environment file +env diff --git a/Cargo.toml b/Cargo.toml index 3f44d5f..106e707 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,35 +10,28 @@ homepage = "https://openxbox.org" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +async-trait = "0.1.74" reqwest = { version = "0.11", features = ["json"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" cvlib = "0.1.2" filetime_type = "0.1" -base64 = "0.13.0" chrono = "0.4" -josekit = "0.8" -uuid = { version = "1", features = ["v4"] } -oauth2 = "4.3" - -# common for bins -tokio = { version = "1", features = ["full"], optional = true } - -# auth_webview -tauri = { version = "1.1.1", optional = true } -wry = { version = "0.21.1", optional = true } +uuid = { version = "1", features = ["v4", "serde"] } +thiserror = "1.0.37" +url = "2.3.1" +http = "0.2.9" +log = "0.4.20" +p256 = "0.13.2" +base64ct = { version = "1.6.0", features = ["std"] } +sha2 = "0.10.8" +rand = "0.8.5" +oauth2 = "4.4.2" [dev-dependencies] hex-literal = "0.3.4" -[features] -webview = ["dep:tauri", "dep:wry"] -tokio = ["dep:tokio"] - -[[bin]] -name = "auth-cli" -required-features = ["tokio"] - -[[bin]] -name = "auth-webview" -required-features = ["webview"] +[workspace] +members = [ + "examples" +] diff --git a/examples/Cargo.toml b/examples/Cargo.toml new file mode 100644 index 0000000..f334473 --- /dev/null +++ b/examples/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "xal_examples" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +xal = { path = ".." } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +async-trait = "0.1.74" +env_logger = "0.10.1" +log = "0.4.20" +clap = { version = "4.4.8", features = ["derive"] } +chrono = "0.4.31" + +# Optional dependencies +tokio = { version = "1", features = ["full"], optional = true } +wry = { version = "0.34.2", optional = true } + +[features] +webview = ["dep:wry"] +tokio = ["dep:tokio"] + +[[bin]] +name = "auth_cli" +required-features = ["tokio"] + +[[bin]] +name = "auth_azure" +required-features = ["tokio"] + +[[bin]] +name = "auth_webview" +required-features = ["webview","tokio"] diff --git a/examples/src/bin/auth_azure.rs b/examples/src/bin/auth_azure.rs new file mode 100644 index 0000000..16bf3a1 --- /dev/null +++ b/examples/src/bin/auth_azure.rs @@ -0,0 +1,72 @@ +use std::str::from_utf8; + +use async_trait::async_trait; +use tokio::{net::TcpListener, io::{AsyncReadExt, AsyncWriteExt}}; +use xal::{ + flows::{AuthPromptCallback, AuthPromptData}, + url::Url, + Error, client_params::CLIENT_ANDROID, Constants, XalAppParameters, oauth2::{Scope, RedirectUrl, ResourceOwnerUsername, ResourceOwnerPassword}, XalAuthenticator, +}; +use xal_examples::auth_main; + +pub struct HttpCallbackHandler { + bind_host: String, + redirect_url_base: String, +} + +#[async_trait] +impl AuthPromptCallback for HttpCallbackHandler { + async fn call( + &self, + cb_data: AuthPromptData, + ) -> Result, Box> { + 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 = 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> { + auth_main( + XalAppParameters { + app_id: "388ea51c-0b25-4029-aae2-17df49d23905".into(), + title_id: None, + auth_scopes: vec![ + Scope::new("Xboxlive.signin".into()), Scope::new("Xboxlive.offline_access".into()) + ], + redirect_uri: Some( + RedirectUrl::new("http://localhost:8080/auth/callback".into()).unwrap(), + ), + }, + CLIENT_ANDROID(), + "RETAIL".into(), + Constants::RELYING_PARTY_XBOXLIVE.into(), + xal::AccessTokenPrefix::D, + HttpCallbackHandler { + bind_host: "127.0.0.1:8080".into(), + redirect_url_base: "http://localhost:8080".into(), + } + ).await +} diff --git a/examples/src/bin/auth_cli.rs b/examples/src/bin/auth_cli.rs new file mode 100644 index 0000000..378752f --- /dev/null +++ b/examples/src/bin/auth_cli.rs @@ -0,0 +1,10 @@ +use xal::{flows, AccessTokenPrefix, Error}; +use xal_examples::auth_main_default; + +#[tokio::main] +async fn main() -> Result<(), Error> { + auth_main_default( + AccessTokenPrefix::None, + flows::CliCallbackHandler + ).await +} diff --git a/examples/src/bin/auth_webview.rs b/examples/src/bin/auth_webview.rs new file mode 100644 index 0000000..ffdfeb0 --- /dev/null +++ b/examples/src/bin/auth_webview.rs @@ -0,0 +1,115 @@ +// Copyright 2020-2022 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use async_trait::async_trait; +use tokio::sync::mpsc::channel; +use wry::{ + application::{ + event::{Event, WindowEvent}, + event_loop::{ControlFlow, EventLoopBuilder}, + platform::run_return::EventLoopExtRunReturn, + window::WindowBuilder, + }, + webview::WebViewBuilder, +}; +use xal::{ + flows::{AuthPromptCallback, AuthPromptData}, + url::Url, + Error, XalAuthenticator, AccessTokenPrefix, +}; +use xal_examples::auth_main_default; + +#[derive(Debug)] +enum UserEvent { + Exit, +} + +struct WebviewCallbackHandler { + redirect_url_schema: String, +} + +#[async_trait] +impl AuthPromptCallback for WebviewCallbackHandler { + async fn call( + &self, + cb_data: AuthPromptData, + ) -> Result, Box> { + let authentication_url = cb_data.authentication_url(); + let does_expect_url = cb_data.expect_url(); + + let redirect_url_schema = self.redirect_url_schema.clone(); + + let mut event_loop = EventLoopBuilder::with_user_event().build(); + let event_proxy = event_loop.create_proxy(); + let window = WindowBuilder::new() + .with_title("XAL Webview") + .build(&event_loop) + .unwrap(); + + let (sender, mut receiver) = channel::(1); + + let _webview = WebViewBuilder::new(window)? + // tell the webview to load the custom protocol + .with_navigation_handler(move |url| { + if does_expect_url { + // Callback wants a redirect URL (with either authorization code or implicit tokens) + let parsed_url = Url::parse(&url).expect("Failed to parse navigation URL"); + if parsed_url.scheme() == redirect_url_schema { + sender + .try_send(parsed_url) + .expect("Failed to send redirect URL over channel"); + + event_proxy.send_event(UserEvent::Exit).unwrap(); + return false; + } + } + + true + }) + .with_url(authentication_url.as_str())? + .build()?; + + let _ = event_loop.run_return(|event, _, control_flow| { + *control_flow = ControlFlow::Wait; + + match event { + Event::WindowEvent { + event: WindowEvent::CloseRequested, + .. + } + | Event::UserEvent(_) => { + *control_flow = ControlFlow::Exit; + } + _ => {} + } + }); + + let retval = { + if does_expect_url { + Some(receiver.try_recv()?) + } else { + None + } + }; + + Ok(retval) + } +} + +#[tokio::main] +async fn main() -> Result<(), Error> { + let authenticator = XalAuthenticator::default(); + + let callback_handler = WebviewCallbackHandler { + redirect_url_schema: authenticator + .get_redirect_uri() + .ok_or(Error::GeneralError( + "Failure! Authenticator not configured with redirect URL".to_string(), + ))? + .scheme() + .to_owned(), + }; + + auth_main_default(AccessTokenPrefix::None, callback_handler).await +} diff --git a/examples/src/lib.rs b/examples/src/lib.rs new file mode 100644 index 0000000..decbb18 --- /dev/null +++ b/examples/src/lib.rs @@ -0,0 +1,149 @@ +use clap::{Parser, ValueEnum}; +use env_logger::Env; +use log::info; +use xal::{flows, Error, XalAppParameters, XalAuthenticator, XalClientParameters, AccessTokenPrefix, Constants}; + +/// Common cli arguments +#[derive(Parser, Debug)] +#[command(author, about, long_about = None)] +pub struct Cli { + /// Increase message verbosity ('-v' -> debug, '-vv' -> trace) + #[arg(short, action = clap::ArgAction::Count)] + pub verbosity: u8, + + /// Filepath to tokenstore JSON + /// If it doesn't exists, it will be created upon successful authentication + #[arg(short, long, default_value = "tokens.json")] + pub token_filepath: String, + + /// Type of authentication flow to use + #[arg(short, long, value_enum, default_value = "sisu")] + pub flow: AuthFlow, + // Whether to do title authentication + // NOTE: Only works with Minecraft Client ID + //#[arg(short, long)] + //pub authenticate_title: bool, +} + +pub fn get_loglevel(verbosity: u8) -> String { + let default_loglevel = match verbosity { + 0 => "info", + 1 => "debug", + 2 => "trace", + _ => "trace", + }; + + default_loglevel.to_string() +} + +pub fn handle_args() -> Cli { + let args = Cli::parse(); + let default_loglevel = get_loglevel(args.verbosity); + env_logger::Builder::from_env(Env::default().default_filter_or(default_loglevel)).init(); + + args +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +pub enum AuthFlow { + Sisu, + DeviceCode, + Implicit, + AuthorizationCode, +} + +pub async fn auth_main_default( + access_token_prefix: AccessTokenPrefix, + auth_cb: impl flows::AuthPromptCallback +) -> Result<(), Error> { + auth_main( + XalAppParameters::default(), + XalClientParameters::default(), + "RETAIL".to_owned(), + Constants::RELYING_PARTY_XBOXLIVE.into(), + access_token_prefix, + auth_cb, + ) + .await +} + +/// Entrypoint for examples +pub async fn auth_main( + app_params: XalAppParameters, + client_params: XalClientParameters, + sandbox_id: String, + xsts_relying_party: String, + access_token_prefix: AccessTokenPrefix, + auth_cb: impl flows::AuthPromptCallback, +) -> Result<(), Error> { + let args = handle_args(); + + let mut ts = match flows::try_refresh_tokens_from_file(&args.token_filepath).await { + Ok((authenticator, ts)) => { + info!("Tokens refreshed succesfully, proceeding with Xbox Live Authorization"); + match args.flow { + AuthFlow::Sisu => { + info!("Authorize and gather rest of xbox live tokens via sisu"); + flows::xbox_live_sisu_authorization_flow( + authenticator, ts.live_token + ) + .await? + .1 + }, + _ => { + info!("Authorize Xbox Live the traditional way, via individual requests"); + flows::xbox_live_authorization_traditional_flow( + authenticator, + ts.live_token, + xsts_relying_party, + access_token_prefix, + false + ) + .await? + .1 + } + } + } + Err(err) => { + log::error!("Refreshing tokens failed err={err}"); + let authenticator = XalAuthenticator::new(app_params, client_params, sandbox_id); + + info!("Authentication via flow={:?}", args.flow); + let (authenticator, ts) = match args.flow { + AuthFlow::Sisu => flows::xbox_live_sisu_full_flow(authenticator, auth_cb).await?, + AuthFlow::DeviceCode => { + flows::ms_device_code_flow(authenticator, auth_cb, tokio::time::sleep).await? + } + AuthFlow::Implicit => { + flows::ms_authorization_flow(authenticator, auth_cb, true).await? + } + AuthFlow::AuthorizationCode => { + flows::ms_authorization_flow(authenticator, auth_cb, false).await? + } + }; + + match args.flow { + AuthFlow::Sisu => ts, + _ => { + info!("Continuing flow via traditional Xbox Live authorization"); + // Only required for non-sisu authentication, as + // sisu already gathers all the tokens at once + flows::xbox_live_authorization_traditional_flow( + authenticator, + ts.live_token, + xsts_relying_party, + access_token_prefix, + false, + ) + .await? + .1 + }, + } + } + }; + + ts.update_timestamp(); + ts.save_to_file(&args.token_filepath)?; + + Ok(()) +} diff --git a/src/app_params.rs b/src/app_params.rs deleted file mode 100644 index 7d10cbc..0000000 --- a/src/app_params.rs +++ /dev/null @@ -1,152 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::str::FromStr; - -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] -pub enum DeviceType { - IOS, - ANDROID, - WIN32, -} - -impl FromStr for DeviceType { - type Err = Box; - - fn from_str(s: &str) -> Result { - let enm = match s.to_lowercase().as_ref() { - "android" => DeviceType::ANDROID, - "ios" => DeviceType::IOS, - "win32" => DeviceType::WIN32, - val => { - return Err(format!("Unhandled device type: '{}'", val).into()); - } - }; - Ok(enm) - } -} - -impl ToString for DeviceType { - fn to_string(&self) -> String { - let str = match self { - DeviceType::ANDROID => "Android", - DeviceType::IOS => "iOS", - DeviceType::WIN32 => "Win32", - }; - str.to_owned() - } -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct XalAppParameters { - pub app_id: String, - pub title_id: String, - pub redirect_uri: String, -} - -impl XalAppParameters { - pub fn xbox_app_beta() -> Self { - Self { - app_id: "000000004415494b".into(), - title_id: "177887386".into(), - redirect_uri: "ms-xal-000000004415494b://auth".into(), - } - } - - pub fn xbox_app() -> Self { - Self { - app_id: "000000004c12ae6f".into(), - title_id: "328178078".into(), - redirect_uri: "ms-xal-000000004c12ae6f://auth".into(), - } - } - - pub fn gamepass() -> Self { - Self { - app_id: "000000004c20a908".into(), - title_id: "1016898439".into(), - redirect_uri: "ms-xal-000000004c20a908://auth".into(), - } - } - - pub fn gamepass_beta() -> Self { - Self { - app_id: "000000004c20a908".into(), - title_id: "1016898439".into(), - redirect_uri: "ms-xal-public-beta-000000004c20a908://auth".into(), - } - } - - /// Family settings is somewhat special - /// Uses default oauth20_desktop.srf redirect uri - pub fn family_settings() -> Self { - Self { - app_id: "00000000482C8F49".into(), - title_id: "1618633878".into(), - redirect_uri: "https://login.live.com/oauth20_desktop.srf".into(), - } - } -} - -impl Default for XalAppParameters { - fn default() -> Self { - Self::gamepass_beta() - } -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct XalClientParameters { - pub user_agent: String, - pub device_type: DeviceType, - pub client_version: String, - pub query_display: String, -} - -impl XalClientParameters { - pub fn ios() -> Self { - Self { - user_agent: "XAL iOS 2021.11.20211021.000".into(), - device_type: DeviceType::IOS, - client_version: "15.6.1".into(), - query_display: "ios_phone".into(), - } - } - - pub fn android() -> Self { - Self { - user_agent: "XAL Android 2020.07.20200714.000".into(), - device_type: DeviceType::ANDROID, - client_version: "8.0.0".into(), - query_display: "android_phone".into(), - } - } -} - -impl Default for XalClientParameters { - fn default() -> Self { - Self::android() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn devicetype_enum_into() { - assert_eq!(DeviceType::WIN32.to_string(), "Win32"); - assert_eq!(DeviceType::ANDROID.to_string(), "Android"); - assert_eq!(DeviceType::IOS.to_string(), "iOS"); - } - - #[test] - fn str_into_devicetype_enum() { - assert_eq!(DeviceType::from_str("win32").unwrap(), DeviceType::WIN32); - assert_eq!(DeviceType::from_str("Win32").unwrap(), DeviceType::WIN32); - assert_eq!(DeviceType::from_str("WIN32").unwrap(), DeviceType::WIN32); - assert_eq!( - DeviceType::from_str("android").unwrap(), - DeviceType::ANDROID - ); - assert_eq!(DeviceType::from_str("ios").unwrap(), DeviceType::IOS); - assert!(DeviceType::from_str("androidx").is_err()); - } -} diff --git a/src/authenticator.rs b/src/authenticator.rs index b2b3527..90414ba 100644 --- a/src/authenticator.rs +++ b/src/authenticator.rs @@ -1,263 +1,863 @@ -use crate::app_params::XalAppParameters; +//! Authentication functionality. +use crate::{RequestSigner, AccessTokenPrefix}; +use crate::extensions::{ + CorrelationVectorReqwestBuilder, JsonExDeserializeMiddleware, LoggingReqwestRequestHandler, + LoggingReqwestResponseHandler, SigningReqwestBuilder, +}; -use super::{ - app_params::{DeviceType, XalClientParameters}, - models::request, - models::response, - request_signer::{self, SigningReqwestBuilder}, +use crate::request::{ + XADProperties, XASTProperties, XASUProperties, XSTSProperties, XTokenRequest, +}; +use crate::{ + error::Error, + models::{request, response, DeviceType, XalAppParameters, XalClientParameters}, + request_signer, }; -use base64; -use cvlib; + +use oauth2::reqwest::async_http_client; use oauth2::{ - basic::{ - BasicErrorResponse, BasicRevocationErrorResponse, BasicTokenIntrospectionResponse, - BasicTokenType, - }, - reqwest::async_http_client, - url, AccessToken, AuthType, AuthUrl, AuthorizationCode, Client as OAuthClient, ClientId, - EmptyExtraTokenFields, ExtraTokenFields, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, - RefreshToken, Scope, StandardRevocableToken, TokenResponse, TokenType, TokenUrl, + basic::{BasicClient, BasicErrorResponseType}, + AuthType, AuthUrl, AuthorizationCode, Client as OAuthClient, ClientId, ClientSecret, CsrfToken, + DeviceAuthorizationUrl, PkceCodeChallenge, PkceCodeVerifier, RefreshToken, RequestTokenError, + Scope, StandardDeviceAuthorizationResponse, StandardErrorResponse, TokenResponse, TokenUrl, }; -use reqwest; -use std::time::Duration; -use url::Url; -use uuid; - -type Error = Box; -type Result = std::result::Result; - -pub type SpecialTokenResponse = response::WindowsLiveTokenResponse; -type SpecialClient = OAuthClient< - BasicErrorResponse, - SpecialTokenResponse, - BasicTokenType, - BasicTokenIntrospectionResponse, - StandardRevocableToken, - BasicRevocationErrorResponse, ->; - -impl TokenResponse for response::WindowsLiveTokenResponse -where - EF: ExtraTokenFields, - BasicTokenType: TokenType, -{ - /// - /// REQUIRED. The access token issued by the authorization server. - /// - fn access_token(&self) -> &AccessToken { - &self.access_token - } - /// - /// REQUIRED. The type of the token issued as described in - /// [Section 7.1](https://tools.ietf.org/html/rfc6749#section-7.1). - /// Value is case insensitive and deserialized to the generic `TokenType` parameter. - /// But in this particular case as the service is non compliant, it has a default value - /// - fn token_type(&self) -> &BasicTokenType { - match &self.token_type { - Some(t) => t, - None => &BasicTokenType::Bearer, - } - } - /// - /// RECOMMENDED. The lifetime in seconds of the access token. For example, the value 3600 - /// denotes that the access token will expire in one hour from the time the response was - /// generated. If omitted, the authorization server SHOULD provide the expiration time via - /// other means or document the default value. - /// - fn expires_in(&self) -> Option { - self.expires_in.map(Duration::from_secs) - } - /// - /// OPTIONAL. The refresh token, which can be used to obtain new access tokens using the same - /// authorization grant as described in - /// [Section 6](https://tools.ietf.org/html/rfc6749#section-6). - /// - fn refresh_token(&self) -> Option<&RefreshToken> { - self.refresh_token.as_ref() - } - /// - /// OPTIONAL, if identical to the scope requested by the client; otherwise, REQUIRED. The - /// scipe of the access token as described by - /// [Section 3.3](https://tools.ietf.org/html/rfc6749#section-3.3). If included in the response, - /// this space-delimited field is parsed into a `Vec` of individual scopes. If omitted from - /// the response, this field is `None`. - /// - fn scopes(&self) -> Option<&Vec> { - self.scopes.as_ref() - } +use oauth2::{EndUserVerificationUrl, UserCode, VerificationUriComplete}; +use serde_json::json; +use std::collections::HashMap; +use url::{form_urlencoded, Url}; + +/// Authentication related constants +pub struct Constants; + +impl Constants { + /// Redirect URL for implicit / authorization code flow + pub const OAUTH20_DESKTOP_REDIRECT_URL: &str = "https://login.live.com/oauth20_desktop.srf"; + /// live.com Authorization URL + pub const OAUTH20_AUTHORIZE_URL: &str = "https://login.live.com/oauth20_authorize.srf"; + /// live.com Device Authorization URL (Device Code flow) + pub const OAUTH20_DEVICE_AUTHORIZE_URL: &str = "https://login.live.com/oauth20_connect.srf"; + /// live.com Remote Device Authorization URL (Device Code flow) - to assemble device authorization URL incl. OTC + pub const OAUTH20_DEVICE_REMOTEAUTHORIZE_URL: &str = + "https://login.live.com/oauth20_remoteconnect.srf"; + /// live.com Token URL + pub const OAUTH20_TOKEN_URL: &str = "https://login.live.com/oauth20_token.srf"; + + /// live.com authentication finish URL + /// Called f.e. on end of device code flow + pub const OAUTH20_FINISH_FLOW_URL: &str = "https://login.live.com/ppsecure/post.srf"; + + /// Xbox Title endpoints URL, returns signing policies for supported domains/endpoints + pub const XBOX_TITLE_ENDPOINTS_URL: &str = + "https://title.mgt.xboxlive.com/titles/default/endpoints"; + + /// Xbox Sisu authentication endpoint + pub const XBOX_SISU_AUTHENTICATE_URL: &str = "https://sisu.xboxlive.com/authenticate"; + /// Xbox Sisu authorization endpoint + pub const XBOX_SISU_AUTHORIZE_URL: &str = "https://sisu.xboxlive.com/authorize"; + + /// Xbox Device Authentication endpoint (XASD token) + pub const XBOX_DEVICE_AUTH_URL: &str = "https://device.auth.xboxlive.com/device/authenticate"; + /// Xbox Title Authentication endpoint (XAST token) + pub const XBOX_TITLE_AUTH_URL: &str = "https://title.auth.xboxlive.com/title/authenticate"; + /// Xbox User Authentication endpoint (XASU token) + pub const XBOX_USER_AUTH_URL: &str = "https://user.auth.xboxlive.com/user/authenticate"; + /// Xbox Service Authorization endpoint (XSTS token) + pub const XBOX_XSTS_AUTH_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize"; + + /// Default Xbox Live authorization scope + pub const SCOPE_SERVICE_USER_AUTH: &str = "service::user.auth.xboxlive.com::MBI_SSL"; + /// Signin Xbox Live authorization scope (used for custom Azure apps) + pub const SCOPE_XBL_SIGNIN: &str = "Xboxlive.signin"; + /// Offline access Xbox Live authorization scope (used for custom Azure apps) + pub const SCOPE_XBL_OFFLINE_ACCESS: &str = "Xboxlive.offline_access"; + + /// Relying Party Auth Xbox Live + pub const RELYING_PARTY_AUTH_XBOXLIVE: &str = "http://auth.xboxlive.com"; + /// Relying Party Xbox Live + pub const RELYING_PARTY_XBOXLIVE: &str = "http://xboxlive.com"; } +/// XAL Authenticator #[derive(Debug)] pub struct XalAuthenticator { + /// Random device id device_id: uuid::Uuid, + /// Application parameters + /// + /// See constants in [`crate::models::app_params] app_params: XalAppParameters, + /// Client parameters + /// + /// See constants in [`crate::models::client_params] client_params: XalClientParameters, + /// Correlation vector ms_cv: cvlib::CorrelationVector, + /// HTTP client instance client: reqwest::Client, - client2: SpecialClient, + /// HTTP request signer request_signer: request_signer::RequestSigner, + /// Xbox Live Sandbox Id, "RETAIL" is commonly used + sandbox_id: String, } impl Default for XalAuthenticator { fn default() -> Self { - let client_params = XalClientParameters::default(); - let app_params = XalAppParameters::default(); - let client_id = ClientId::new(app_params.app_id.clone()); - let client_secret = None; - - let auth_url = AuthUrl::new("https://login.live.com/oauth20_authorize.srf".into()) - .expect("Invalid authorization endpoint URL"); - let token_url = TokenUrl::new("https://login.live.com/oauth20_token.srf".into()) - .expect("Invalid token endpoint URL"); - let redirect_url = - RedirectUrl::new(app_params.redirect_uri.clone()).expect("Invalid redirect URL"); - - let client2 = OAuthClient::new(client_id, client_secret, auth_url, Some(token_url)) - .set_auth_type(AuthType::RequestBody) - .set_redirect_uri(redirect_url); + Self::new( + XalAppParameters::default(), + XalClientParameters::default(), + "RETAIL".to_string(), + ) + } +} + +/// Static methods +impl XalAuthenticator { + /// Generate OAuth2 random state + pub fn generate_random_state() -> CsrfToken { + CsrfToken::new_random() + } + /// Generate OAuth2 code verifier + pub fn generate_code_verifier() -> (PkceCodeChallenge, PkceCodeVerifier) { + PkceCodeChallenge::new_random_sha256() + } + + /// Obtain an alternative URL for the device code verification process + /// + /// This method generates a URL with the device code already prefilled. + /// Users only need to visit this URL and authenticate their account without copying and pasting the code. + /// + /// # Arguments + /// + /// * `user_code` - The user code generated during the device code flow initialization. + /// + /// # Returns + /// + /// * `VerificationUriComplete` - A URL with the device code already prefilled. + /// + /// # Examples + /// + /// ``` + /// use xal::XalAuthenticator; + /// use xal::oauth2::UserCode; + /// + /// let user_code = UserCode::new("abc123".to_string()); + /// let verification_uri = XalAuthenticator::get_device_code_verification_uri(&user_code); + /// println!("{:?}", verification_uri); + /// ``` + pub fn get_device_code_verification_uri(user_code: &UserCode) -> VerificationUriComplete { + VerificationUriComplete::new(format!( + "{}?lc=1033&otc={}", + Constants::OAUTH20_DEVICE_REMOTEAUTHORIZE_URL, + user_code.secret() + )) + } + + /// Parse OAuth2 authorization response by providing the full redirect url containing a code= query parameter + /// + /// # Arguments + /// + /// * `redirect_url` - The full url of the redirect endpoint containing the code= query parameter. + /// * `expected_state` - (Optional) The expected state that should match the state returned by the server. + /// If the states do not match, an error will be returned. + /// + /// # Returns + /// + /// * `Ok(AuthorizationCode)` - On successful validation of the server response and retrieval of the authorization code. + /// * `Err(Error)` - If there is an error while parsing the server response or if the states do not match. + /// + /// # Errors + /// + /// * `Error::GeneralError` - If there is a problem with the response url. + /// * `Error::OAuthExecutionError` - If there is an error with the server response or if the authorization code is not present. + pub fn parse_authorization_code_response( + redirect_url: &Url, + expected_state: Option<&CsrfToken>, + ) -> Result { + let query_map: HashMap = redirect_url + .query_pairs() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + if let Some(state) = expected_state { + if let Some(state_resp) = query_map.get("state") { + if state.secret() != state_resp { + return Err(Error::GeneralError(format!( + "Invalid state, Expected: {}, Got: {}", + state.secret(), + state_resp + ))); + } + } else { + return Err(Error::GeneralError( + "Expected 'state' in redirect response".into(), + )); + } + } + + if let Some(error) = query_map.get("error") { + let error_resp: StandardErrorResponse = serde_json::from_value( + serde_json::json!({ + "error": error, + "error_description": query_map.get("error_description").map(|x| x.to_string()), + "error_uri": query_map.get("error_uri").map(|x| x.to_string()), + }), + ) + .map_err(|e|Error::JsonError(e))?; + + return Err(Error::OAuthExecutionError( + oauth2::RequestTokenError::ServerResponse(error_resp), + )); + } else if let Some(code) = query_map.get("code") { + return Ok(AuthorizationCode::new(code.to_owned())); + } + + Err(Error::GeneralError( + "Response neither had 'code' nor 'error' field".into(), + )) + } + + /// Parse OAuth2 implicit grant response + /// + /// This method takes a URL with an OAuth2 implicit grant response and optionally a CSRF token. + /// It parses the response, verifies the CSRF token if provided, and returns the access token. + /// + /// # Arguments + /// + /// * `url` - The URL containing the OAuth2 implicit grant response. + /// * `expected_state` - Optionally, a CSRF token to verify against. + /// + /// # Returns + /// + /// * `Result` - The access token and token type. + /// + /// # Examples + /// + /// ``` + /// use xal::XalAuthenticator; + /// use xal::oauth2::CsrfToken; + /// use xal::url::Url; + /// + /// let url = Url::parse("https://example.com/callback#access_token=token123&token_type=Bearer&expires_in=3600&state=123abc") + /// .unwrap(); + /// let state = "123abc".to_string(); + /// let expected_state = CsrfToken::new(state); + /// let parsed_response = XalAuthenticator::parse_implicit_grant_url(&url, Some(&expected_state)).unwrap(); + /// println!("{:?}", parsed_response); + /// ``` + pub fn parse_implicit_grant_url( + url: &Url, + expected_state: Option<&CsrfToken>, + ) -> Result { + let fragment = url + .fragment() + .ok_or(Error::InvalidRedirectUrl("No fragment found".to_string()))?; + + let mut kv_pairs: HashMap = HashMap::new(); + let mut state_resp = None; + + for (k, v) in form_urlencoded::parse(fragment.as_bytes()) { + match k.as_ref() { + "expires_in" => { + kv_pairs.insert(k.to_string(), json!(v.parse::().unwrap())); + } + "state" => { + state_resp = Some(v.to_string()); + kv_pairs.insert(k.to_string(), json!(v.to_string())); + } + _ => { + kv_pairs.insert(k.to_string(), json!(v.to_string())); + } + } + } + + if let Some(state) = expected_state { + if let Some(s) = state_resp { + if state.secret() != &s.to_string() { + return Err(Error::GeneralError(format!( + "Invalid state, Expected: {}, Got: {s}", + state.secret() + ))); + } + } else { + return Err(Error::InvalidRedirectUrl("No state found".to_string())); + } + } + + Ok(serde_json::from_value(json!(kv_pairs))?) + } +} + +/// OAuth2 request functionality +impl XalAuthenticator { + /// Create a new instance of the XAL Authenticator + /// + /// This method initializes an instance of the XAL Authenticator with the specified + /// `app_params`, `client_params`, and `sandbox_id`. + /// + /// The `device_id` parameter can be provided to use a specific device ID, or it can be left as + /// `None` to generate a new device ID. + /// + /// See constants in [`crate::models::app_params`] for [`crate::XalAppParameters`] and + /// [`crate::models::client_params`] for [`crate::XalClientParameters`]. + pub fn new( + app_params: XalAppParameters, + client_params: XalClientParameters, + sandbox_id: String, + ) -> Self { Self { - device_id: uuid::Uuid::new_v4(), app_params, client_params, + device_id: uuid::Uuid::new_v4(), ms_cv: cvlib::CorrelationVector::new(), client: reqwest::Client::new(), - client2, request_signer: request_signer::RequestSigner::default(), + sandbox_id: sandbox_id.to_owned(), } } -} -impl XalAuthenticator { - pub fn get_code_challenge() -> (PkceCodeChallenge, PkceCodeVerifier) { - PkceCodeChallenge::new_random_sha256() + /// Get Device Id + pub fn device_id(&self) -> uuid::Uuid { + self.device_id } - pub fn generate_random_state() -> String { - let state = uuid::Uuid::new_v4().hyphenated().to_string(); - - base64::encode(state) + /// Get configured sandbox id + pub fn sandbox_id(&self) -> String { + self.sandbox_id.clone() } -} -impl XalAuthenticator { + /// Get active app parameters pub fn app_params(&self) -> XalAppParameters { self.app_params.clone() } + /// Get active client parameters pub fn client_params(&self) -> XalClientParameters { self.client_params.clone() } - pub fn get_redirect_uri(&self) -> Url { - self.client2.redirect_url().unwrap().url().to_owned() + /// Get request signer instance + pub fn request_signer(&self) -> RequestSigner { + self.request_signer.clone() } - fn next_cv(&mut self) -> String { - self.ms_cv.increment(); - self.ms_cv.to_string() + /// Get redirection Url + pub fn get_redirect_uri(&self) -> Option { + self.app_params + .redirect_uri + .clone() + .map(|url| Url::parse(&url).unwrap()) } - pub async fn exchange_code_for_token( + /// Create an internal OAuth2 client with provided scopes + pub fn oauth_client(&self, client_secret: Option) -> Result { + let client = OAuthClient::new( + ClientId::new(self.app_params.app_id.to_string()), + client_secret, + AuthUrl::new(Constants::OAUTH20_AUTHORIZE_URL.to_string())?, + Some(TokenUrl::new(Constants::OAUTH20_TOKEN_URL.to_string())?), + ) + .set_auth_type(AuthType::RequestBody) + .set_device_authorization_url(DeviceAuthorizationUrl::new( + Constants::OAUTH20_DEVICE_AUTHORIZE_URL.to_string(), + )?); + + Ok(client) + } + + /// Gets the authorization URL for the OAuth2 authentication flow. + /// + /// When the user clicks the link in the URL, they will be prompted to sign in with their Xbox Live account. + /// + /// Defining a `redirect_url` in [`crate::XalAppParameters`] is mandatory + /// for this authentication flow + /// + /// # Examples + /// + /// ``` + /// use xal::{XalAuthenticator, XalAppParameters, client_params}; + /// use xal::oauth2::{RedirectUrl, Scope}; + /// + /// # async fn demo_code() { + /// let mut authenticator = XalAuthenticator::new( + /// XalAppParameters { + /// app_id: "388ea51c-0b25-4029-aae2-17df49d23905".into(), + /// title_id: None, + /// auth_scopes: vec![ + /// Scope::new("Xboxlive.signin".into()), + /// Scope::new("Xboxlive.offline_access".into()) + /// ], + /// redirect_uri: Some(RedirectUrl::new("https://login.live.com/oauth20_desktop.srf".into()).unwrap()) + /// }, + /// client_params::CLIENT_ANDROID(), + /// None, + /// "RETAIL".into() + /// ); + /// + /// let (url, state) = authenticator.get_authorization_url(false) + /// .unwrap(); + /// + /// assert!(url.as_str().starts_with("https://login.live.com/oauth20_desktop.srf")); + /// # } + /// ``` + pub fn get_authorization_url( &mut self, - authorization_code: &str, - code_verifier: PkceCodeVerifier, - ) -> Result { - let code = AuthorizationCode::new(authorization_code.into()); - let token = self - .client2 - .exchange_code(code) - .set_pkce_verifier(code_verifier) - .add_extra_param("scope", "service::user.auth.xboxlive.com::MBI_SSL") - .request_async(async_http_client) - .await?; + implicit_flow: bool, + ) -> Result<(EndUserVerificationUrl, CsrfToken), Error> { + let client = + self.oauth_client(None)? + .set_redirect_uri(self.app_params.redirect_uri.clone().ok_or( + Error::InvalidRedirectUrl("Redirect URL was not provided".into()), + )?); - Ok(token) + let mut req = client + .authorize_url(Self::generate_random_state) + .add_scopes(self.app_params.auth_scopes.clone()); + + if implicit_flow { + req = req.use_implicit_flow(); + } + + let (url, state) = req.url(); + Ok((EndUserVerificationUrl::from_url(url), state)) } - pub async fn exchange_refresh_token_for_xcloud_transfer_token( + /// Initiates the Device Code Authentication Flow. + /// + /// After presenting the returned [`crate::oauth2:: EndUserVerificationUrl`] and [`crate::oauth2::UserCode`] + /// to the user, call `poll_device_code_auth`. + /// + /// You can transform the returned value into [`crate::oauth2::VerificationUriComplete`] by calling `get_device_code_verification_uri`. + pub async fn initiate_device_code_auth( &mut self, - refresh_token: &RefreshToken, - ) -> Result { - let form_body = request::WindowsLiveTokenRequest { - client_id: &self.app_params.app_id.clone(), - grant_type: "refresh_token", - scope: - "service::http://Passport.NET/purpose::PURPOSE_XBOX_CLOUD_CONSOLE_TRANSFER_TOKEN", - refresh_token: Some(refresh_token.secret()), - code: None, - code_verifier: None, - redirect_uri: None, - }; + ) -> Result { + self.oauth_client(None)? + .exchange_device_code() + .unwrap() + .add_scopes(self.app_params.auth_scopes.clone()) + .add_extra_param("response_type", "device_code") + .request_async(&async_http_client) + .await + .map_err(std::convert::Into::into) + } - self.client - .post("https://login.live.com/oauth20_token.srf") - .header("MS-CV", self.next_cv()) - .form(&form_body) - .send() - .await? - .json::() + /// Poll for device code. + /// + /// To be called after presenting the result of `start_device_code_auth` to the user. + /// + /// # Arguments + /// + /// - `sleep_fn` is the impl of an async sleep function + pub async fn poll_device_code_auth( + &mut self, + device_auth_resp: &StandardDeviceAuthorizationResponse, + sleep_fn: S, + ) -> Result + where + S: Fn(std::time::Duration) -> SF, + SF: std::future::Future, + { + self.oauth_client(None)? + .exchange_device_access_token(device_auth_resp) + .request_async(&async_http_client, sleep_fn, None) .await - .map_err(|e| e.into()) + .map_err(std::convert::Into::into) } - pub async fn refresh_token( + /// Exchange OAuth2 Authorization Token for Windows Live Access Token. + /// + /// This method utilizes the PKCE extension to securely obtain an access token from the Microsoft Identity Platform. + /// + /// # Arguments + /// + /// * `authorization_code` - The authorization code received from the user authentication step. + /// * `code_verifier` - The code verifier that was generated earlier in the PKCE process. This parameter is optional. + /// + /// # Examples + /// + /// ``` + /// use xal::XalAuthenticator; + /// use xal::oauth2::{AuthorizationCode, TokenResponse}; + /// # async fn demo_code() -> Result<(), Box> { + /// let mut authenticator = XalAuthenticator::default(); + /// let code = AuthorizationCode::new("123".to_string()); + /// let live_tokens = authenticator + /// .exchange_code_for_token(code, None) + /// .await?; + /// + /// assert!(!live_tokens.access_token().secret().is_empty()); + /// # Ok(()) + /// # } + /// ``` + pub async fn exchange_code_for_token( + &mut self, + authorization_code: AuthorizationCode, + code_verifier: Option, + ) -> Result { + let client = self.oauth_client(None)?; + + let mut req = client + .exchange_code(authorization_code); + + if let Some(redirect_url) = &self.app_params.redirect_uri { + req = req.set_redirect_uri(std::borrow::Cow::Owned(redirect_url.clone())); + } + + if let Some(pkce) = code_verifier { + req = req.set_pkce_verifier(pkce) + } + + req.request_async(&async_http_client) + .await + .map_err(std::convert::Into::into) + } + + /// Refresh an OAuth2 Refresh Token for specific scope(s) and deserialize into custom response type + /// + /// This is used when the token response does not align with the standard. + /// + /// # Examples + /// + /// ``` + /// use xal::XalAuthenticator; + /// use xal::oauth2::{RefreshToken, Scope}; + /// use serde::{Deserialize, Serialize}; + /// + /// // Custom JSON response body + /// #[derive(Debug, Serialize, Deserialize)] + /// pub struct XCloudTokenResponse { + /// pub lpt: String, + /// pub refresh_token: String, + /// pub user_id: String, + /// } + /// + /// # async fn demo_code() { + /// # let refresh_token = RefreshToken::new("...refresh token...".into()); + /// let mut authenticator = XalAuthenticator::default(); + /// let scopes = vec![ + /// Scope::new( + /// "service::http://Passport.NET/purpose::PURPOSE_XBOX_CLOUD_CONSOLE_TRANSFER_TOKEN".into() + /// ) + /// ]; + /// + /// let token_response = authenticator + /// .refresh_token_for_scope::( + /// &refresh_token, + /// scopes + /// ) + /// .await + /// .unwrap(); + /// # } + /// ``` + /// + pub async fn refresh_token_for_scope( &mut self, refresh_token: &RefreshToken, - ) -> Result { - let token = self - .client2 + scopes: Vec, + ) -> Result + where + T: serde::de::DeserializeOwned, + { + let resp = self + .oauth_client(None)? .exchange_refresh_token(refresh_token) - .add_scope(Scope::new( - "service::user.auth.xboxlive.com::MBI_SSL".into(), - )) - .request_async(async_http_client) - .await?; + .add_scopes(scopes) + .request_async(&async_http_client) + .await; + + // HACK: Catch message body from parsing failure and parse it ourselves + match resp { + Ok(res) => serde_json::from_value::(serde_json::json!(&res)) + .map_err(std::convert::Into::into), + Err(RequestTokenError::Parse(_, data)) => { + serde_json::from_slice(&data).map_err(std::convert::Into::into) + } + Err(e) => Err(e).map_err(std::convert::Into::into), + } + } - Ok(token) + /// Refresh a Windows Live Refresh & Access Token by providing a Refresh Token + /// + /// # Arguments + /// + /// * `refresh_token` - The refresh token to use for obtaining a new access token + /// + /// # Examples + /// + /// ``` + /// use xal::XalAuthenticator; + /// use xal::oauth2::RefreshToken; + /// + /// let authenticator = XalAuthenticator::default(); + /// let refresh_token = RefreshToken::new("old_refresh_token".to_string()); + /// /* + /// let refreshed_live_tokens = authenticator + /// .refresh_token(&refresh_token) + /// .await + /// .unwrap(); + /// + /// println!("Refreshed tokens: {refreshed_live_tokens:?}"); + /// */ + /// ``` + pub async fn refresh_token( + &mut self, + refresh_token: &RefreshToken, + ) -> Result { + self.refresh_token_for_scope(refresh_token, self.app_params.auth_scopes.clone()) + .await } } +/// Xbox Live token functionality impl XalAuthenticator { - pub async fn get_endpoints(&self) -> Result { + /// Initiate authentication via SISU flow + /// + /// # Parameters + /// + /// * `device_token`: A [`response::DeviceToken`] object representing the device token. + /// * `code_challenge`: A [`PkceCodeChallenge`] object representing the code challenge. + /// * `state`: A [`CsrfToken`] object representing the CSRF token. + /// + /// # Errors + /// + /// * If `device_token` is missing. + /// * If `redirect_uri` is missing. + /// * If the Sisu Authentication request fails. + /// + /// # Examples + /// + /// ``` + /// use xal::XalAuthenticator; + /// use xal::url::Url; + /// + /// # async fn demo_code() { + /// let mut authenticator = XalAuthenticator::default(); + /// let state = XalAuthenticator::generate_random_state(); + /// let (pkce_challenge, pkce_verifier) = XalAuthenticator::generate_code_verifier(); + /// let device_token = authenticator.get_device_token() + /// .await + /// .unwrap(); + /// + /// let (resp, session_id) = authenticator.sisu_authenticate( + /// &device_token, + /// &pkce_challenge, + /// &state + /// ) + /// .await + /// .unwrap(); + /// + /// println!( + /// "Visit this url and pass back the redirect url containing the authorization code {}", + /// resp.msa_oauth_redirect + /// ); + /// let redirect_url = Url::parse("https://example.com/?code=123").unwrap(); + /// + /// let authorization_code = XalAuthenticator::parse_authorization_code_response( + /// &redirect_url, Some(&state) + /// ).unwrap(); + /// + /// let live_tokens = authenticator.exchange_code_for_token( + /// authorization_code, Some(pkce_verifier) + /// ) + /// .await + /// .unwrap(); + /// + /// let sisu_authorization_resp = authenticator.sisu_authorize( + /// &live_tokens, &device_token, Some(session_id) + /// ) + /// .await + /// .unwrap(); + /// # } + /// ``` + /// + /// # Notes + /// + /// It is mandatory to have [`XalAppParameters`] setup with a `redirect_uri` and `title_id`. + pub async fn sisu_authenticate( + &mut self, + device_token: &response::DeviceToken, + code_challenge: &PkceCodeChallenge, + state: &CsrfToken, + ) -> Result< + ( + response::SisuAuthenticationResponse, + response::SisuSessionId, + ), + Error, + > { + let title_id = self.app_params.title_id + .clone() + .ok_or(Error::InvalidRequest("Sisu authentication not possible without title Id (check XalAppParameters)".into()))?; + + let json_body = request::SisuAuthenticationRequest { + app_id: &self.app_params.app_id, + title_id: &title_id, + redirect_uri: self.app_params.redirect_uri.as_deref().ok_or( + Error::InvalidRedirectUrl("sisu_authenticate requires Redirect URL".to_string()), + )?, + device_token: &device_token.token, + sandbox: &self.sandbox_id, + token_type: "code", + offers: vec![Constants::SCOPE_SERVICE_USER_AUTH], + query: request::SisuQuery { + display: &self.client_params.query_display, + code_challenge: code_challenge.as_str(), + code_challenge_method: code_challenge.method(), + state: state.secret(), + }, + }; + let resp = self .client - .get("https://title.mgt.xboxlive.com/titles/default/endpoints") + .post(Constants::XBOX_SISU_AUTHENTICATE_URL) .header("x-xbl-contract-version", "1") - .query(&[("type", 1)]) + .add_cv(&mut self.ms_cv)? + .json(&json_body) + .sign(&self.request_signer, None)? .send() - .await? - .json::() .await?; - Ok(resp) + let session_id = resp + .headers() + .get("X-SessionId") + .ok_or(Error::GeneralError("Missing X-SessionId".to_owned()))? + .to_str() + .map_err(|e| Error::GeneralError(e.to_string()))? + .to_owned(); + + let resp_json = resp + .json_ex::() + .await?; + + Ok((resp_json, response::SisuSessionId(session_id))) + } + + /// Authorize via SISU flow after completing OAuth2 Authentication + /// + /// This function handles the second step of the SISU flow. + /// The response from the server contains a collection of tokens, which can be used for further interaction with the Xbox Live service. + /// + /// # Examples + /// + /// ``` + /// use xal::XalAuthenticator; + /// use xal::url::Url; + /// + /// # async fn demo_code() { + /// let mut authenticator = XalAuthenticator::default(); + /// let state = XalAuthenticator::generate_random_state(); + /// let (pkce_challenge, pkce_verifier) = XalAuthenticator::generate_code_verifier(); + /// let device_token = authenticator.get_device_token() + /// .await + /// .unwrap(); + /// + /// let (resp, session_id) = authenticator.sisu_authenticate( + /// &device_token, + /// &pkce_challenge, + /// &state + /// ) + /// .await + /// .unwrap(); + /// + /// println!( + /// "Visit this url and pass back the redirect url containing the authorization code {}", + /// resp.msa_oauth_redirect + /// ); + /// let redirect_url = Url::parse("https://example.com/?code=123").unwrap(); + /// + /// let authorization_code = XalAuthenticator::parse_authorization_code_response( + /// &redirect_url, Some(&state) + /// ).unwrap(); + /// + /// let live_tokens = authenticator.exchange_code_for_token( + /// authorization_code, Some(pkce_verifier) + /// ) + /// .await + /// .unwrap(); + /// + /// let sisu_authorization_resp = authenticator.sisu_authorize( + /// &live_tokens, &device_token, Some(session_id) + /// ) + /// .await + /// .unwrap(); + /// # } + /// ``` + pub async fn sisu_authorize( + &mut self, + access_token: &response::WindowsLiveTokens, + device_token: &response::DeviceToken, + sisu_session_id: Option, + ) -> Result { + let json_body = request::SisuAuthorizationRequest { + access_token: &format!("t={}", access_token.access_token().secret()), + app_id: &self.app_params.app_id, + device_token: &device_token.token, + sandbox: &self.sandbox_id.clone(), + site_name: "user.auth.xboxlive.com", + session_id: sisu_session_id.map(|a| a.0), + proof_key: self.request_signer.get_proof_key(), + }; + + self.client + .post(Constants::XBOX_SISU_AUTHORIZE_URL) + .add_cv(&mut self.ms_cv)? + .json(&json_body) + .sign(&self.request_signer, None)? + .send() + .await? + .json_ex::() + .await + .map_err(std::convert::Into::into) } - pub async fn get_device_token(&mut self) -> Result { + /// Requests a Xbox Live Device Token from the Xbox Live authentication service. + /// + /// This method is responsible for requesting a Xbox Live Device Token, which identifies a client device to the Xbox service. + /// + /// # Errors + /// + /// This method returns an `Error` if the POST request fails or the JSON response cannot be parsed. + /// + /// # Examples + /// + /// ``` + /// # async fn demo_code() { + /// use xal::XalAuthenticator; + /// + /// let mut authenticator = XalAuthenticator::default(); + /// let device_token = authenticator.get_device_token() + /// .await + /// .unwrap(); + /// + /// assert!(!device_token.token.is_empty()); + /// # } + /// ``` + /// + /// # Notes + /// + /// Device tokens can only be requested for devices of the following type: + /// + /// - Android + /// - iOS + /// - Nintendo + /// - Win32 + /// + /// Xbox devices use a much more sophisticated request method. + pub async fn get_device_token(&mut self) -> Result { + let device_id = self.device_id.hyphenated().to_string(); let client_uuid: String = match self.client_params.device_type { // {decf45e4-945d-4379-b708-d4ee92c12d99} - DeviceType::ANDROID => [ - "{".to_string(), - self.device_id.hyphenated().to_string(), - "}".to_string(), - ] - .concat(), - + DeviceType::ANDROID | DeviceType::NINTENDO => ["{", &device_id, "}"].concat(), // DECF45E4-945D-4379-B708-D4EE92C12D99 - DeviceType::IOS => self.device_id.hyphenated().to_string().to_uppercase(), + DeviceType::IOS => device_id.to_uppercase(), // Unknown - _ => self.device_id.hyphenated().to_string(), + _ => device_id, }; - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert("x-xbl-contract-version", "1".parse()?); - headers.insert("MS-CV", self.next_cv().parse()?); - - let json_body = request::XADRequest { - relying_party: "http://auth.xboxlive.com", + let json_body = XTokenRequest:: { + relying_party: Constants::RELYING_PARTY_AUTH_XBOXLIVE, token_type: "JWT", - properties: request::XADProperties { + properties: XADProperties { auth_method: "ProofOfPossession", id: client_uuid.as_str(), device_type: &self.client_params.device_type.to_string(), @@ -267,135 +867,257 @@ impl XalAuthenticator { }; self.client - .post("https://device.auth.xboxlive.com/device/authenticate") - .headers(headers) + .post(Constants::XBOX_DEVICE_AUTH_URL) + .header("x-xbl-contract-version", "1") + .add_cv(&mut self.ms_cv)? .json(&json_body) .sign(&self.request_signer, None)? .send() .await? - .json::() + .json_ex::() .await - .map_err(|e| e.into()) + .map_err(std::convert::Into::into) } - /// Sisu authentication - /// Returns tuple: - /// 1. Part: Response that contains authorization URL - /// 2. Part: Session ID from response headers (X-SessionId) - pub async fn do_sisu_authentication( + /// Retrieves a Xbox User Token for a specified Access Token. + /// + /// This method sends a POST request to the Xbox Live User Authentication URL, using the provided + /// `access_token` and `prefix`. + /// + /// The resulting User Token is then used to retrieve the final *XSTS* token to access Xbox Live services. + /// + /// # Arguments + /// + /// * `access_token` - The Windows Live access token. + /// * `prefix` - The access token prefix, either "d=", "t=" or *None*. + /// + /// # Errors + /// + /// This method returns an [`crate::Error`] if the POST request fails or the JSON response cannot be parsed. + /// + pub async fn get_user_token( &mut self, - device_token: &str, - code_challenge: PkceCodeChallenge, - state: &str, - ) -> Result<(response::SisuAuthenticationResponse, String)> { - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert("x-xbl-contract-version", "1".parse()?); - headers.insert("MS-CV", self.next_cv().parse()?); - - let json_body = request::SisuAuthenticationRequest { - app_id: &self.app_params.app_id, - title_id: &self.app_params.title_id, - redirect_uri: &self.app_params.redirect_uri, - device_token, - sandbox: "RETAIL", - token_type: "code", - offers: vec!["service::user.auth.xboxlive.com::MBI_SSL"], - query: request::SisuQuery { - display: &self.client_params.query_display, - code_challenge: code_challenge.as_str(), - code_challenge_method: code_challenge.method(), - state, + access_token: &response::WindowsLiveTokens, + prefix: AccessTokenPrefix, + ) -> Result { + let json_body = XTokenRequest:: { + relying_party: Constants::RELYING_PARTY_AUTH_XBOXLIVE, + token_type: "JWT", + properties: XASUProperties { + auth_method: "RPS", + site_name: "user.auth.xboxlive.com", + rps_ticket: &format!( + "{}{}", + prefix.to_string(), + access_token.access_token().secret() + ), }, }; - let resp = self - .client - .post("https://sisu.xboxlive.com/authenticate") - .headers(headers) + self.client + .post(Constants::XBOX_USER_AUTH_URL) + .header("x-xbl-contract-version", "1") + .add_cv(&mut self.ms_cv)? .json(&json_body) .sign(&self.request_signer, None)? + .log() + .await? .send() - .await?; - - let session_id = resp - .headers() - .get("X-SessionId") - .ok_or("Failed to fetch session id")? - .to_str()? - .to_owned(); - - let resp_json = resp.json::().await?; - - Ok((resp_json, session_id)) + .await? + .log() + .await? + .json_ex::() + .await + .map_err(std::convert::Into::into) } - pub async fn do_sisu_authorization( + /// Retrieves a Title Token for a specified Access Token and Device Token. + /// + /// This method sends a POST request to the Xbox Live Title Authentication URL, using the provided + /// `access_token` and `device_token`. + /// + /// The resulting Title Token is then used to retrieve the final *XSTS* token to access Xbox Live services. + /// + /// # Arguments + /// + /// * `access_token` - The Windows Live access token. + /// * `device_token` - The Xbox Live device token. + /// + /// # Errors + /// + /// This method returns an `Error` if the POST request fails or the JSON response cannot be parsed. + /// + pub async fn get_title_token( &mut self, - sisu_session_id: &str, - access_token: &str, - device_token: &str, - ) -> Result { - let json_body = request::SisuAuthorizationRequest { - access_token: &format!("t={}", access_token), - app_id: &self.app_params.app_id.clone(), - device_token, - sandbox: "RETAIL", - site_name: "user.auth.xboxlive.com", - session_id: sisu_session_id, - proof_key: self.request_signer.get_proof_key(), + access_token: &response::WindowsLiveTokens, + device_token: &response::DeviceToken, + ) -> Result { + let json_body = XTokenRequest:: { + relying_party: Constants::RELYING_PARTY_AUTH_XBOXLIVE, + token_type: "JWT", + properties: XASTProperties { + auth_method: "RPS", + site_name: "user.auth.xboxlive.com", + rps_ticket: &format!("t={}", access_token.access_token().secret()), + device_token: &device_token.token, + }, }; self.client - .post("https://sisu.xboxlive.com/authorize") - .header("MS-CV", self.next_cv()) + .post(Constants::XBOX_TITLE_AUTH_URL) + .header("x-xbl-contract-version", "1") + .add_cv(&mut self.ms_cv)? .json(&json_body) .sign(&self.request_signer, None)? + .log() + .await? .send() .await? - .json::() + .log() + .await? + .json_ex::() .await - .map_err(|e| e.into()) + .map_err(std::convert::Into::into) } - pub async fn do_xsts_authorization( + /// Authenticates with the Xbox Live service and retrieves an XSTS token. + /// + /// This method sends a POST request to the Xbox Live XSTS Authentication URL, using the provided `relying_party` + /// and optionally `device_token`, `title_token`, and `user_token`. + /// + /// The resulting XSTS token can be used to authenticate with various Xbox Live services. + /// + /// # Arguments + /// + /// * `device_token` - (Optional) The Xbox Live device token. + /// * `title_token` - (Optional) The Xbox Live title token. + /// * `user_token` - (Optional) The Xbox Live user token. + /// * `relying_party` - The relying party of the application. + /// + /// # Errors + /// + /// This method returns an `Error` if the POST request fails or the JSON response cannot be parsed. + pub async fn get_xsts_token( &mut self, - device_token: &str, - title_token: &str, - user_token: &str, + device_token: Option<&response::DeviceToken>, + title_token: Option<&response::TitleToken>, + user_token: Option<&response::UserToken>, relying_party: &str, - ) -> Result { - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert("x-xbl-contract-version", "1".parse()?); - headers.insert("MS-CV", self.next_cv().parse()?); + ) -> Result { + let dtoken = device_token.map(|t| t.token.clone()); + let ttoken = title_token.map(|t| t.token.clone()); - let json_body = request::XSTSRequest { + let json_body = XTokenRequest:: { relying_party, token_type: "JWT", - properties: request::XSTSProperties { - sandbox_id: "RETAIL", - device_token, - title_token, - user_tokens: vec![user_token], + properties: XSTSProperties { + sandbox_id: &self.sandbox_id, + device_token: dtoken.as_deref(), + title_token: ttoken.as_deref(), + user_tokens: if let Some(token) = user_token { + vec![&token.token] + } else { + vec![] + }, }, }; self.client - .post("https://xsts.auth.xboxlive.com/xsts/authorize") - .headers(headers) + .post(Constants::XBOX_XSTS_AUTH_URL) + .header("x-xbl-contract-version", "1") + .add_cv(&mut self.ms_cv)? .json(&json_body) .sign(&self.request_signer, None)? + .log() + .await? .send() .await? - .json::() + .log() + .await? + .json_ex::() .await - .map_err(|e| e.into()) + .map_err(std::convert::Into::into) } } #[cfg(test)] mod test { + use std::time::Duration; + use oauth2::{basic::BasicTokenType, RequestTokenError}; + use super::*; + + fn parse_authorization_code_response( + url: &'static str, + state: Option, + ) -> Result { + let url = Url::parse(url)?; + let csrf_state = state.map(CsrfToken::new); + XalAuthenticator::parse_authorization_code_response(&url, csrf_state.as_ref()) + } + + #[test] + #[should_panic = "UrlParseError"] + fn authorization_code_response_empty() { + parse_authorization_code_response("", None).unwrap(); + } + + #[test] + #[should_panic = "Expected 'state'"] + fn authorization_code_response_no_state() { + parse_authorization_code_response( + "ms-xal-public-beta-000000004c20a908://?code=123", + Some("ABC".to_string()), + ) + .unwrap(); + } + + #[test] + fn authorization_code_response_valid_code() { + let ret = parse_authorization_code_response( + "ms-xal-public-beta-000000004c20a908://?code=123", + None, + ) + .unwrap(); + assert_eq!(ret.secret(), "123") + } + #[test] - fn test() { - assert_eq!(true, true); + fn authorization_code_response_error() { + let ret = parse_authorization_code_response("ms-xal-public-beta-000000004c20a908://?error=unknown_error&error_description=some_error_desc&error_uri=some_error_uri", None); + + assert!(ret.is_err()); + match ret { + Err(Error::OAuthExecutionError(RequestTokenError::ServerResponse(err))) => { + assert_eq!( + err.error(), + &BasicErrorResponseType::Extension("unknown_error".to_string()) + ); + assert_eq!( + err.error_description(), + Some(&"some_error_desc".to_string()) + ); + assert_eq!(err.error_uri(), Some(&"some_error_uri".to_string())); + } + _ => panic!("Unexpected error for this test"), + } + } + + #[test] + fn implicit_grant_parsing() { + let redirect_url = Url::parse("https://login.live.com/oauth20_desktop.srf?lc=1033#access_token=EwAYA%2bSpFvzVdqQK4qPrPticr6YwVhZtUQYx8QEmKUlOy48j/DFgHrsUGfdoE7UnUZ6thRthHLq5YHXpn1rvRA7hGWiilt08MnbIEVy0ZPCyNx/1yiHe4Y7iEs40TrkH6i7FW3B0sk2WPuFQFI8B592TnR74yxpdMntpzpPdM34gdPGPtBDLiIvHdvRJMMj95JsOm/f2MZiQd/3L0L92CIAwwUdx/HLrfw85Va6jsL2y39bxI56xivbMj6e6eAFRX3eMfH8lRiO2Ro58KnG8fFncduisPOAVf2fcsQM0DxjboKLSUgB7d4qwc0iIMcrdvCQjCi2d202tXPlwjKYCgeYls7nEn3xGu31dvbygnGrz/jeO1NwWzrrSAqxk9sF0sEwm1hsyES2Q5RVJkj4xvHNMLSsXGmZMe7yRqPUU7enRii8Jg7NGIvA&token_type=bearer&expires_in=86400&scope=service::user.auth.xboxlive.com::MBI_SSL&refresh_token=M.C104_BL2.-AfLD2hcS42M2c867oIQF27DQ5ldU9JNt5yfOv5V2picnPBloUElr5I7Qg25xkjaKGifYXRwJUtJi1gT6JBr3d2fLE8Gh323VS9Oz3pk89ygxxkPjQlhIlx6m6F1t919SaqJw3tXID5OT8EmadB4vLcjcLotlS2l2CMuo4q/lP/DyJqQ5pzryGdRWu4oHott4Ubylo8r3qUw9JgntYHTBxbo2kJFkkTp8ue6Yd82kXQNBEhVqmpKzE6eGeNX5HzD35MFis2YlAndF8QW8GnGW9X3zIKHvOVOG4XJt8ZLI81LDMoiAaYJ7kPDEXtDXmWxoGaOpR/Zff6PkyKEbuWS0ZxPtQqqWH2efnx4SODcz7WjaM3DerG6DQJhqFHtGOa3MwMNw420Zl1SQIVlIksLpGZmiSqCBKRyjSbuddZQ2away4Q%24%24&user_id=0787a39b92e882f4d7e&state=XpdMe7g3jH5UZQ").expect("Failed to parse URL"); + let state = CsrfToken::new("XpdMe7g3jH5UZQ".to_string()); + let live_tokens = XalAuthenticator::parse_implicit_grant_url(&redirect_url, Some(&state)) + .expect("Failed parsing.."); + + assert_eq!(live_tokens.token_type(), &BasicTokenType::Bearer); + assert_eq!(live_tokens.access_token().secret(), "EwAYA+SpFvzVdqQK4qPrPticr6YwVhZtUQYx8QEmKUlOy48j/DFgHrsUGfdoE7UnUZ6thRthHLq5YHXpn1rvRA7hGWiilt08MnbIEVy0ZPCyNx/1yiHe4Y7iEs40TrkH6i7FW3B0sk2WPuFQFI8B592TnR74yxpdMntpzpPdM34gdPGPtBDLiIvHdvRJMMj95JsOm/f2MZiQd/3L0L92CIAwwUdx/HLrfw85Va6jsL2y39bxI56xivbMj6e6eAFRX3eMfH8lRiO2Ro58KnG8fFncduisPOAVf2fcsQM0DxjboKLSUgB7d4qwc0iIMcrdvCQjCi2d202tXPlwjKYCgeYls7nEn3xGu31dvbygnGrz/jeO1NwWzrrSAqxk9sF0sEwm1hsyES2Q5RVJkj4xvHNMLSsXGmZMe7yRqPUU7enRii8Jg7NGIvA"); + assert_eq!(live_tokens.refresh_token().unwrap().secret(), "M.C104_BL2.-AfLD2hcS42M2c867oIQF27DQ5ldU9JNt5yfOv5V2picnPBloUElr5I7Qg25xkjaKGifYXRwJUtJi1gT6JBr3d2fLE8Gh323VS9Oz3pk89ygxxkPjQlhIlx6m6F1t919SaqJw3tXID5OT8EmadB4vLcjcLotlS2l2CMuo4q/lP/DyJqQ5pzryGdRWu4oHott4Ubylo8r3qUw9JgntYHTBxbo2kJFkkTp8ue6Yd82kXQNBEhVqmpKzE6eGeNX5HzD35MFis2YlAndF8QW8GnGW9X3zIKHvOVOG4XJt8ZLI81LDMoiAaYJ7kPDEXtDXmWxoGaOpR/Zff6PkyKEbuWS0ZxPtQqqWH2efnx4SODcz7WjaM3DerG6DQJhqFHtGOa3MwMNw420Zl1SQIVlIksLpGZmiSqCBKRyjSbuddZQ2away4Q$$"); + //assert_eq!(live_tokens.user_id(), "0787a39b92e882f4d7e"); + assert_eq!(live_tokens.expires_in(), Some(Duration::from_secs(86400))); + assert_eq!(live_tokens.scopes().unwrap().len(), 1); + assert_eq!( + live_tokens.scopes().unwrap().first().unwrap().to_string(), + "service::user.auth.xboxlive.com::MBI_SSL" + ); } } diff --git a/src/bin/auth-cli.rs b/src/bin/auth-cli.rs deleted file mode 100644 index f66b55a..0000000 --- a/src/bin/auth-cli.rs +++ /dev/null @@ -1,146 +0,0 @@ -use chrono::Utc; -use reqwest::Url; -use std::io; -use xal::authenticator::XalAuthenticator; -use xal::oauth2::PkceCodeVerifier; -use xal::utils::TokenStore; - -const TOKENS_FILEPATH: &str = "tokens.json"; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let mut xal = XalAuthenticator::default(); - - if let Ok(mut ts) = TokenStore::load(TOKENS_FILEPATH) { - let refreshed_xcoud = xal - .exchange_refresh_token_for_xcloud_transfer_token(&ts.xcloud_transfer_token.into()) - .await?; - println!("{:?}", refreshed_xcoud); - - ts.xcloud_transfer_token = refreshed_xcoud; - ts.updated = Utc::now(); - ts.save(TOKENS_FILEPATH)?; - - return Ok(()); - } - - let (code_challenge, code_verifier) = XalAuthenticator::get_code_challenge(); - - println!("Getting device token..."); - let device_token = xal.get_device_token().await?; - println!("Device token={:?}", device_token); - - let state = XalAuthenticator::generate_random_state(); - - println!("Fetching SISU authentication URL..."); - let (sisu_response, sisu_session_id) = xal - .do_sisu_authentication(&device_token.token_data.token, code_challenge, &state) - .await?; - - println!( - r#"!!! ACTION REQUIRED !!! -Navigate to this URL and authenticate: {} -When finished, paste the Redirect URL and hit [ENTER]"#, - sisu_response.msa_oauth_redirect - ); - - let mut redirect_uri = String::new(); - let _ = io::stdin().read_line(&mut redirect_uri)?; - - // Check if redirect URI has expected scheme - println!("Checking redirect URI..."); - let expected_scheme = xal.get_redirect_uri().scheme().to_owned(); - if !redirect_uri.starts_with(&expected_scheme) { - return Err(format!( - "Invalid redirect URL, expecting scheme: {}", - expected_scheme - ) - .into()); - } - - // Parse redirect URI - let parsed_url = Url::parse(&redirect_uri)?; - // Extract query parameters {code, state} - let mut code_query: Option = None; - let mut state_query: Option = None; - - for i in parsed_url.query_pairs() { - if i.0 == "code" { - code_query = Some(i.1.into_owned()) - } else if i.0 == "state" { - state_query = Some(i.1.into_owned()) - } - } - - println!("Verifying state..."); - if let Some(returned_state) = &state_query { - let valid_state = &state == returned_state; - println!( - "State valid: {} ({} vs. {})", - valid_state, state, returned_state - ); - } else { - println!("WARN: No state query returned!"); - } - - if let Some(authorization_code) = code_query { - println!("Authorization Code: {}", &authorization_code); - let local_code_verifier = PkceCodeVerifier::new(code_verifier.secret().clone()); - - println!("Getting WL tokens..."); - let wl_token = xal - .exchange_code_for_token(&authorization_code, local_code_verifier) - .await - .expect("Failed exchanging code for token"); - let wl_token_clone = wl_token.clone(); - println!("WL={:?}", wl_token); - - println!("Attempting SISU authorization..."); - let auth_response = xal - .do_sisu_authorization( - &sisu_session_id, - wl_token.access_token.secret(), - &device_token.token_data.token, - ) - .await?; - println!("SISU={:?}", auth_response); - - println!("Getting GSSV token..."); - // Fetch GSSV (gamestreaming) token - let gssv_token = xal - .do_xsts_authorization( - &auth_response.device_token, - &auth_response.title_token.token_data.token, - &auth_response.user_token.token_data.token, - "http://gssv.xboxlive.com/", - ) - .await?; - println!("GSSV={:?}", gssv_token); - - println!("Getting XCloud transfer token..."); - // Fetch XCloud transfer token - let transfer_token = xal - .exchange_refresh_token_for_xcloud_transfer_token( - &wl_token - .refresh_token - .expect("Failed to unwrap refresh token"), - ) - .await?; - println!("Transfer token={:?}", transfer_token); - - let ts = TokenStore { - app_params: xal.app_params(), - client_params: xal.client_params(), - wl_token: wl_token_clone, - sisu_tokens: auth_response, - gssv_token, - xcloud_transfer_token: transfer_token, - updated: Utc::now(), - }; - ts.save(TOKENS_FILEPATH)? - } else { - println!("No authorization code fetched :("); - } - - Ok(()) -} diff --git a/src/bin/auth-webview.rs b/src/bin/auth-webview.rs deleted file mode 100644 index 52f59a0..0000000 --- a/src/bin/auth-webview.rs +++ /dev/null @@ -1,200 +0,0 @@ -// Copyright 2020-2022 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -use chrono::Utc; -use tauri::async_runtime; -use wry::{ - application::{ - event::{Event, StartCause, WindowEvent}, - event_loop::{ControlFlow, EventLoop}, - window::WindowBuilder, - }, - webview::{Url, WebViewBuilder}, -}; -use xal::oauth2::PkceCodeVerifier; -use xal::{authenticator::XalAuthenticator, utils::TokenStore}; - -const TOKENS_FILEPATH: &str = "tokens.json"; - -async fn continue_auth( - xal: &mut XalAuthenticator, - code_verifier: &PkceCodeVerifier, - authorization_code: &str, - sisu_session_id: &str, - device_token: &str, -) -> std::result::Result<(), Box> { - println!("Authorization Code: {}", &authorization_code); - let local_code_verifier = PkceCodeVerifier::new(code_verifier.secret().clone()); - let wl_token = xal - .exchange_code_for_token(authorization_code, local_code_verifier) - .await - .expect("Failed exchanging code for token"); - let wl_token_clone = wl_token.clone(); - println!("WL={:?}", wl_token); - - let auth_response = xal - .do_sisu_authorization( - sisu_session_id, - wl_token.access_token.secret(), - device_token, - ) - .await?; - println!("SISU={:?}", auth_response); - - // Fetch GSSV (gamestreaming) token - let gssv_token = xal - .do_xsts_authorization( - &auth_response.device_token, - &auth_response.title_token.token_data.token, - &auth_response.user_token.token_data.token, - "http://gssv.xboxlive.com/", - ) - .await?; - println!("GSSV={:?}", gssv_token); - - // Fetch XCloud transfer token - let transfer_token = xal - .exchange_refresh_token_for_xcloud_transfer_token( - &wl_token - .refresh_token - .expect("Failed to unwrap refresh token"), - ) - .await?; - println!("Transfer token={:?}", transfer_token); - - let ts = TokenStore { - app_params: xal.app_params(), - client_params: xal.client_params(), - wl_token: wl_token_clone, - sisu_tokens: auth_response, - gssv_token, - xcloud_transfer_token: transfer_token, - updated: Utc::now(), - }; - ts.save(TOKENS_FILEPATH) -} - -enum UserEvent { - Navigation(String), -} - -fn main() -> wry::Result<()> { - let mut xal = XalAuthenticator::default(); - - if let Ok(mut ts) = TokenStore::load(TOKENS_FILEPATH) { - let refreshed_xcoud = async_runtime::block_on( - xal.exchange_refresh_token_for_xcloud_transfer_token(&ts.xcloud_transfer_token.into()), - ) - .expect("Failed to exchange refresh token for fresh XCloud transfer token"); - - println!("{:?}", refreshed_xcoud); - ts.xcloud_transfer_token = refreshed_xcoud; - ts.updated = Utc::now(); - ts.save(TOKENS_FILEPATH) - .expect("Failed to save refreshed XCloud token"); - - return Ok(()); - } - - let (code_challenge, code_verifier) = XalAuthenticator::get_code_challenge(); - let device_token = - async_runtime::block_on(xal.get_device_token()).expect("Failed to fetch device token"); - - println!("Device token={:?}", device_token); - - let state = XalAuthenticator::generate_random_state(); - - let (sisu_response, sisu_session_id) = async_runtime::block_on(xal.do_sisu_authentication( - &device_token.token_data.token, - code_challenge, - &state, - )) - .unwrap(); - - let redirect_uri = xal.get_redirect_uri(); - let auth_url = sisu_response.msa_oauth_redirect; - - let event_loop: EventLoop = EventLoop::with_user_event(); - let proxy = event_loop.create_proxy(); - let window = WindowBuilder::new() - .with_title("Hello World") - .build(&event_loop) - .unwrap(); - - let webview = WebViewBuilder::new(window) - .unwrap() - // tell the webview to load the custom protocol - .with_url(&auth_url)? - .with_devtools(true) - .with_navigation_handler(move |uri: String| { - proxy.send_event(UserEvent::Navigation(uri)).is_ok() - }) - .build()?; - - event_loop.run(move |event, _, control_flow| { - *control_flow = ControlFlow::Wait; - - match event { - Event::NewEvents(StartCause::Init) => println!("Wry application started!"), - Event::WindowEvent { - event: WindowEvent::Moved { .. }, - .. - } => { - let _ = webview.evaluate_script("console.log('hello');"); - } - Event::UserEvent(UserEvent::Navigation(uri)) => { - if uri.starts_with(redirect_uri.scheme()) { - let url = Url::parse(&uri).expect("Failed to parse redirect URL"); - - let mut code_query: Option = None; - let mut state_query: Option = None; - - for i in url.query_pairs() { - if i.0 == "code" { - code_query = Some(i.1.into_owned()) - } else if i.0 == "state" { - state_query = Some(i.1.into_owned()) - } - } - - if let Some(returned_state) = &state_query { - let valid_state = &state == returned_state; - println!( - "State valid: {} ({} vs. {})", - valid_state, state, returned_state - ); - } else { - println!("WARN: No state query returned!"); - } - - if let Some(authorization_code) = code_query { - match async_runtime::block_on(continue_auth( - &mut xal, - &code_verifier, - &authorization_code, - &sisu_session_id, - &device_token.token_data.token, - )) { - Ok(_) => { - println!("SISU authentication succeeded! :)"); - } - Err(err) => { - println!("Failed SISU auth :( - details: {}", err); - } - } - } else { - println!("No authorization code fetched :("); - } - - *control_flow = ControlFlow::Exit; - } - } - Event::WindowEvent { - event: WindowEvent::CloseRequested, - .. - } => *control_flow = ControlFlow::Exit, - _ => (), - } - }); -} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..9d8dd80 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,71 @@ +//! Definition of custom error type. +//! + +use oauth2::{ + basic::BasicErrorResponse, reqwest::AsyncHttpClientError, DeviceCodeErrorResponse, + RequestTokenError, +}; +use reqwest::header::InvalidHeaderValue; +use thiserror::Error; +use url::ParseError; + +/// Custom Error type +#[derive(Error, Debug)] +pub enum Error { + /// URL failed parsing + #[error("Url parsing failed")] + UrlParseError(#[from] ParseError), + /// OAuth2 failed + #[error("Failed executing oauth request")] + OAuthExecutionError(#[from] RequestTokenError), + /// OAuth2 device code flow failed + #[error("Failed executing oauth request")] + OAuthDeviceCodeError(#[from] RequestTokenError), + /// Generic Reqwest HTTP client error + #[error("Reqwest error")] + ReqwestError(#[from] reqwest::Error), + /// Generic HTTP error + #[error("HTTP error")] + HttpError(#[from] http::Error), + /// Invalid redirection URL + #[error("Invalid redirect url")] + InvalidRedirectUrl(String), + /// Invalid HTTP header + #[error("Invalid header")] + InvalidHeader(#[from] InvalidHeaderValue), + /// Invalid HTTP Signature + #[error("Signature error")] + SignatureError(#[from] p256::ecdsa::Error), + /// General error + #[error("General error")] + GeneralError(String), + /// JSON De/Serialization error + #[error("JSON De/Serialization error")] + JsonError(#[from] serde_json::Error), + /// HTTP response failed to parse into JSON model + #[error("HTTP JSON Deserialization")] + JsonHttpResponseError { + /// HTTP status code + status: http::StatusCode, + /// Target HTTP url + url: String, + /// HTTP headers + headers: http::HeaderMap, + /// HTTP message body + body: String, + /// [`serde_json`] error + inner: serde_json::Error, + }, + /// Base64 decoding error + #[error("Base64 Decode error")] + DecodeError(#[from] base64ct::Error), + /// I/O error + #[error("I/O error")] + IoError(#[from] std::io::Error), + /// Failed processing HTTP request + #[error("Failed processing HTTP request")] + InvalidRequest(String), + /// Unknown error + #[error("unknown xal error")] + Unknown, +} diff --git a/src/extensions.rs b/src/extensions.rs new file mode 100644 index 0000000..e7e4257 --- /dev/null +++ b/src/extensions.rs @@ -0,0 +1,150 @@ +//! Extensions to reqwest HTTP client library. +//! +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use cvlib::CorrelationVector; +use http::response::Builder; +use log::{debug, trace}; +use reqwest::ResponseBuilderExt; + +use crate::{error::Error, request_signer::RequestSigner, RequestSigning}; + +/// Extension to [`reqwest::RequestBuilder`] allowing for verbosely logging the request +#[async_trait] +pub trait LoggingReqwestRequestHandler { + /// Log request (debug-loglevel) + async fn log(self) -> Result; +} + +#[async_trait] +impl LoggingReqwestRequestHandler for reqwest::RequestBuilder { + async fn log(self) -> Result { + if let Some(rb) = self.try_clone() { + let req = rb.build()?; + let body = match req.body() { + Some(body) => { + let b = body.as_bytes().unwrap(); + std::str::from_utf8(b) + } + None => Ok(""), + }; + + debug!("[*] Request: {:?}, Body: {:?}", req, body); + }; + + Ok(self) + } +} + +/// Extension to [`reqwest::Response`] allowing for verbosely logging the request +#[async_trait] +pub trait LoggingReqwestResponseHandler { + /// Log response (debug-loglevel) + async fn log(self) -> Result; +} + +#[async_trait] +impl LoggingReqwestResponseHandler for reqwest::Response { + async fn log(self) -> Result { + let mut response_builder = Builder::new() + .url(self.url().to_owned()) + .status(self.status()); + + let headers = self.headers().clone(); + let hdr_mut = response_builder.headers_mut().unwrap(); + headers.into_iter().for_each(|(key, val)| { + hdr_mut.insert(key.unwrap(), val); + }); + + let new_resp = response_builder + .body(self.bytes().await?) + .expect("Failed to attach body to new response"); + + debug!("[*] Response: {:?}", new_resp); + + Ok(reqwest::Response::from(new_resp)) + } +} + +/// Extension to [`reqwest::Response`] allowing for returning more-verbose error +/// on deserialization failure +#[async_trait] +pub trait JsonExDeserializeMiddleware { + /// Deserialize JSON response into struct implementing [`serde::de::DeserializeOwned`] + /// + /// If response body fails to deserialize, return verbose [`crate::Error`] + async fn json_ex(self) -> Result; +} + +#[async_trait] +impl JsonExDeserializeMiddleware for reqwest::Response { + async fn json_ex(self) -> Result { + let http_status = self.status(); + let url = self.url().to_owned(); + let headers = self.headers().to_owned(); + + let full = self.bytes().await?; + + let res = serde_json::from_slice::(&full).map_err(|e| Error::JsonHttpResponseError { + status: http_status, + url: url.to_string(), + headers, + body: String::from_utf8_lossy(&full).to_string(), + inner: e, + }); + + res + } +} + +/// Extension to [`reqwest::RequestBuilder`] for signing HTTP requests according to Xbox Live specs +pub trait SigningReqwestBuilder { + /// Sign HTTP request for Xbox Live + fn sign( + self, + signer: &RequestSigner, + timestamp: Option>, + ) -> Result; +} + +impl SigningReqwestBuilder for reqwest::RequestBuilder { + fn sign( + self, + signer: &RequestSigner, + timestamp: Option>, + ) -> Result { + match self.try_clone() { + Some(rb) => { + let request = rb.build()?; + // Fallback to Utc::now() internally + let signed = signer.sign_request(request, timestamp)?; + let body_bytes = signed + .body() + .ok_or(Error::InvalidRequest("Failed getting request body".into()))? + .as_bytes() + .ok_or(Error::InvalidRequest( + "Failed getting bytes from request body".into(), + ))? + .to_vec(); + let headers = signed.headers().clone(); + + Ok(self.headers(headers).body(body_bytes)) + } + None => Err(Error::InvalidRequest("Failed to clone request".into())), + } + } +} + +/// Extension to [`reqwest::RequestBuilder`] for adding [`cvlib::CorrelationVector`] to request headers +pub trait CorrelationVectorReqwestBuilder { + /// Add HTTP header `MS-CV` into headers + fn add_cv(self, cv: &mut CorrelationVector) -> Result; +} + +impl CorrelationVectorReqwestBuilder for reqwest::RequestBuilder { + fn add_cv(self, cv: &mut CorrelationVector) -> Result { + cv.increment(); + trace!("Injecting MS-CV: {}", cv); + Ok(self.header("MS-CV", cv.to_string())) + } +} diff --git a/src/flows.rs b/src/flows.rs new file mode 100644 index 0000000..1abc6c2 --- /dev/null +++ b/src/flows.rs @@ -0,0 +1,563 @@ +//! Higher-level, bundled functionality for common tasks +use async_trait::async_trait; +use log::{debug, info, trace}; +use oauth2::{ + EndUserVerificationUrl, StandardDeviceAuthorizationResponse, TokenResponse, UserCode, + VerificationUriComplete, +}; +use url::Url; + +use crate::{ + response::{SisuAuthenticationResponse, WindowsLiveTokens}, + tokenstore::TokenStore, + Error, XalAuthenticator, AccessTokenPrefix, +}; + +/// Argument passed into [`crate::flows::AuthPromptCallback`] +#[derive(Debug)] +pub enum AuthPromptData { + /// User action request for authorization code / implict grant flow + /// It requires the user to visit an URL and pass back the returned redirect URL + RedirectUrl { + /// Prompt message for the user + prompt: String, + /// URL to use for authentication + url: EndUserVerificationUrl, + /// Whether the caller expects a redirect URL with authorization data + expect_url: bool, + }, + + /// User action request for device code flow + /// It should return directly after showing the action prompt to the user + DeviceCode { + /// Prompt message for the user + prompt: String, + /// URL to use for authentication + url: EndUserVerificationUrl, + /// Code the user has to enter in the webform to authenticate + code: UserCode, + /// The complete URL with pre-filled UserCode + full_verificiation_url: VerificationUriComplete, + /// Whether the caller expects a redirect URL + expect_url: bool, + }, +} + +impl From for AuthPromptData { + fn from(value: SisuAuthenticationResponse) -> Self { + Self::RedirectUrl { + prompt: format!( + "!!! ACTION REQUIRED !!!\nNavigate to this URL and authenticate: {0} (Query params: {1:?})\n + \nThen enter the resulting redirected URL (might need to open DevTools in your browser before opening the link)", + value.msa_oauth_redirect, + value.msa_request_parameters, + ), + url: EndUserVerificationUrl::from_url(value.msa_oauth_redirect.clone()), + expect_url: true, + } + } +} + +impl From for AuthPromptData { + fn from(value: StandardDeviceAuthorizationResponse) -> Self { + let user_code = value.user_code().to_owned(); + let verification_uri = value.verification_uri().to_owned(); + let full_url = XalAuthenticator::get_device_code_verification_uri(value.user_code()); + + Self::DeviceCode { + prompt: format!( + "!!! ACTION REQUIRED !!!\nNavigate to this URL and authenticate: {0}\nUse code: {1}\n\nAlternatively, use this link: {2}", + verification_uri.as_str(), + user_code.secret(), + full_url.secret(), + ), + url: verification_uri, + code: user_code, + full_verificiation_url: full_url, + expect_url: false, + } + } +} + +impl From for AuthPromptData { + fn from(value: EndUserVerificationUrl) -> Self { + Self::RedirectUrl { + prompt: format!( + "!!! ACTION REQUIRED !!!\nNavigate to this URL and authenticate: {0}\nNOTE: You might have to open DevTools when navigating the flow to catch redirect", + value.as_str() + ), + url: value.to_owned(), + expect_url: true, + } + } +} + +impl AuthPromptData { + /// Return user prompt string aka. instructions of which URL the user needs to visit to authenticate + pub fn prompt(&self) -> String { + match self { + AuthPromptData::RedirectUrl { prompt, .. } => prompt.to_owned(), + AuthPromptData::DeviceCode { prompt, .. } => prompt.to_owned(), + } + } + + /// Return whether the callback expects n URL as return value + pub fn expect_url(&self) -> bool { + match self { + AuthPromptData::RedirectUrl { expect_url, .. } => *expect_url, + AuthPromptData::DeviceCode { expect_url, .. } => *expect_url, + } + } + + /// Returns the authentication URL + pub fn authentication_url(&self) -> Url { + match self { + AuthPromptData::RedirectUrl { url, .. } => Url::parse(url.as_str()).unwrap(), + AuthPromptData::DeviceCode { + full_verificiation_url, + .. + } => Url::parse(full_verificiation_url.secret()).unwrap(), + } + } +} + +/// Sisu Auth callback trait +/// +/// Used as an argument to [`XalAuthenticator.do_sisu_flow`](crate::XalAuthenticator#method.do_sisu_flow) +/// +/// # Examples +/// +/// ``` +/// # use std::io; +/// # use async_trait::async_trait; +/// # use xal::XalAuthenticator; +/// # use xal::flows::{AuthPromptCallback, AuthPromptData}; +/// # use xal::url::Url; +/// // Define callback handler for OAuth2 flow +/// struct CallbackHandler; +/// # fn do_interactive_oauth2(url: &str) -> String { String::new() } +/// +/// #[async_trait] +/// impl AuthPromptCallback for CallbackHandler { +/// async fn call( +/// &self, +/// cb_data: AuthPromptData +/// ) -> Result, Box> +/// { +/// let prompt = cb_data.prompt(); +/// let do_expect_url = cb_data.expect_url(); +/// println!("{prompt}\n"); +/// +/// let res = if do_expect_url { +/// // Read pasted URL from terminal +/// println!("Redirect URL> "); +/// let mut redirect_url = String::new(); +/// let _ = io::stdin().read_line(&mut redirect_url)?; +/// Some(Url::parse(&redirect_url)?) +/// } else { +/// // Callback does not expect any user input, just return +/// None +/// }; +/// +/// Ok(res) +/// } +/// } +/// ``` +#[async_trait] +pub trait AuthPromptCallback { + /// Callback function that is called when the Authentication flow requires the user to perform interactive authentication via a webpage. + /// + /// This function takes an argument of type [`crate::flows::AuthPromptData`], which provides the necessary data for the interactive + /// authentication process. + /// + /// The function returns a [`Result`] that represents either a successfully completed interactive authentication or an error that + /// occurred during the process. + /// + /// # Errors + /// + /// This function may return an error if the user fails to perform the interactive authentication process or if there is a problem with the underlying authentication process. + async fn call( + &self, + cb_data: AuthPromptData, + ) -> Result, Box>; +} + +/// Implementation of a cli callback handler +pub struct CliCallbackHandler; + +#[async_trait] +impl AuthPromptCallback for CliCallbackHandler { + async fn call( + &self, + cb_data: AuthPromptData, + ) -> Result, Box> { + let prompt = cb_data.prompt(); + let do_expect_url = cb_data.expect_url(); + + println!("{prompt}\n"); + + let res = if do_expect_url { + // Read pasted URL from terminal + print!("Redirect URL> "); + let mut redirect_url = String::new(); + let _ = std::io::stdin().read_line(&mut redirect_url)?; + Some(Url::parse(&redirect_url)?) + } else { + // Callback does not expect any user input, just return + None + }; + + Ok(res) + } +} + +/// Try to deserialize a JSON TokenStore from filepath and refresh the Windows Live tokens if needed. +/// +/// # Errors +/// +/// This function may return an error if the file cannot be read, fails to deserialize or the +/// tokens cannot be refreshed. +/// +/// # Returns +/// +/// If successful, a tuple of [`crate::XalAuthenticator`] and [`crate::tokenstore::TokenStore`] +/// is returned. TokenStore will contain the refreshed `live_tokens`. +pub async fn try_refresh_tokens_from_file( + filepath: &str, +) -> Result<(XalAuthenticator, TokenStore), Error> { + let ts = TokenStore::load_from_file(filepath)?; + try_refresh_live_tokens_from_tokenstore(ts).await +} + +/// Try to read tokens from the token store and refresh the Windows Live tokens if needed. +/// +/// # Errors +/// +/// This function may return an error if the token store cannot be read or the tokens cannot be refreshed. +/// +/// # Returns +/// +/// If successful, a tuple of [`crate::XalAuthenticator`] and [`crate::tokenstore::TokenStore`] +/// is returned. TokenStore will contain the refreshed `live_tokens`. +pub async fn try_refresh_live_tokens_from_tokenstore( + mut ts: TokenStore, +) -> Result<(XalAuthenticator, TokenStore), Error> { + let mut authenticator = Into::::into(ts.clone()); + + info!("Refreshing windows live tokens"); + let refreshed_wl_tokens = authenticator + .refresh_token(ts.live_token.refresh_token().unwrap()) + .await + .expect("Failed to exchange refresh token for fresh WL tokens"); + + debug!("Windows Live tokens: {:?}", refreshed_wl_tokens); + ts.live_token = refreshed_wl_tokens.clone(); + + Ok((authenticator, ts)) +} + +/// Shorthand for Windows Live device code flow +pub async fn ms_device_code_flow( + mut authenticator: XalAuthenticator, + cb: impl AuthPromptCallback, + sleep_fn: S, +) -> Result<(XalAuthenticator, TokenStore), Error> +where + S: Fn(std::time::Duration) -> SF, + SF: std::future::Future, +{ + trace!("Initiating device code flow"); + let device_code_flow = authenticator + .initiate_device_code_auth() + .await?; + debug!("Device code={:?}", device_code_flow); + + trace!("Reaching into callback to notify caller about device code url"); + cb.call(device_code_flow.clone().into()) + .await + .map_err(|e| Error::GeneralError(format!("Failed getting redirect URL err={e}")))?; + + trace!("Polling for device code"); + let live_tokens = authenticator + .poll_device_code_auth(&device_code_flow, sleep_fn) + .await?; + + let ts = TokenStore { + app_params: authenticator.app_params(), + client_params: authenticator.client_params(), + sandbox_id: authenticator.sandbox_id(), + live_token: live_tokens, + user_token: None, + title_token: None, + device_token: None, + authorization_token: None, + updated: None, + }; + + Ok((authenticator, ts)) +} + +/// Shorthand for Windows Live authorization flow +/// - Depending on the argument `implicit` the +/// methods `implicit grant` or `authorization code` are chosen +pub async fn ms_authorization_flow( + mut authenticator: XalAuthenticator, + cb: impl AuthPromptCallback, + implicit: bool, +) -> Result<(XalAuthenticator, TokenStore), Error> { + trace!("Starting implicit authorization flow"); + + let (url, state) = + authenticator.get_authorization_url(implicit)?; + + trace!("Reaching into callback to receive authentication redirect URL"); + let redirect_url = cb + .call(url.into()) + .await + .map_err(|e| Error::GeneralError(format!("Failed getting redirect URL err={e}")))? + .ok_or(Error::GeneralError( + "Failed receiving redirect URL".to_string(), + ))?; + + debug!("From callback: Redirect URL={:?}", redirect_url); + + let live_tokens = if implicit { + trace!("Parsing (implicit grant) redirect URI"); + XalAuthenticator::parse_implicit_grant_url(&redirect_url, Some(&state))? + } else { + trace!("Parsing (authorization code) redirect URI"); + let authorization_code = + XalAuthenticator::parse_authorization_code_response(&redirect_url, Some(&state))?; + debug!("Authorization Code: {:?}", &authorization_code); + + trace!("Getting Windows Live tokens (exchange code)"); + authenticator + .exchange_code_for_token(authorization_code, None) + .await? + }; + + let ts = TokenStore { + app_params: authenticator.app_params(), + client_params: authenticator.client_params(), + sandbox_id: authenticator.sandbox_id(), + live_token: live_tokens, + user_token: None, + title_token: None, + device_token: None, + authorization_token: None, + updated: None, + }; + + Ok((authenticator, ts)) +} + +/// Shorthand for sisu authentication flow +pub async fn xbox_live_sisu_full_flow( + mut authenticator: XalAuthenticator, + callback: impl AuthPromptCallback, +) -> Result<(XalAuthenticator, TokenStore), Error> { + trace!("Getting device token"); + let device_token = authenticator.get_device_token().await?; + debug!("Device token={:?}", device_token); + let (code_challenge, code_verifier) = XalAuthenticator::generate_code_verifier(); + trace!("Generated Code verifier={:?}", code_verifier); + trace!("Generated Code challenge={:?}", code_challenge); + let state = XalAuthenticator::generate_random_state(); + trace!("Generated random state={:?}", state); + + trace!("Fetching SISU authentication URL and Session Id"); + let (auth_resp, session_id) = authenticator + .sisu_authenticate(&device_token, &code_challenge, &state) + .await?; + debug!( + "SISU Authenticate response={:?} Session Id={:?}", + auth_resp, session_id + ); + + // Passing redirect URL to callback and expecting redirect url + authorization token back + trace!("Reaching into callback to receive authentication redirect URL"); + let redirect_url = callback + .call(auth_resp.into()) + .await + .map_err(|e| Error::GeneralError(format!("Failed getting redirect URL err={e}")))? + .ok_or(Error::GeneralError( + "Did not receive any Redirect URL from RedirectUrl callback".to_string(), + ))?; + + debug!("From callback: Redirect URL={:?}", redirect_url); + + trace!("Parsing redirect URI"); + let authorization_code = + XalAuthenticator::parse_authorization_code_response(&redirect_url, Some(&state))?; + + debug!("Authorization Code: {:?}", &authorization_code); + trace!("Getting Windows Live tokens (exchange code)"); + let live_tokens = authenticator + .exchange_code_for_token(authorization_code, Some(code_verifier)) + .await?; + debug!("Windows live tokens={:?}", &live_tokens); + + trace!("Getting Sisu authorization response"); + let sisu_resp = authenticator + .sisu_authorize(&live_tokens, &device_token, Some(session_id)) + .await?; + debug!("Sisu authorizatione response={:?}", sisu_resp); + + let ts = TokenStore { + app_params: authenticator.app_params(), + client_params: authenticator.client_params(), + sandbox_id: authenticator.sandbox_id(), + live_token: live_tokens, + device_token: Some(device_token), + user_token: Some(sisu_resp.user_token), + title_token: Some(sisu_resp.title_token), + authorization_token: Some(sisu_resp.authorization_token), + + updated: None, + }; + + Ok((authenticator, ts)) +} + +/// Implements the traditional Xbox Live authorization flow. +/// +/// The method serves as a shorthand for executing the Xbox Live authorization flow by exchanging +/// [`crate::models::response::WindowsLiveTokens`] to ultimately acquire an authorized Xbox Live session. +/// +/// The authorization flow is designed to be highly modular, allowing for extensive customization +/// based on the specific needs of your application. +/// +/// # Arguments +/// +/// - `xsts_relying_party` XSTS Relying Party URL (see #Notes) +/// - `access_token_prefix` Whether AccessToken needs to be prefixed for the Xbox UserToken (XASU) Request (see #Notes). +/// - `request_title_token` Whether to request a Title Token (see #Notes) +/// +/// # Errors +/// +/// This method may return an error if any of the intermediate token requests fail. +/// For a more detailed explanation of the error, refer to the documentation of the +/// [`crate::XalAuthenticator`] methods. +/// +/// # Returns +/// +/// This method returns a `Result` containing a tuple with two elements: +/// +/// - The updated `XalAuthenticator` instance, with an incremented [`crate::cvlib::CorrelationVector`] +/// - A `TokenStore` struct, with all the tokens necessary exchanged during the authorization flow. +/// +/// # Examples +/// +/// ``` +/// # async fn example() -> Result<(), Error> { +/// // Assume the following authenticator and tokens have been obtained.. +/// let mut authenticator = XalAuthenticator::default(); +/// let live_tokens = WindowsLiveTokens { /*...*/ }; +/// +/// // Execute the Xbox Live authorization flow.. +/// let (authenticator, token_store) = authenticator +/// .do_xbox_live_authorization_flow( +/// live_tokens, +/// "rp://api.minecraftservices.com/".to_string(), +/// AccessTokenPrefix::MSAL, +/// true, +/// ) +/// .await?; +/// # Ok(()) +/// # } +/// ``` +/// +/// # Notes +/// +/// - Requesting a Title Token *standalone* aka. without sisu-flow only works for very few clients, +/// currently only "Minecraft" is known. +/// - Depending on the client an AccessToken prefix is necessary to have the User Token (XASU) request succeed +/// - Success of authorizing (device, user, ?title?) tokens for XSTS relies on the target relying party +pub async fn xbox_live_authorization_traditional_flow( + mut authenticator: XalAuthenticator, + live_tokens: WindowsLiveTokens, + xsts_relying_party: String, + access_token_prefix: AccessTokenPrefix, + request_title_token: bool, +) -> Result<(XalAuthenticator, TokenStore), Error> { + debug!("Windows live tokens={:?}", &live_tokens); + trace!("Getting device token"); + let device_token = authenticator.get_device_token().await?; + debug!("Device token={:?}", device_token); + + trace!("Getting user token"); + let user_token = authenticator + .get_user_token(&live_tokens, access_token_prefix) + .await?; + + debug!("User token={:?}", user_token); + + let maybe_title_token = if request_title_token { + trace!("Getting title token"); + let title_token = authenticator + .get_title_token(&live_tokens, &device_token) + .await?; + debug!("Title token={:?}", title_token); + + Some(title_token) + } else { + debug!("Skipping title token request.."); + None + }; + + trace!("Getting XSTS token"); + let authorization_token = authenticator + .get_xsts_token( + Some(&device_token), + maybe_title_token.as_ref(), + Some(&user_token), + &xsts_relying_party, + ) + .await?; + debug!("XSTS token={:?}", authorization_token); + + let ts = TokenStore { + app_params: authenticator.app_params(), + client_params: authenticator.client_params(), + sandbox_id: authenticator.sandbox_id(), + live_token: live_tokens, + device_token: Some(device_token), + user_token: Some(user_token), + title_token: maybe_title_token, + authorization_token: Some(authorization_token), + + updated: None, + }; + + Ok((authenticator, ts)) +} + +/// bla +pub async fn xbox_live_sisu_authorization_flow( + mut authenticator: XalAuthenticator, + live_tokens: WindowsLiveTokens, +) -> Result<(XalAuthenticator, TokenStore), Error> { + debug!("Windows live tokens={:?}", &live_tokens); + trace!("Getting device token"); + let device_token = authenticator.get_device_token().await?; + debug!("Device token={:?}", device_token); + + trace!("Getting user token"); + let resp = authenticator.sisu_authorize(&live_tokens, &device_token, None).await?; + debug!("Sisu authorization response"); + + let ts = TokenStore { + app_params: authenticator.app_params(), + client_params: authenticator.client_params(), + sandbox_id: authenticator.sandbox_id(), + live_token: live_tokens, + device_token: Some(device_token), + user_token: Some(resp.user_token), + title_token: Some(resp.title_token), + authorization_token: Some(resp.authorization_token), + + updated: None, + }; + + Ok((authenticator, ts)) +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index a63513a..36ee563 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,29 @@ +#![warn(missing_docs)] +#![deny(rustdoc::broken_intra_doc_links)] +//! XAL - Xbox Live Authentication Library for Rust +//! +//! Features: +//! - OAuth2 Authentication via SISU App-Flow - see [`crate::XalAuthenticator`] +//! - HTTP Request Signing - see [`crate::RequestSigner`] +//! - Extensions for Reqwest HTTP client library - see [`crate::extensions`] +//! +//! # Examples +//! +//! For advanced usage, see [`crate::XalAuthenticator`]. + pub use cvlib; pub use oauth2; +pub use url; + +mod authenticator; +mod error; +mod models; +mod request_signer; -pub mod app_params; -pub mod authenticator; -pub mod models; -pub mod request_signer; -pub mod utils; +pub mod extensions; +pub mod flows; +pub mod tokenstore; +pub use authenticator::*; +pub use error::Error; +pub use models::*; +pub use request_signer::*; diff --git a/src/models.rs b/src/models.rs index 12e3957..e8efc29 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,18 +1,70 @@ +//! HTTP Request and Response models and XAL related constants. +use std::str::FromStr; + +use base64ct::Encoding; + +use oauth2::{RedirectUrl, Scope}; +use p256::elliptic_curve::sec1::ToEncodedPoint; +use p256::SecretKey; + use serde::{Deserialize, Serialize}; use std::collections::HashMap; +/// ProofKey model +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub struct ProofKey { + alg: String, + crv: String, + kty: String, + #[serde(rename = "use")] + u: String, + x: String, + y: String, +} + +impl ProofKey { + /// Create new instance of proof key + pub fn new(key: &SecretKey) -> Self { + let point = key.public_key().to_encoded_point(false); + Self { + crv: "P-256".into(), + alg: "ES256".into(), + u: "sig".into(), + kty: "EC".into(), + x: base64ct::Base64UrlUnpadded::encode_string(point.x().unwrap().as_slice()), + y: base64ct::Base64UrlUnpadded::encode_string(point.y().unwrap().as_slice()), + } + } +} + +/// Supported signing algorithms for HTTP request signing +/// +/// Utilized by [`SigningPolicy`] #[derive(Debug, Serialize, Deserialize, Copy, Clone, PartialEq, Eq)] pub enum SigningAlgorithm { + /// Elliptic curve DSA with SHA256 ES256, + /// Elliptic curve DSA with SHA384 ES384, + /// Elliptic curve DSA with SHA521 ES521, } -#[derive(Debug, Serialize, Deserialize)] +/// Signing policy for HTTP request signing +/// +/// Info about used policy for domains / endpoints can be requested +/// via [`XalAuthenticator.get_endpoints(&self)`](crate::XalAuthenticator#method.get_endpoints) +/// +/// Utilized by [`crate::RequestSigner`] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct SigningPolicy { + /// Signing policy version pub version: i32, + /// List of supported signing algorithms pub supported_algorithms: Vec, + /// Maximum body bytes to consider for signing pub max_body_bytes: usize, } @@ -26,257 +78,631 @@ impl Default for SigningPolicy { } } +/// HTTP Request models pub mod request { - use josekit::jwk::Jwk; - - use super::{Deserialize, Serialize}; - - #[derive(Debug, Serialize, Deserialize)] - #[serde(rename_all = "PascalCase")] - pub struct XADProperties<'a> { - pub auth_method: &'a str, - pub id: &'a str, - pub device_type: &'a str, - pub version: &'a str, - pub proof_key: Jwk, - } - - #[derive(Debug, Serialize, Deserialize)] - #[serde(rename_all = "PascalCase")] - pub struct XADRequest<'a> { - pub relying_party: &'a str, - pub token_type: &'a str, - pub properties: XADProperties<'a>, - } + use super::{Deserialize, ProofKey, Serialize}; + /// SISU query node + /// + /// Subnode of [`SisuAuthenticationRequest`] request body. #[derive(Debug, Serialize, Deserialize)] pub struct SisuQuery<'a> { + /// Display parameter pub display: &'a str, + /// OAuth2 code challenge pub code_challenge: &'a str, + /// OAuth2 code challenge method pub code_challenge_method: &'a str, + /// OAuth2 state pub state: &'a str, } + /// SISU Authentication request body + /// + /// Used by [`XalAuthenticator.do_sisu_authentication(&mut self)`](crate::XalAuthenticator#method.do_sisu_authentication) #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct SisuAuthenticationRequest<'a> { + /// Application Id - see [`crate::XalAppParameters`] pub app_id: &'a str, + /// Title Id - see [`crate::XalAppParameters`] pub title_id: &'a str, + /// Redirect Uri - see [`crate::XalAppParameters`] pub redirect_uri: &'a str, + /// Device token pub device_token: &'a str, + /// Target Xbox Live sandbox pub sandbox: &'a str, + /// Token type pub token_type: &'a str, + /// Offers - Defines desired authorization scopes pub offers: Vec<&'a str>, + /// Query pub query: SisuQuery<'a>, } + /// SISU Authorization request body + /// + /// Used by [`XalAuthenticator.sisu_authorize(&mut self)`](crate::XalAuthenticator#method.sisu_authorize) #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct SisuAuthorizationRequest<'a> { + /// Access token pub access_token: &'a str, + /// App Id - see [`crate::XalAppParameters`] pub app_id: &'a str, + /// Device token pub device_token: &'a str, + /// Target Xbox Live sandbox pub sandbox: &'a str, + /// Site name pub site_name: &'a str, - pub session_id: &'a str, - pub proof_key: Jwk, + /// Session Id + /// + /// Received by previous call on [`XalAuthenticator`](crate::XalAuthenticator#method.sisu_authenticate) + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + /// JWK proof key, related to HTTP request signing + /// + /// Can be obtained from an instance of [`crate::RequestSigner`] + pub proof_key: ProofKey, } + /// Xbox Authentication Device Properties + /// + /// Subtype of [`XTokenRequest`] request body. #[derive(Debug, Serialize, Deserialize)] - pub struct WindowsLiveTokenRequest<'a> { - pub client_id: &'a str, - pub refresh_token: Option<&'a str>, - pub grant_type: &'a str, - pub scope: &'a str, - pub redirect_uri: Option<&'a str>, - pub code: Option<&'a str>, - pub code_verifier: Option<&'a str>, + #[serde(rename_all = "PascalCase")] + pub struct XADProperties<'a> { + /// Authentication method, usually "JWT" + pub auth_method: &'a str, + /// Client UUID (can be random for Win32/Android/iOS client) + pub id: &'a str, + /// Device type - String representation of [`crate::DeviceType`] + pub device_type: &'a str, + /// Version of client OS + pub version: &'a str, + /// JWK proof key, related to HTTP request signing + /// + /// Can be obtained from an instance of [`crate::RequestSigner`] + pub proof_key: ProofKey, + } + + /// Xbox Authentication Title Properties + /// + /// Subtype of [`XTokenRequest`] request body. + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct XASTProperties<'a> { + /// Authentication method, usually "RPS" + pub auth_method: &'a str, + /// Device token + pub device_token: &'a str, + /// Site name, usually: "user.auth.xboxlive.com" + pub site_name: &'a str, + /// RPS Ticket + pub rps_ticket: &'a str, } + /// Xbox Authentication User Properties + /// + /// Subtype of [`XTokenRequest`] request body. + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct XASUProperties<'a> { + /// Authentication method, usually "RPS" + pub auth_method: &'a str, + /// Site name, usually: "user.auth.xboxlive.com" + pub site_name: &'a str, + /// RPS Ticket + pub rps_ticket: &'a str, + } + + /// XSTS Token request properties + /// + /// Subtype of [`XTokenRequest`] request body. #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct XSTSProperties<'a> { + /// Target Xbox Live sandbox pub sandbox_id: &'a str, - pub device_token: &'a str, - pub title_token: &'a str, + /// Device Token + #[serde(skip_serializing_if = "Option::is_none")] + pub device_token: Option<&'a str>, + /// Title Token + #[serde(skip_serializing_if = "Option::is_none")] + pub title_token: Option<&'a str>, + /// List of User tokens + #[serde(skip_serializing_if = "Vec::is_empty")] pub user_tokens: Vec<&'a str>, } + /// XToken request body #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] - pub struct XSTSRequest<'a> { + pub struct XTokenRequest<'a, T> { + /// Relying party pub relying_party: &'a str, + /// Token type pub token_type: &'a str, - pub properties: XSTSProperties<'a>, + /// XSTS Properties + pub properties: T, } } +/// HTTP Response models pub mod response { - use oauth2::{ - basic::BasicTokenType, helpers, AccessToken, ExtraTokenFields, RefreshToken, Scope, - }; + use oauth2::basic::BasicTokenResponse; + use url::Url; use super::{Deserialize, HashMap, Serialize, SigningPolicy}; - #[derive(Debug, Serialize, Deserialize)] - #[serde(rename_all = "PascalCase")] - pub struct TokenData { - pub issue_instant: String, - pub not_after: String, - pub token: String, - } - - #[derive(Debug, Serialize, Deserialize)] + /// Alias type for Windows Live token response + pub type WindowsLiveTokens = BasicTokenResponse; + /// Shorthand type for Token response with Device-DisplayClaims + pub type DeviceToken = XTokenResponse; + /// Shorthand type for Token response with User-DisplayClaims + pub type UserToken = XTokenResponse; + /// Shorthand type for Token response with Title-DisplayClaims + pub type TitleToken = XTokenResponse; + /// Shorthand type for Token response with XSTS-DisplayClaims + pub type XSTSToken = XTokenResponse; + + /// Device Token Display claims + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct XADDisplayClaims { - /// {"xdi": {"did": "F.....", "dcs": "0"}} + /// Contains shorthand identifiers about Device Token claims + /// + /// e.g. `{"xdi": {"did": "F.....", "dcs": "0"}}` pub xdi: HashMap, } - #[derive(Debug, Serialize, Deserialize)] - #[serde(rename_all = "PascalCase")] - pub struct XADResponse { - #[serde(flatten)] - pub token_data: TokenData, - pub display_claims: XADDisplayClaims, - } - - #[derive(Debug, Serialize, Deserialize)] + /// Title display claims + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct XATDisplayClaims { + /// Title identity pub xti: HashMap, } - #[derive(Debug, Serialize, Deserialize)] - #[serde(rename_all = "PascalCase")] - pub struct XATResponse { - #[serde(flatten)] - pub token_data: TokenData, - pub display_claims: XATDisplayClaims, - } - - #[derive(Debug, Serialize, Deserialize)] + /// User display claims + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct XAUDisplayClaims { + /// Xbox user identity pub xui: Vec>, } - #[derive(Debug, Serialize, Deserialize)] - #[serde(rename_all = "PascalCase")] - pub struct XAUResponse { - #[serde(flatten)] - pub token_data: TokenData, - pub display_claims: XAUDisplayClaims, - } - - #[derive(Debug, Serialize, Deserialize)] + /// XSTS display claims + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct XSTSDisplayClaims { + /// Xui pub xui: Vec>, } - #[derive(Debug, Serialize, Deserialize)] + /// XSTS Token response + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "PascalCase")] - pub struct XSTSResponse { - #[serde(flatten)] - pub token_data: TokenData, - pub display_claims: XSTSDisplayClaims, + pub struct XTokenResponse { + /// Issue datetime of token + pub issue_instant: String, + /// Expiry datetime of token + pub not_after: String, + /// Token value + pub token: String, + /// XSTS display claims + pub display_claims: Option, } - impl XSTSResponse { + impl XTokenResponse { + /// Return Xbox Userhash (related to `Authorization`) + #[must_use] pub fn userhash(&self) -> String { - self.display_claims.xui[0]["uhs"].clone() + self.display_claims.clone().unwrap().xui[0]["uhs"].clone() } + + /// Return Authorization header value + #[must_use] pub fn authorization_header_value(&self) -> String { - format!("XBL3.0 x={};{}", self.userhash(), self.token_data.token) + format!("XBL3.0 x={};{}", self.userhash(), self.token) } } - #[derive(Debug, Serialize, Deserialize)] + impl From<&str> for XTokenResponse { + fn from(s: &str) -> Self { + Self { + issue_instant: "2020-12-15T00:00:00.0000000Z".into(), + not_after: "2199-12-15T00:00:00.0000000Z".into(), + token: s.to_owned(), + display_claims: None + } + } + } + + /// Sisu authentication repsonse + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "PascalCase")] pub struct SisuAuthenticationResponse { - pub msa_oauth_redirect: String, + /// OAuth2 redirection URL + pub msa_oauth_redirect: Url, + /// Request parameters pub msa_request_parameters: HashMap, } - #[derive(Debug, Serialize, Deserialize)] + /// Sisu authorization response + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "PascalCase")] pub struct SisuAuthorizationResponse { + /// Device Token pub device_token: String, - pub title_token: XATResponse, - pub user_token: XAUResponse, - pub authorization_token: XSTSResponse, + /// Title Token + pub title_token: TitleToken, + /// User Token + pub user_token: UserToken, + /// Authorization Token + pub authorization_token: XSTSToken, + /// Web page pub web_page: String, + /// Xbox Live sandbox pub sandbox: String, + /// Modern gamertag indication pub use_modern_gamertag: Option, } - #[derive(Debug, Serialize, Deserialize, Clone)] - pub struct WindowsLiveTokenResponse { - pub token_type: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub expires_in: Option, - #[serde(rename = "scope")] - #[serde(deserialize_with = "helpers::deserialize_space_delimited_vec")] - #[serde(serialize_with = "helpers::serialize_space_delimited_vec")] - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(default)] - pub scopes: Option>, - pub access_token: AccessToken, - #[serde(skip_serializing_if = "Option::is_none")] - pub refresh_token: Option, - pub user_id: String, - - #[serde(bound = "EF: ExtraTokenFields")] - #[serde(flatten)] - pub extra_fields: EF, - } - - #[derive(Debug, Serialize, Deserialize)] - pub struct XCloudTokenResponse { - pub lpt: String, - pub refresh_token: String, - pub user_id: String, - } - - impl From for RefreshToken { - fn from(t: XCloudTokenResponse) -> Self { - Self::new(t.refresh_token) - } - } - - #[derive(Debug, Serialize, Deserialize)] + /// Title endpoint certificate + #[derive(Debug, Eq, PartialEq, Serialize, Deserialize, Clone)] #[serde(rename_all = "PascalCase")] pub struct TitleEndpointCertificate { + /// Certificate thumb-/fingerprint pub thumbprint: String, + /// Whether an issuer cert pub is_issuer: Option, + /// Root certificate index pub root_cert_index: i32, } - #[derive(Debug, Serialize, Deserialize)] - #[serde(rename_all = "PascalCase")] - pub struct TitleEndpointsResponse { - pub end_points: Vec, - pub signature_policies: Vec, - pub certs: Vec, - pub root_certs: Vec, - } - - #[derive(Debug, Serialize, Deserialize)] + /// Title endpoint + #[derive(Debug, Eq, PartialEq, Serialize, Deserialize, Clone)] #[serde(rename_all = "PascalCase")] pub struct TitleEndpoint { + /// Protocol pub protocol: String, + /// Host pub host: String, + /// Host type pub host_type: String, + /// Path pub path: Option, + /// Relying party pub relying_party: Option, + /// Sub relying party pub sub_relying_party: Option, + /// Token type pub token_type: Option, + /// Signature policy index pub signature_policy_index: Option, + /// Server cert index pub server_cert_index: Option>, } + + /// Title Endpoints response + /// + /// Can be fetched via [`crate::XalAuthenticator`] + #[derive(Debug, Eq, PartialEq, Serialize, Deserialize, Clone)] + #[serde(rename_all = "PascalCase")] + pub struct TitleEndpointsResponse { + /// Collection of available endpoints + pub end_points: Vec, + /// Collection of signing policies + pub signature_policies: Vec, + /// Collection of title endpoint certificates + pub certs: Vec, + /// List of root certificates + pub root_certs: Vec, + } + + /// Wrapper for sisu session id + #[derive(Debug, Serialize, Deserialize, Clone)] + pub struct SisuSessionId(pub String); +} + +/// Access Token prefix +/// +/// Relevant for fetching the UserToken +/// +/// Exact conditions are still unknown, when to use which format. +#[derive(Debug, Eq, PartialEq, Clone)] +pub enum AccessTokenPrefix { + /// Prefix access token with "d=" + D, + /// Prefix access token with "t=" + T, + /// Use token string as-is + None +} + +impl ToString for AccessTokenPrefix { + fn to_string(&self) -> String { + let prefix = match self { + AccessTokenPrefix::D => "d=", + AccessTokenPrefix::T => "t=", + AccessTokenPrefix::None => "", + }; + + prefix.to_string() + } +} + +/// Device type +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Clone)] +pub enum DeviceType { + /// iOS (iPhone or iPad) + IOS, + /// Google Android + ANDROID, + /// Microsoft Windows + WIN32, + /// Nintendo Switch + NINTENDO, + /// Custom type (user-defined) + Custom(String), +} + +impl FromStr for DeviceType { + type Err = Box; + + fn from_str(s: &str) -> Result { + let enm = match s.to_lowercase().as_ref() { + "android" => DeviceType::ANDROID, + "ios" => DeviceType::IOS, + "win32" => DeviceType::WIN32, + "nintendo" => DeviceType::NINTENDO, + val => DeviceType::Custom(val.to_owned()) + }; + Ok(enm) + } +} + +impl ToString for DeviceType { + fn to_string(&self) -> String { + let str = match self { + DeviceType::ANDROID => "Android", + DeviceType::IOS => "iOS", + DeviceType::WIN32 => "Win32", + DeviceType::NINTENDO => "Nintendo", + DeviceType::Custom(val) => val, + }; + str.to_owned() + } +} + +/// XAL App parameters +/// +/// Mandatory for XAL authentication flow +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct XalAppParameters { + /// App Id (For authorization/permission scope) + pub app_id: String, + /// App Title-Id (For TitleToken) + pub title_id: Option, + /// Scopes + pub auth_scopes: Vec, + /// Redirect Uri (For OAuth2 code response) + pub redirect_uri: Option, +} + +/// Application parameter constants +/// +/// Used for instantiating [`crate::XalAuthenticator`] +#[allow(non_snake_case)] +pub mod app_params { + use oauth2::{RedirectUrl, Scope}; + + use crate::Constants; + use super::XalAppParameters; + + /// Xbox Beta App + pub fn APP_XBOX_BETA() -> XalAppParameters { + XalAppParameters { + app_id: "000000004415494b".into(), + title_id: Some("177887386".into()), + auth_scopes: vec![Scope::new(Constants::SCOPE_SERVICE_USER_AUTH.to_string())], + // Originally "ms-xal-000000004415494b://auth" + redirect_uri: Some( + RedirectUrl::new(crate::Constants::OAUTH20_DESKTOP_REDIRECT_URL.into()).unwrap(), + ), + } + } + + /// Xbox App + pub fn APP_XBOX() -> XalAppParameters { + XalAppParameters { + app_id: "000000004c12ae6f".into(), + title_id: Some("328178078".into()), + auth_scopes: vec![Scope::new(Constants::SCOPE_SERVICE_USER_AUTH.to_string())], + // Originally "ms-xal-000000004c12ae6f://auth" + redirect_uri: Some( + RedirectUrl::new(crate::Constants::OAUTH20_DESKTOP_REDIRECT_URL.into()).unwrap(), + ), + } + } + + /// Gamepass App + pub fn APP_GAMEPASS() -> XalAppParameters { + XalAppParameters { + app_id: "000000004c20a908".into(), + title_id: Some("1016898439".into()), + auth_scopes: vec![Scope::new(Constants::SCOPE_SERVICE_USER_AUTH.to_string())], + // Originally "ms-xal-000000004c20a908://auth" + redirect_uri: Some( + RedirectUrl::new(crate::Constants::OAUTH20_DESKTOP_REDIRECT_URL.into()).unwrap(), + ), + } + } + + /// Gamepass Beta App + pub fn APP_GAMEPASS_BETA() -> XalAppParameters { + XalAppParameters { + app_id: "000000004c20a908".into(), + title_id: Some("1016898439".into()), + auth_scopes: vec![Scope::new(Constants::SCOPE_SERVICE_USER_AUTH.to_string())], + // Originally "ms-xal-public-beta-000000004c20a908://auth" + redirect_uri: Some( + RedirectUrl::new(crate::Constants::OAUTH20_DESKTOP_REDIRECT_URL.into()).unwrap(), + ), + } + } + + /// Family Settings App + /// + /// Uses default `oauth20_desktop.srf` redirect uri + pub fn APP_FAMILY_SETTINGS() -> XalAppParameters { + XalAppParameters { + app_id: "00000000482C8F49".into(), + title_id: Some("1618633878".into()), + auth_scopes: vec![Scope::new(Constants::SCOPE_SERVICE_USER_AUTH.to_string())], + redirect_uri: Some( + RedirectUrl::new(crate::Constants::OAUTH20_DESKTOP_REDIRECT_URL.into()).unwrap(), + ), + } + } + + /// Old Xbox App (non-sisu-flow) + pub fn APP_OLD_XBOX_APP() -> XalAppParameters { + XalAppParameters { + app_id: "0000000048093EE3".into(), + title_id: None, + auth_scopes: vec![Scope::new(Constants::SCOPE_SERVICE_USER_AUTH.to_string())], + redirect_uri: Some( + RedirectUrl::new(crate::Constants::OAUTH20_DESKTOP_REDIRECT_URL.into()).unwrap(), + ), + } + } + + /// Minecraft for Windows (JAVA) + pub fn MC_JAVA_WIN32() -> XalAppParameters { + XalAppParameters { + app_id: "00000000402b5328".into(), + title_id: None, + auth_scopes: vec![Scope::new(Constants::SCOPE_SERVICE_USER_AUTH.to_string())], + redirect_uri: None, + } + } + + /// Minecraft Bedrock (Nintendo Switch) + pub fn MC_BEDROCK_SWITCH() -> XalAppParameters { + XalAppParameters { + app_id: "00000000441cc96b".into(), + title_id: Some("2047319603".into()), + auth_scopes: vec![Scope::new(Constants::SCOPE_SERVICE_USER_AUTH.to_string())], + redirect_uri: None, + } + } + + /// Minecraft Bedrock (Android) + pub fn MC_BEDROCK_ANDROID() -> XalAppParameters { + XalAppParameters { + app_id: "0000000048183522".into(), + title_id: Some("1739947436".into()), + auth_scopes: vec![Scope::new(Constants::SCOPE_SERVICE_USER_AUTH.to_string())], + redirect_uri: None, + } + } + + /// Minecraft Bedrock (iOS) + pub fn MC_BEDROCK_IOS() -> XalAppParameters { + XalAppParameters { + app_id: "000000004c17c01a".into(), + title_id: Some("1810924247".into()), + auth_scopes: vec![Scope::new(Constants::SCOPE_SERVICE_USER_AUTH.to_string())], + redirect_uri: None, + } + } + + /* + /// Minecraft Bedrock (Win32)) + pub const MC_BEDROCK_WIN32: XalAppParameters = XalAppParameters { + app_id: "".into(), + title_id: "896928775".into(), + auth_scopes: vec![Scope::new(Constants::SCOPE_SERVICE_USER_AUTH.to_string())], + redirect_uri: None, + }; + + pub const MC_BEDROCK_PLAYSTATION: XalAppParameters = XalAppParameters { + app_id: "".into(), + title_id: "2044456598".into(), + auth_scopes: vec![Scope::new(Constants::SCOPE_SERVICE_USER_AUTH.to_string())], + redirect_uri: None, + }; + */ +} + +impl Default for XalAppParameters { + fn default() -> Self { + app_params::APP_GAMEPASS_BETA() + } +} + +/// XAL Client parameters +/// +/// Metadata from the client which attempts authentication +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct XalClientParameters { + /// HTTP User Agent + pub user_agent: String, + /// Device type + pub device_type: DeviceType, + /// Software version (aka. OS version) + pub client_version: String, + /// Query display parameter (for webinterface rendering of OAuth page) + pub query_display: String, +} + +/// Client parameter constants +/// +/// Used for instantiating [`crate::XalAuthenticator`] +#[allow(non_snake_case)] +pub mod client_params { + use super::{DeviceType, XalClientParameters}; + + /// iOS Client (iPhone or iPad) + pub fn CLIENT_IOS() -> XalClientParameters { + XalClientParameters { + user_agent: "XAL iOS 2021.11.20211021.000".into(), + device_type: DeviceType::IOS, + client_version: "15.6.1".into(), + query_display: "ios_phone".into(), + } + } + + /// Android Client + pub fn CLIENT_ANDROID() -> XalClientParameters { + XalClientParameters { + user_agent: "XAL Android 2020.07.20200714.000".into(), + device_type: DeviceType::ANDROID, + client_version: "8.0.0".into(), + query_display: "android_phone".into(), + } + } + + /// Nintendo Switch Client + pub fn CLIENT_NINTENDO() -> XalClientParameters { + XalClientParameters { + user_agent: "XAL".into(), + device_type: DeviceType::NINTENDO, + client_version: "0.0.0".into(), + query_display: "touch".into(), + } + } +} + +impl Default for XalClientParameters { + fn default() -> Self { + client_params::CLIENT_ANDROID() + } } #[cfg(test)] mod test { - use super::{response, SigningAlgorithm, SigningPolicy}; - use serde_json; + use super::*; #[test] fn deserialize_xsts() { @@ -301,18 +727,21 @@ mod test { } "#; - let bla: response::XSTSResponse = + let xsts: response::XSTSToken = serde_json::from_str(data).expect("BUG: Failed to deserialize XSTS response"); - assert_eq!(bla.userhash(), "abcdefg"); + assert_eq!(xsts.userhash(), "abcdefg"); assert_eq!( - bla.authorization_header_value(), + xsts.authorization_header_value(), "XBL3.0 x=abcdefg;123456789" ); - assert_eq!(bla.token_data.token, "123456789".to_owned()); - assert_eq!(bla.display_claims.xui[0].get("gtg"), Some(&"e".to_owned())); + assert_eq!(xsts.token, "123456789".to_owned()); + assert_eq!( + xsts.display_claims.as_ref().unwrap().xui[0].get("gtg"), + Some(&"e".to_owned()) + ); assert_ne!( - bla.display_claims.xui[0].get("uhs"), + xsts.display_claims.as_ref().unwrap().xui[0].get("uhs"), Some(&"invalid".to_owned()) ); } @@ -335,4 +764,32 @@ mod test { vec![SigningAlgorithm::ES521] ) } + + #[test] + fn devicetype_enum_into() { + assert_eq!(DeviceType::WIN32.to_string(), "Win32"); + assert_eq!(DeviceType::ANDROID.to_string(), "Android"); + assert_eq!(DeviceType::IOS.to_string(), "iOS"); + assert_eq!(DeviceType::NINTENDO.to_string(), "Nintendo"); + } + + #[test] + fn str_into_devicetype_enum() { + assert_eq!(DeviceType::from_str("win32").unwrap(), DeviceType::WIN32); + assert_eq!(DeviceType::from_str("Win32").unwrap(), DeviceType::WIN32); + assert_eq!(DeviceType::from_str("WIN32").unwrap(), DeviceType::WIN32); + assert_eq!( + DeviceType::from_str("android").unwrap(), + DeviceType::ANDROID + ); + assert_eq!(DeviceType::from_str("ios").unwrap(), DeviceType::IOS); + assert_eq!( + DeviceType::from_str("nintendo").unwrap(), + DeviceType::NINTENDO + ); + assert_eq!( + DeviceType::from_str("androidx").unwrap(), + DeviceType::Custom("androidx".into()) + ); + } } diff --git a/src/request_signer.rs b/src/request_signer.rs index 536bbb7..a82abf0 100644 --- a/src/request_signer.rs +++ b/src/request_signer.rs @@ -1,25 +1,49 @@ -use crate::models::SigningPolicy; +//! Xbox-specific HTTP Request signing. +//! -use filetime_type::FileTime; -use oauth2::url::Position; -use super::models; -use base64::{self, DecodeError}; +use crate::{ + error::Error, + models::{self, SigningPolicy}, + ProofKey, response::{self, TitleEndpointsResponse}, Constants, extensions::JsonExDeserializeMiddleware, +}; +use base64ct::{self, Base64, Encoding}; use chrono::prelude::*; -use josekit::{ - self, - jwk::{alg::ec::EcKeyPair, Jwk}, +use filetime_type::FileTime; +use log::warn; +use p256::{ + ecdsa::{ + signature::hazmat::{PrehashSigner, PrehashVerifier}, + Signature, SigningKey, VerifyingKey, + }, + SecretKey, +}; +use serde::{Serialize, Deserialize}; +use sha2::{Digest, Sha256}; +use std::{ + convert::{TryFrom, TryInto}, + option::Option, + str::FromStr, }; -use reqwest::{self, Method}; -use std::{option::Option, str::FromStr}; -type Error = Box; -type Result = std::result::Result; +/// Request signing trait +pub trait RequestSigning { + /// Sign a request + fn sign_request(&self, rhs: Rhs, timestamp: Option>) -> Result; +} + +/// Request verification trait +pub trait RequestVerification { + /// Verify a request's signature + fn verify(&self, rhs: Rhs) -> Result; +} +/// Helper structure which describes the components of a Xbox Live HTTP Signature +/// aka. the base64 value of `Signature` HTTP header #[derive(Debug)] -pub struct XboxWebSignatureBytes { +struct XboxWebSignatureBytes { signing_policy_version: Vec, timestamp: Vec, - signed_digest: Vec, + signature: Signature, } impl From<&XboxWebSignatureBytes> for Vec { @@ -27,17 +51,17 @@ impl From<&XboxWebSignatureBytes> for Vec { let mut bytes: Vec = Vec::new(); bytes.extend_from_slice(obj.signing_policy_version.as_slice()); bytes.extend_from_slice(obj.timestamp.as_slice()); - bytes.extend_from_slice(obj.signed_digest.as_slice()); + bytes.extend_from_slice(&obj.signature.to_bytes()); bytes } } impl FromStr for XboxWebSignatureBytes { - type Err = DecodeError; + type Err = base64ct::Error; fn from_str(s: &str) -> std::result::Result { - let bytes = base64::decode(s)?; + let bytes = Base64::decode_vec(s)?; Ok(bytes.into()) } } @@ -46,7 +70,7 @@ impl From> for XboxWebSignatureBytes { Self { signing_policy_version: bytes[..4].to_vec(), timestamp: bytes[4..12].to_vec(), - signed_digest: bytes[12..].to_vec(), + signature: Signature::from_slice(&bytes[12..]).unwrap(), } } } @@ -54,54 +78,116 @@ impl From> for XboxWebSignatureBytes { impl ToString for XboxWebSignatureBytes { fn to_string(&self) -> String { let bytes: Vec = self.into(); - base64::encode(bytes) + Base64::encode_string(&bytes) } } +/// Wrapper around the parts of a HTTP request which are used to calculate +/// the signature #[derive(Debug)] -pub struct HttpRequestToSign { +pub struct HttpMessageToSign { method: String, path_and_query: String, authorization: String, body: Vec, } -impl From for HttpRequestToSign { - fn from(request: reqwest::Request) -> Self { +impl TryFrom for HttpMessageToSign { + type Error = Error; + + fn try_from(request: reqwest::Request) -> Result { let url = request.url(); - let auth_header_val = match request.headers().get(reqwest::header::AUTHORIZATION) { - Some(val) => val - .to_str() - .expect("Failed serializing Authentication header to string"), + let method = request.method().to_string().to_uppercase(); + let authorization = match request.headers().get(reqwest::header::AUTHORIZATION) { + Some(val) => val.to_str().map_err(|_| { + Error::InvalidRequest( + "Failed serializing Authentication header to string".to_string(), + ) + })?, None => "", - }; + } + .to_string(); let body = match *request.method() { - Method::GET => { + reqwest::Method::GET => { vec![] } - Method::POST => request + reqwest::Method::POST => request .body() - .expect("Failed to get body from HTTP request") + .ok_or(Error::InvalidRequest( + "Failed to get body from HTTP request".to_string(), + ))? .as_bytes() - .expect("Failed to convert HTTP body to bytes") + .ok_or(Error::InvalidRequest( + "Failed to convert HTTP body to bytes".to_string(), + ))? .to_vec(), _ => panic!("Unhandled HTTP method: {:?}", request.method()), }; - HttpRequestToSign { - method: request.method().to_string().to_uppercase(), - path_and_query: url[Position::BeforePath..].to_owned(), - authorization: auth_header_val.to_owned(), + let path_and_query = { + match url.query() { + Some(query) => { + format!("{}?{query}", url.path()) + } + None => url.path().to_owned(), + } + }; + + Ok(HttpMessageToSign { + method, + path_and_query, + authorization, body, + }) + } +} + +impl TryFrom>> for HttpMessageToSign { + type Error = Error; + + fn try_from(request: http::Request>) -> Result { + let (parts, body) = request.into_parts(); + + let method = parts.method.to_string().to_uppercase(); + let authorization = match parts.headers.get(reqwest::header::AUTHORIZATION) { + Some(val) => val.to_str().map_err(|_| { + Error::InvalidRequest( + "Failed serializing Authentication header to string".to_string(), + ) + })?, + None => "", } + .to_string(); + + let path_and_query = parts + .uri + .path_and_query() + .ok_or(Error::InvalidRequest( + "Failed getting path and query".to_string(), + ))? + .to_string(); + + Ok(HttpMessageToSign { + method, + path_and_query, + authorization, + body, + }) } } -#[derive(Debug)] +/// Request signer +/// +/// Calculates the `Signature` header for certain Xbox Live HTTP request +#[derive(Debug, PartialEq, Eq, Clone)] pub struct RequestSigner { - pub keypair: EcKeyPair, + /// Elliptic curve keypair + pub keypair: SecretKey, + /// Signing policy + /// + /// Specific signing policies can be gathered by [`XalAuthenticator.get_endpoints(&self)`](crate::XalAuthenticator#method.get_endpoints) pub signing_policy: models::SigningPolicy, } @@ -111,215 +197,312 @@ impl Default for RequestSigner { } } -pub trait SigningReqwestBuilder { - fn sign( - self, - signer: &RequestSigner, +impl RequestSigning for RequestSigner { + fn sign_request( + &self, + rhs: reqwest::Request, timestamp: Option>, - ) -> Result; -} + ) -> Result { + let mut clone_request = rhs.try_clone().unwrap(); + // Gather data from request used for signing + let to_sign = rhs.try_into()?; -impl SigningReqwestBuilder for reqwest::RequestBuilder { - fn sign( - self, - signer: &RequestSigner, - timestamp: Option>, - ) -> Result { - match self.try_clone() { - Some(rb) => { - let request = rb.build()?; - // Fallback to Utc::now() internally - let signed = signer.sign_request(request, timestamp)?; - let body_bytes = signed - .body() - .ok_or("Failed getting request body")? - .as_bytes() - .ok_or("Failed getting bytes from request body")? - .to_vec(); - let headers = signed.headers().to_owned(); - - Ok(self.headers(headers).body(body_bytes)) - } - None => Err("Failed to clone RequestBuilder for signing".into()), - } + // Create signature + let signature = self.sign( + self.signing_policy.version, + timestamp.unwrap_or_else(Utc::now), + &to_sign, + )?; + + // Replace request body with byte representation (so signature creation is deterministic) + clone_request.body_mut().replace(to_sign.body.into()); + + // Assign Signature-header in request + clone_request + .headers_mut() + .insert("Signature", signature.to_string().parse()?); + + Ok(clone_request) } } -impl RequestSigner { - pub fn new(policy: models::SigningPolicy) -> Self { - Self { - keypair: josekit::jws::ES256.generate_key_pair().unwrap(), - signing_policy: policy, - } - } +impl RequestVerification for RequestSigner { + fn verify(&self, rhs: reqwest::Request) -> Result { + let request_clone = rhs + .try_clone() + .ok_or(Error::InvalidRequest("Failed cloning request".into()))?; + + let signature = request_clone + .headers() + .get("Signature") + .ok_or(Error::InvalidRequest( + "Failed getting Signature header".into(), + ))? + .to_str() + .map_err(|_e| { + Error::InvalidRequest("Failed converting Signature header value to str".into()) + })? + .to_owned(); - pub fn get_proof_key(&self) -> Jwk { - let mut jwk = self.keypair.to_jwk_public_key(); - jwk.set_key_use("sig"); + self.verify_message( + XboxWebSignatureBytes::from_str(&signature)?, + &request_clone.try_into()?, + )?; - jwk + Ok(rhs) } +} - pub fn sign_request( +impl RequestSigning>> for RequestSigner { + fn sign_request( &self, - request: reqwest::Request, + rhs: http::Request>, timestamp: Option>, - ) -> Result { - let mut clone_request = request.try_clone().unwrap(); + ) -> Result>, Error> { // Gather data from request used for signing - let to_sign = request.into(); + let (method, uri, mut headers, version, body) = ( + rhs.method().to_owned(), + rhs.uri().to_owned(), + rhs.headers().to_owned(), + rhs.version(), + rhs.body().clone(), + ); // Create signature - let signature = self - .sign( - self.signing_policy.version, - timestamp.unwrap_or_else(Utc::now), - &to_sign, - ) - .expect("Signing request failed!"); - - // Replace request body with byte representation (so signature creation is deterministic) - clone_request.body_mut().replace(to_sign.body.into()); + let signature = self.sign( + self.signing_policy.version, + timestamp.unwrap_or_else(Utc::now), + &rhs.try_into()?, + )?; // Assign Signature-header in request - clone_request - .headers_mut() - .insert("Signature", signature.to_string().parse()?); + headers.insert("Signature", signature.to_string().parse()?); - Ok(clone_request) + let mut builder = http::Request::builder() + .method(method) + .uri(uri) + .version(version); + builder.headers_mut().replace(&mut headers); + + builder.body(body).map_err(std::convert::Into::into) + } +} + +impl RequestSigner { + /// Creates a new instance of [`RequestSigner`] + pub fn new(policy: models::SigningPolicy) -> Self { + Self { + keypair: SecretKey::random(&mut rand::thread_rng()), + signing_policy: policy, + } + } + + /// Returns the proof key as JWK + pub fn get_proof_key(&self) -> ProofKey { + ProofKey::new(&self.keypair) } - /// Sign - pub fn sign( + /// Create signature from parts + fn sign( &self, signing_policy_version: i32, timestamp: DateTime, - request: &HttpRequestToSign, - ) -> Result { + request: &HttpMessageToSign, + ) -> Result { self.sign_raw( signing_policy_version, timestamp, - request.method.to_owned(), - request.path_and_query.to_owned(), - request.authorization.to_owned(), + &request.method, + &request.path_and_query, + &request.authorization, &request.body, ) + .map_err(std::convert::Into::into) } + /// Create signature from low-level parts fn sign_raw( &self, signing_policy_version: i32, timestamp: DateTime, - method: String, - path_and_query: String, - authorization: String, + method: &str, + path_and_query: &str, + authorization: &str, body: &[u8], - ) -> Result { - let signer = josekit::jws::ES256.signer_from_jwk(&self.keypair.to_jwk_private_key())?; + ) -> Result { + let signing_key: SigningKey = self.keypair.clone().into(); let filetime_bytes = FileTime::from(timestamp).filetime().to_be_bytes(); let signing_policy_version_bytes = signing_policy_version.to_be_bytes(); // Assemble the message to sign - let message = self - .assemble_message_data( - &signing_policy_version_bytes, - &filetime_bytes, - method, - path_and_query, - authorization, - body, - self.signing_policy.max_body_bytes, - ) - .expect("Failed to assemble message data !"); + let prehash = RequestSigner::prehash_message_data( + &signing_policy_version_bytes, + &filetime_bytes, + method, + path_and_query, + authorization, + body, + self.signing_policy.max_body_bytes, + ); // Sign the message - let signed_digest: Vec = signer.sign(&message)?; + let signature: Signature = signing_key.sign_prehash(&prehash).unwrap(); // Return final signature Ok(XboxWebSignatureBytes { signing_policy_version: signing_policy_version_bytes.to_vec(), timestamp: filetime_bytes.to_vec(), - signed_digest, + signature, }) } - pub fn verify_request(&self, request: reqwest::Request) -> Result<()> { - let signature = request - .try_clone() - .ok_or("Failed to clone request")? - .headers() - .get("Signature") - .ok_or("Failed to get signature header")? - .to_str()? - .to_owned(); - - self.verify( - XboxWebSignatureBytes::from_str(&signature)?, - &request.into(), - ) - } - - pub fn verify( + /// Verify the signature of a HTTP request (lower level) + fn verify_message( &self, signature: XboxWebSignatureBytes, - request: &HttpRequestToSign, - ) -> Result<()> { - let verifier = josekit::jws::ES256.verifier_from_jwk(&self.keypair.to_jwk_public_key())?; - let message = self.assemble_message_data( + request: &HttpMessageToSign, + ) -> Result<(), Error> { + let verifier: VerifyingKey = self.keypair.public_key().into(); + + // Assemble the message to sign + let prehash = RequestSigner::prehash_message_data( &signature.signing_policy_version, &signature.timestamp, - request.method.to_owned(), - request.path_and_query.to_owned(), - request.authorization.to_owned(), + &request.method, + &request.path_and_query, + &request.authorization, &request.body, self.signing_policy.max_body_bytes, - )?; + ); + verifier - .verify(&message, &signature.signed_digest) - .map_err(|err| err.into()) + .verify_prehash(&prehash, &signature.signature) + .map_err(std::convert::Into::into) } + /// Helper function to assemble the to-be-signed data #[allow(clippy::too_many_arguments)] - fn assemble_message_data( - &self, + pub fn prehash_message_data( signing_policy_version: &[u8], timestamp: &[u8], - method: String, - path_and_query: String, - authorization: String, + method: &str, + path_and_query: &str, + authorization: &str, body: &[u8], max_body_bytes: usize, - ) -> Result> { + ) -> Vec { const NULL_BYTE: &[u8; 1] = &[0x00]; - let mut data = Vec::::new(); + let mut hasher = Sha256::new(); + // Signature version + null - data.extend_from_slice(signing_policy_version); - data.extend_from_slice(NULL_BYTE); + hasher.update(signing_policy_version); + hasher.update(NULL_BYTE); // Timestamp + null - data.extend_from_slice(timestamp); - data.extend_from_slice(NULL_BYTE); + hasher.update(timestamp); + hasher.update(NULL_BYTE); // Method (uppercase) + null - data.extend_from_slice(method.to_uppercase().as_bytes()); - data.extend_from_slice(NULL_BYTE); + hasher.update(method.to_uppercase().as_bytes()); + hasher.update(NULL_BYTE); // Path and query + null - data.extend_from_slice(path_and_query.as_bytes()); - data.extend_from_slice(NULL_BYTE); + hasher.update(path_and_query.as_bytes()); + hasher.update(NULL_BYTE); // Authorization (even if an empty string) - data.extend_from_slice(authorization.as_bytes()); - data.extend_from_slice(NULL_BYTE); + hasher.update(authorization.as_bytes()); + hasher.update(NULL_BYTE); // Body let body_size_to_hash = std::cmp::min(max_body_bytes, body.len()); - data.extend_from_slice(&body[..body_size_to_hash]); - data.extend_from_slice(NULL_BYTE); + hasher.update(&body[..body_size_to_hash]); + hasher.update(NULL_BYTE); - Ok(data) + hasher.finalize().to_vec() + } +} + +/// Get Xbox Live endpoint descriptions required for dynamically signing HTTP requests +/// based on target domain / endpoint +pub async fn get_endpoints() -> Result { + let resp = reqwest::Client::new() + .get(Constants::XBOX_TITLE_ENDPOINTS_URL) + .header("x-xbl-contract-version", "1") + .query(&[("type", 1)]) + .send() + .await? + .json_ex::() + .await?; + + Ok(resp) +} + +/// Signature policy cache +/// +/// +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SignaturePolicyCache { + endpoints: TitleEndpointsResponse, +} + +impl SignaturePolicyCache { + /// Create a new SignaturePolicyCache. + pub fn new(endpoints: TitleEndpointsResponse) -> Self { + Self { + endpoints + } + } + + /// Retrieve the stored TitleEndpointsResponse. + pub fn get_endpoints(&self) -> TitleEndpointsResponse { + self.endpoints.clone() + } + + /// Find the policy for the given URL. + /// + /// If a matching policy is found, returns the corresponding SigningPolicy. Otherwise, returns None. + pub fn find_policy_for_url(&self, url: &str) -> Result, Error> { + let url = url::Url::parse(url)?; + + if !["http","https"].contains(&url.scheme()) { + return Err(Error::GeneralError(format!("Url with invalid protocol passed, expected http or https, url={url}"))) + } + + let matching_endpoint = self.endpoints.end_points + .iter() + .filter(|e| { + e.protocol.eq_ignore_ascii_case(url.scheme()) && + url.host_str().map(|host| { + match e.host_type.as_str() { + "fqdn" => host == e.host, + "wildcard" => host.ends_with(e.host.trim_start_matches('*')), + _ => false, + } + }).unwrap_or(false) && + e.path.as_ref().map(|path| url.path() == path).unwrap_or(true) && + e.signature_policy_index.is_some() + }) + .max_by_key(|e| e.host.len()); + + match matching_endpoint { + Some(ep) => { + println!("Identified Title endpoint={ep:?} for URL={url} {url:?}"); + let policy_index = ep.signature_policy_index.unwrap() as usize; + let policy = self.endpoints + .signature_policies + .get(policy_index) + .ok_or(Error::GeneralError(format!("SignaturePolicy at index {policy_index} not found!")))?; + + Ok(Some(policy.to_owned())) + }, + None => { + warn!("No matched SigningPolicy for url={url:?} found"); + Ok(None) + }, + } } } @@ -327,35 +510,55 @@ impl RequestSigner { mod test { use std::str::FromStr; - use super::{ - reqwest, FileTime, HttpRequestToSign, RequestSigner, SigningReqwestBuilder, - XboxWebSignatureBytes, - }; - use chrono::prelude::*; + use super::*; + use crate::{extensions::SigningReqwestBuilder, SigningAlgorithm}; use hex_literal::hex; use reqwest::{Body, Client}; + const PRIVATE_KEY_PEM: &str = "MHcCAQEEIObr5IVtB+DQcn25+R9n4K/EyUUSbVvxIJY7WhVeELUuoAoGCCqGSM49AwEHoUQDQgAEOKyCQ9qH5U4lZcS0c5/LxIyKvOpKe0l3x4Eg5OgDbzezKNLRgT28fd4Fq3rU/1OQKmx6jSq0vTB5Ao/48m0iGg=="; + fn get_request_signer() -> RequestSigner { - const PRIVATE_KEY_PEM: &str = "-----BEGIN EC PRIVATE KEY-----\n - MHcCAQEEIObr5IVtB+DQcn25+R9n4K/EyUUSbVvxIJY7WhVeELUuoAoGCCqGSM49\n - AwEHoUQDQgAEOKyCQ9qH5U4lZcS0c5/LxIyKvOpKe0l3x4Eg5OgDbzezKNLRgT28\n - fd4Fq3rU/1OQKmx6jSq0vTB5Ao/48m0iGg==\n - -----END EC PRIVATE KEY-----\n"; + let private_key = + Base64::decode_vec(PRIVATE_KEY_PEM).expect("Failed deserializing EC private key"); RequestSigner { - keypair: josekit::jws::ES256 - .key_pair_from_pem(PRIVATE_KEY_PEM) - .unwrap(), + keypair: SecretKey::from_sec1_der(&private_key) + .expect("Failed deserializing private key"), signing_policy: Default::default(), } } + #[test] + fn find_matching_signing_policy() { + let policy_0: SigningPolicy = SigningPolicy { version: 1, supported_algorithms: vec![SigningAlgorithm::ES256], max_body_bytes: 8192 }; + let policy_1: SigningPolicy = SigningPolicy { version: 1, supported_algorithms: vec![SigningAlgorithm::ES256], max_body_bytes: 4294967295 }; + + let title_endpoints = serde_json::from_str::( + include_str!("../testdata/title_endpoints.json") + ).unwrap(); + + let cache = SignaturePolicyCache::new(title_endpoints); + + assert!(cache.find_policy_for_url("https://unhandled.example.com").unwrap().is_none()); + assert!(cache.find_policy_for_url("https://unhandled.microsoft.com").unwrap().is_none()); + + assert_eq!(cache.find_policy_for_url("https://experimentation.xboxlive.com").unwrap().unwrap(), policy_0); + assert_eq!(cache.find_policy_for_url("https://xoobe.xboxlive.com").unwrap().unwrap(), policy_0); + assert_eq!(cache.find_policy_for_url("https://xaaa.bbtv.cn/xboxsms/OOBEService/AuthorizationStatus").unwrap().unwrap(), policy_0); + + assert_eq!(cache.find_policy_for_url("https://hello.experimentation.xboxlive.com").unwrap().unwrap(), policy_1); + assert_eq!(cache.find_policy_for_url("https://data-vef.xboxlive.com").unwrap().unwrap(), policy_1); + assert_eq!(cache.find_policy_for_url("https://settings.xboxlive.com").unwrap().unwrap(), policy_1); + assert_eq!(cache.find_policy_for_url("https://device.mgt.xboxlive.com").unwrap().unwrap(), policy_1); + assert_eq!(cache.find_policy_for_url("https://device.mgt.xboxlive.com/devices/current/unlock").unwrap().unwrap(), policy_1); + } + #[test] fn sign() { let signer = get_request_signer(); let dt = Utc.timestamp_opt(1586999965, 0).unwrap(); - let request = HttpRequestToSign { + let request = HttpMessageToSign { method: "POST".to_owned(), path_and_query: "/path?query=1".to_owned(), authorization: "XBL3.0 x=userid;jsonwebtoken".to_owned(), @@ -366,39 +569,38 @@ mod test { .sign_raw( 1, dt, - request.method.to_owned(), - request.path_and_query.to_owned(), - request.authorization.to_owned(), + &request.method, + &request.path_and_query, + &request.authorization, &request.body, ) .expect("Signing failed!"); signer - .verify(signature, &request) + .verify_message(signature, &request) .expect("Verification failed") } #[test] - fn data_to_hash() { - let signer = get_request_signer(); + fn prehashed_data() { let signing_policy_version: i32 = 1; - let ts_bytes = FileTime::from(Utc.timestamp_opt(1586999965, 0).unwrap()).filetime().to_be_bytes(); - - let message_data = signer - .assemble_message_data( - &signing_policy_version.to_be_bytes(), - &ts_bytes, - "POST".to_owned(), - "/path?query=1".to_owned(), - "XBL3.0 x=userid;jsonwebtoken".to_owned(), - "thebodygoeshere".as_bytes(), - 8192, - ) - .expect("Failed to assemble message data"); + let ts_bytes = FileTime::from(Utc.timestamp_opt(1586999965, 0).unwrap()) + .filetime() + .to_be_bytes(); + + let message_data = RequestSigner::prehash_message_data( + &signing_policy_version.to_be_bytes(), + &ts_bytes, + "POST", + "/path?query=1", + "XBL3.0 x=userid;jsonwebtoken", + "thebodygoeshere".as_bytes(), + 8192, + ); assert_eq!( - message_data, - hex!("000000010001d6138d10f7cc8000504f5354002f706174683f71756572793d310058424c332e3020783d7573657269643b6a736f6e776562746f6b656e00746865626f6479676f65736865726500").to_vec() + &message_data, + &hex!("f7d61b6f8d4dcd86da1aa8553f0ee7c15450811e7cd2759364e22f67d853ff50") ); } @@ -426,23 +628,21 @@ mod test { let signature = request.headers().get("Signature"); assert!(signature.is_some()); - assert!(signer.verify_request(request).is_ok()); + assert!(signer.verify(request).is_ok()); } #[test] fn verify_real_request() { - let pem_priv_key = r#"-----BEGIN PRIVATE KEY----- - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgYhW3PQAibijp6X71 - Uua4a45KoHHpQZaUIef+gPeWOu2hRANCAAQYlLUACGI9jDRlJAkMIXyRxmQoBza1 - FZcA3pjD6j+ExFAECR1HP8lSIVEICL6BA95LdCQ8/xvI4F8rP10drPl3 - -----END PRIVATE KEY-----"#; + let private_key = Base64::decode_vec( + "MHcCAQEEIGIVtz0AIm4o6el+9VLmuGuOSqBx6UGWlCHn/oD3ljrtoAoGCCqGSM49AwEHoUQDQgAEGJS1AAhiPYw0ZSQJDCF8kcZkKAc2tRWXAN6Yw+o/hMRQBAkdRz/JUiFRCAi+gQPeS3QkPP8byOBfKz9dHaz5dw==" + ).expect("Failed deserializing EC private key"); let signer = RequestSigner { - keypair: josekit::jws::ES256.key_pair_from_pem(pem_priv_key).unwrap(), + keypair: SecretKey::from_sec1_der(&private_key).unwrap(), signing_policy: Default::default(), }; - let request = HttpRequestToSign { + let request = HttpMessageToSign { method: "POST".to_owned(), path_and_query: "/device/authenticate".to_owned(), authorization: "".to_owned(), @@ -451,7 +651,7 @@ mod test { let signature = XboxWebSignatureBytes::from_str("AAAAAQHY4xgs5DyIujFG5E5MZ4D1xjd9Up+H4AKLoyBHd95MAUZcabUN//Y/gijed4vvKtlfp4Cd4dJzVhpK0m+sYZcYRqQjBEKAZw==") .expect("Failed to deserialize into XboxWebSignatureBytes"); - assert!(signer.verify(signature, &request).is_ok()); + assert!(signer.verify_message(signature, &request).is_ok()); } #[test] diff --git a/src/tokenstore.rs b/src/tokenstore.rs new file mode 100644 index 0000000..f2929f0 --- /dev/null +++ b/src/tokenstore.rs @@ -0,0 +1,126 @@ +//! Token store + +use crate::{ + response::{DeviceToken, TitleToken, UserToken, WindowsLiveTokens, XSTSToken}, + Error, XalAppParameters, XalAuthenticator, XalClientParameters, +}; +use chrono::{DateTime, Utc}; +use log::trace; +use serde::{Deserialize, Serialize}; +use std::io::{Read, Seek}; + +/// Model describing authentication tokens +/// +/// Can be used for de-/serializing tokens and respective +/// authentication parameters. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct TokenStore { + /// Stored app parameters + pub app_params: XalAppParameters, + /// Stored client parameters + pub client_params: XalClientParameters, + /// Xbox Live sandbox id used for authentication + pub sandbox_id: String, + /// Windows Live access- & refresh token + pub live_token: WindowsLiveTokens, + /// Xbox live user token + pub user_token: Option, + /// Xbox live title token + pub title_token: Option, + /// Xbox live device token + pub device_token: Option, + /// Xbox live authorization/XSTS token + pub authorization_token: Option, + /// Update timestamp of this struct + /// + /// Can be updated by calling `update_timestamp` + /// on its instance. + pub updated: Option>, +} + +impl From for XalAuthenticator { + fn from(value: TokenStore) -> Self { + Self::new( + value.app_params.clone(), + value.client_params.clone(), + value.sandbox_id.clone(), + ) + } +} + +impl ToString for TokenStore { + fn to_string(&self) -> String { + serde_json::to_string(&self).expect("Failed to serialize TokenStore") + } +} + +impl TokenStore { + /// Load a tokenstore from a file by providing the filename/path to the + /// serialized JSON + /// + /// Returns the json string if possible + pub fn load_from_file(filepath: &str) -> Result { + trace!("Trying to load tokens from filepath={:?}", filepath); + let mut file = std::fs::File::options().read(true).open(filepath)?; + + let mut json = String::new(); + file.read_to_string(&mut json)?; + + Self::deserialize_from_string(&json) + } + + /// Load tokens from file + pub fn deserialize_from_string(json: &str) -> Result { + trace!("Attempting to deserialize token data"); + serde_json::from_str(json).map_err(std::convert::Into::into) + } + + /// Save tokens to writer + pub fn save_to_writer(&self, writer: impl std::io::Write) -> Result<(), Error> { + serde_json::to_writer_pretty(writer, self).map_err(std::convert::Into::into) + } + + /// Save the tokens to a JSON file + /// + /// NOTE: If the file already exists it will be overwritten + pub fn save_to_file(&self, filepath: &str) -> Result<(), Error> { + trace!( + "Trying to open tokenfile read/write/create path={:?}", + filepath + ); + let mut file = std::fs::File::options() + .read(true) + .write(true) + .create(true) + .open(filepath)?; + + file.rewind()?; + file.set_len(0)?; + + trace!("Saving tokens path={:?}", filepath); + self.save_to_writer(file) + } + + /// Update the timestamp of this instance + pub fn update_timestamp(&mut self) { + self.updated = Some(chrono::offset::Utc::now()); + } +} + +#[cfg(test)] +mod tests { + use rand::distributions::{Alphanumeric, DistString}; + + use super::*; + + fn random_filename() -> String { + Alphanumeric.sample_string(&mut rand::thread_rng(), 16) + } + + #[test] + fn read_invalid_tokenfile() { + let res = TokenStore::load_from_file(&random_filename()); + + assert!(res.is_err()); + } +} diff --git a/src/utils.rs b/src/utils.rs deleted file mode 100644 index 572a765..0000000 --- a/src/utils.rs +++ /dev/null @@ -1,32 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use std::fs; - -use crate::authenticator::SpecialTokenResponse; -use crate::{ - app_params::{XalAppParameters, XalClientParameters}, - models::response::{SisuAuthorizationResponse, XCloudTokenResponse, XSTSResponse}, -}; - -#[derive(Serialize, Deserialize, Debug)] -pub struct TokenStore { - pub app_params: XalAppParameters, - pub client_params: XalClientParameters, - pub wl_token: SpecialTokenResponse, - pub sisu_tokens: SisuAuthorizationResponse, - pub gssv_token: XSTSResponse, - pub xcloud_transfer_token: XCloudTokenResponse, - pub updated: DateTime, -} - -impl TokenStore { - pub fn load(filepath: &str) -> Result> { - let s = fs::read_to_string(filepath)?; - serde_json::from_str(&s).map_err(|e| e.into()) - } - - pub fn save(&self, filepath: &str) -> Result<(), Box> { - let s = serde_json::to_string_pretty(self)?; - fs::write(filepath, s).map_err(|e| e.into()) - } -} diff --git a/xal-rs.code-workspace b/xal-rs.code-workspace new file mode 100644 index 0000000..a27b36c --- /dev/null +++ b/xal-rs.code-workspace @@ -0,0 +1,13 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": {}, + "extensions": { + "recommendations": [ + "rust-lang.rust-analyzer" + ] + } +} \ No newline at end of file From 80045a438d1c38ac68aa85cd8c4a2b7c232c8d12 Mon Sep 17 00:00:00 2001 From: tuxuser <462620+tuxuser@users.noreply.github.com> Date: Sat, 16 Dec 2023 12:18:12 +0100 Subject: [PATCH 03/13] request_signer: Use nt-time instead filetime_type for DateTime -> FILETIME conversion --- Cargo.toml | 2 +- src/request_signer.rs | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 106e707..1a61c82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,6 @@ reqwest = { version = "0.11", features = ["json"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" cvlib = "0.1.2" -filetime_type = "0.1" chrono = "0.4" uuid = { version = "1", features = ["v4", "serde"] } thiserror = "1.0.37" @@ -27,6 +26,7 @@ base64ct = { version = "1.6.0", features = ["std"] } sha2 = "0.10.8" rand = "0.8.5" oauth2 = "4.4.2" +nt-time = { version = "0.6.5", features = ["chrono"] } [dev-dependencies] hex-literal = "0.3.4" diff --git a/src/request_signer.rs b/src/request_signer.rs index a82abf0..9dd38c7 100644 --- a/src/request_signer.rs +++ b/src/request_signer.rs @@ -8,7 +8,7 @@ use crate::{ }; use base64ct::{self, Base64, Encoding}; use chrono::prelude::*; -use filetime_type::FileTime; +use nt_time::FileTime; use log::warn; use p256::{ ecdsa::{ @@ -332,7 +332,9 @@ impl RequestSigner { ) -> Result { let signing_key: SigningKey = self.keypair.clone().into(); - let filetime_bytes = FileTime::from(timestamp).filetime().to_be_bytes(); + let filetime_bytes = FileTime::try_from(timestamp) + .map_err(|e|Error::GeneralError(format!("{e}")))? + .to_be_bytes(); let signing_policy_version_bytes = signing_policy_version.to_be_bytes(); // Assemble the message to sign @@ -584,8 +586,8 @@ mod test { #[test] fn prehashed_data() { let signing_policy_version: i32 = 1; - let ts_bytes = FileTime::from(Utc.timestamp_opt(1586999965, 0).unwrap()) - .filetime() + let ts_bytes = FileTime::try_from(Utc.timestamp_opt(1586999965, 0).unwrap()) + .unwrap() .to_be_bytes(); let message_data = RequestSigner::prehash_message_data( From 0fd94daf9e748eecb577917da7c2eb7ea8934d03 Mon Sep 17 00:00:00 2001 From: tuxuser <462620+tuxuser@users.noreply.github.com> Date: Sat, 16 Dec 2023 12:18:39 +0100 Subject: [PATCH 04/13] docs: Fix intradoc links to functions --- src/flows.rs | 3 ++- src/models.rs | 8 ++++---- src/request_signer.rs | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/flows.rs b/src/flows.rs index 1abc6c2..9fb6599 100644 --- a/src/flows.rs +++ b/src/flows.rs @@ -123,7 +123,8 @@ impl AuthPromptData { /// Sisu Auth callback trait /// -/// Used as an argument to [`XalAuthenticator.do_sisu_flow`](crate::XalAuthenticator#method.do_sisu_flow) +/// Used as an argument to [`crate::flows::xbox_live_sisu_full_flow`] +/// /// /// # Examples /// diff --git a/src/models.rs b/src/models.rs index e8efc29..81a5cf7 100644 --- a/src/models.rs +++ b/src/models.rs @@ -54,7 +54,7 @@ pub enum SigningAlgorithm { /// Signing policy for HTTP request signing /// /// Info about used policy for domains / endpoints can be requested -/// via [`XalAuthenticator.get_endpoints(&self)`](crate::XalAuthenticator#method.get_endpoints) +/// via [`crate::request_signer::get_endpoints`]. /// /// Utilized by [`crate::RequestSigner`] #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] @@ -99,7 +99,7 @@ pub mod request { /// SISU Authentication request body /// - /// Used by [`XalAuthenticator.do_sisu_authentication(&mut self)`](crate::XalAuthenticator#method.do_sisu_authentication) + /// Used by [`crate::XalAuthenticator::sisu_authenticate`] #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct SisuAuthenticationRequest<'a> { @@ -123,7 +123,7 @@ pub mod request { /// SISU Authorization request body /// - /// Used by [`XalAuthenticator.sisu_authorize(&mut self)`](crate::XalAuthenticator#method.sisu_authorize) + /// Used by [`crate::XalAuthenticator::sisu_authorize`] #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct SisuAuthorizationRequest<'a> { @@ -139,7 +139,7 @@ pub mod request { pub site_name: &'a str, /// Session Id /// - /// Received by previous call on [`XalAuthenticator`](crate::XalAuthenticator#method.sisu_authenticate) + /// Received by previous call on [`crate::XalAuthenticator::sisu_authenticate`] #[serde(skip_serializing_if = "Option::is_none")] pub session_id: Option, /// JWK proof key, related to HTTP request signing diff --git a/src/request_signer.rs b/src/request_signer.rs index 9dd38c7..486224d 100644 --- a/src/request_signer.rs +++ b/src/request_signer.rs @@ -187,7 +187,7 @@ pub struct RequestSigner { pub keypair: SecretKey, /// Signing policy /// - /// Specific signing policies can be gathered by [`XalAuthenticator.get_endpoints(&self)`](crate::XalAuthenticator#method.get_endpoints) + /// Specific signing policies can be gathered by [`get_endpoints`] pub signing_policy: models::SigningPolicy, } From 9b3c8c3fe81085d95ada5d96c71197e020761b5b Mon Sep 17 00:00:00 2001 From: tuxuser <462620+tuxuser@users.noreply.github.com> Date: Mon, 18 Dec 2023 18:46:03 +0100 Subject: [PATCH 05/13] flows: Work on mutable references of XalAuthenticator, only return TokenStore / not a tuple --- examples/src/lib.rs | 23 ++++++++++------------- src/authenticator.rs | 1 - src/flows.rs | 41 +++++++++++++++++++++-------------------- 3 files changed, 31 insertions(+), 34 deletions(-) diff --git a/examples/src/lib.rs b/examples/src/lib.rs index decbb18..612c2b9 100644 --- a/examples/src/lib.rs +++ b/examples/src/lib.rs @@ -79,46 +79,44 @@ pub async fn auth_main( let args = handle_args(); let mut ts = match flows::try_refresh_tokens_from_file(&args.token_filepath).await { - Ok((authenticator, ts)) => { + Ok((mut authenticator, ts)) => { info!("Tokens refreshed succesfully, proceeding with Xbox Live Authorization"); match args.flow { AuthFlow::Sisu => { info!("Authorize and gather rest of xbox live tokens via sisu"); flows::xbox_live_sisu_authorization_flow( - authenticator, ts.live_token + &mut authenticator, ts.live_token ) .await? - .1 }, _ => { info!("Authorize Xbox Live the traditional way, via individual requests"); flows::xbox_live_authorization_traditional_flow( - authenticator, + &mut authenticator, ts.live_token, xsts_relying_party, access_token_prefix, false ) .await? - .1 } } } Err(err) => { log::error!("Refreshing tokens failed err={err}"); - let authenticator = XalAuthenticator::new(app_params, client_params, sandbox_id); + let mut authenticator = XalAuthenticator::new(app_params, client_params, sandbox_id); info!("Authentication via flow={:?}", args.flow); - let (authenticator, ts) = match args.flow { - AuthFlow::Sisu => flows::xbox_live_sisu_full_flow(authenticator, auth_cb).await?, + let ts = match args.flow { + AuthFlow::Sisu => flows::xbox_live_sisu_full_flow(&mut authenticator, auth_cb).await?, AuthFlow::DeviceCode => { - flows::ms_device_code_flow(authenticator, auth_cb, tokio::time::sleep).await? + flows::ms_device_code_flow(&mut authenticator, auth_cb, tokio::time::sleep).await? } AuthFlow::Implicit => { - flows::ms_authorization_flow(authenticator, auth_cb, true).await? + flows::ms_authorization_flow(&mut authenticator, auth_cb, true).await? } AuthFlow::AuthorizationCode => { - flows::ms_authorization_flow(authenticator, auth_cb, false).await? + flows::ms_authorization_flow(&mut authenticator, auth_cb, false).await? } }; @@ -129,14 +127,13 @@ pub async fn auth_main( // Only required for non-sisu authentication, as // sisu already gathers all the tokens at once flows::xbox_live_authorization_traditional_flow( - authenticator, + &mut authenticator, ts.live_token, xsts_relying_party, access_token_prefix, false, ) .await? - .1 }, } } diff --git a/src/authenticator.rs b/src/authenticator.rs index 90414ba..105fc71 100644 --- a/src/authenticator.rs +++ b/src/authenticator.rs @@ -390,7 +390,6 @@ impl XalAuthenticator { /// redirect_uri: Some(RedirectUrl::new("https://login.live.com/oauth20_desktop.srf".into()).unwrap()) /// }, /// client_params::CLIENT_ANDROID(), - /// None, /// "RETAIL".into() /// ); /// diff --git a/src/flows.rs b/src/flows.rs index 9fb6599..8adeb80 100644 --- a/src/flows.rs +++ b/src/flows.rs @@ -226,8 +226,9 @@ impl AuthPromptCallback for CliCallbackHandler { pub async fn try_refresh_tokens_from_file( filepath: &str, ) -> Result<(XalAuthenticator, TokenStore), Error> { - let ts = TokenStore::load_from_file(filepath)?; - try_refresh_live_tokens_from_tokenstore(ts).await + let mut ts = TokenStore::load_from_file(filepath)?; + let authenticator = try_refresh_live_tokens_from_tokenstore(&mut ts).await?; + Ok((authenticator, ts)) } /// Try to read tokens from the token store and refresh the Windows Live tokens if needed. @@ -241,8 +242,8 @@ pub async fn try_refresh_tokens_from_file( /// If successful, a tuple of [`crate::XalAuthenticator`] and [`crate::tokenstore::TokenStore`] /// is returned. TokenStore will contain the refreshed `live_tokens`. pub async fn try_refresh_live_tokens_from_tokenstore( - mut ts: TokenStore, -) -> Result<(XalAuthenticator, TokenStore), Error> { + ts: &mut TokenStore, +) -> Result { let mut authenticator = Into::::into(ts.clone()); info!("Refreshing windows live tokens"); @@ -254,15 +255,15 @@ pub async fn try_refresh_live_tokens_from_tokenstore( debug!("Windows Live tokens: {:?}", refreshed_wl_tokens); ts.live_token = refreshed_wl_tokens.clone(); - Ok((authenticator, ts)) + Ok(authenticator) } /// Shorthand for Windows Live device code flow pub async fn ms_device_code_flow( - mut authenticator: XalAuthenticator, + authenticator: &mut XalAuthenticator, cb: impl AuthPromptCallback, sleep_fn: S, -) -> Result<(XalAuthenticator, TokenStore), Error> +) -> Result where S: Fn(std::time::Duration) -> SF, SF: std::future::Future, @@ -295,17 +296,17 @@ where updated: None, }; - Ok((authenticator, ts)) + Ok(ts) } /// Shorthand for Windows Live authorization flow /// - Depending on the argument `implicit` the /// methods `implicit grant` or `authorization code` are chosen pub async fn ms_authorization_flow( - mut authenticator: XalAuthenticator, + authenticator: &mut XalAuthenticator, cb: impl AuthPromptCallback, implicit: bool, -) -> Result<(XalAuthenticator, TokenStore), Error> { +) -> Result { trace!("Starting implicit authorization flow"); let (url, state) = @@ -349,14 +350,14 @@ pub async fn ms_authorization_flow( updated: None, }; - Ok((authenticator, ts)) + Ok(ts) } /// Shorthand for sisu authentication flow pub async fn xbox_live_sisu_full_flow( - mut authenticator: XalAuthenticator, + authenticator: &mut XalAuthenticator, callback: impl AuthPromptCallback, -) -> Result<(XalAuthenticator, TokenStore), Error> { +) -> Result { trace!("Getting device token"); let device_token = authenticator.get_device_token().await?; debug!("Device token={:?}", device_token); @@ -417,7 +418,7 @@ pub async fn xbox_live_sisu_full_flow( updated: None, }; - Ok((authenticator, ts)) + Ok(ts) } /// Implements the traditional Xbox Live authorization flow. @@ -475,12 +476,12 @@ pub async fn xbox_live_sisu_full_flow( /// - Depending on the client an AccessToken prefix is necessary to have the User Token (XASU) request succeed /// - Success of authorizing (device, user, ?title?) tokens for XSTS relies on the target relying party pub async fn xbox_live_authorization_traditional_flow( - mut authenticator: XalAuthenticator, + authenticator: &mut XalAuthenticator, live_tokens: WindowsLiveTokens, xsts_relying_party: String, access_token_prefix: AccessTokenPrefix, request_title_token: bool, -) -> Result<(XalAuthenticator, TokenStore), Error> { +) -> Result { debug!("Windows live tokens={:?}", &live_tokens); trace!("Getting device token"); let device_token = authenticator.get_device_token().await?; @@ -530,14 +531,14 @@ pub async fn xbox_live_authorization_traditional_flow( updated: None, }; - Ok((authenticator, ts)) + Ok(ts) } /// bla pub async fn xbox_live_sisu_authorization_flow( - mut authenticator: XalAuthenticator, + authenticator: &mut XalAuthenticator, live_tokens: WindowsLiveTokens, -) -> Result<(XalAuthenticator, TokenStore), Error> { +) -> Result { debug!("Windows live tokens={:?}", &live_tokens); trace!("Getting device token"); let device_token = authenticator.get_device_token().await?; @@ -560,5 +561,5 @@ pub async fn xbox_live_sisu_authorization_flow( updated: None, }; - Ok((authenticator, ts)) + Ok(ts) } \ No newline at end of file From 0924d17906d4a118e3c9646634454631515e94d8 Mon Sep 17 00:00:00 2001 From: tuxuser <462620+tuxuser@users.noreply.github.com> Date: Tue, 19 Dec 2023 01:07:16 +0100 Subject: [PATCH 06/13] Extend documentation, fix code examples --- examples/Cargo.toml | 10 +- examples/src/bin/auth_azure.rs | 28 ++-- examples/src/bin/auth_cli.rs | 9 +- examples/src/bin/auth_minecraft.rs | 67 +++++++++ examples/src/bin/auth_webview.rs | 8 +- examples/src/lib.rs | 40 +++--- src/authenticator.rs | 210 ++++++++++++++-------------- src/flows.rs | 214 +++++++++++++++++++++++++---- src/models.rs | 28 ++-- src/request_signer.rs | 166 +++++++++++++++------- 10 files changed, 556 insertions(+), 224 deletions(-) create mode 100644 examples/src/bin/auth_minecraft.rs diff --git a/examples/Cargo.toml b/examples/Cargo.toml index f334473..971a640 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -14,23 +14,21 @@ env_logger = "0.10.1" log = "0.4.20" clap = { version = "4.4.8", features = ["derive"] } chrono = "0.4.31" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +reqwest = { version = "0.11", features = ["json"] } # Optional dependencies -tokio = { version = "1", features = ["full"], optional = true } wry = { version = "0.34.2", optional = true } [features] -webview = ["dep:wry"] -tokio = ["dep:tokio"] +webview = ["wry"] [[bin]] name = "auth_cli" -required-features = ["tokio"] [[bin]] name = "auth_azure" -required-features = ["tokio"] [[bin]] name = "auth_webview" -required-features = ["webview","tokio"] +required-features = ["webview"] diff --git a/examples/src/bin/auth_azure.rs b/examples/src/bin/auth_azure.rs index 16bf3a1..aaae60a 100644 --- a/examples/src/bin/auth_azure.rs +++ b/examples/src/bin/auth_azure.rs @@ -1,11 +1,16 @@ use std::str::from_utf8; use async_trait::async_trait; -use tokio::{net::TcpListener, io::{AsyncReadExt, AsyncWriteExt}}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, +}; use xal::{ + client_params::CLIENT_ANDROID, flows::{AuthPromptCallback, AuthPromptData}, + oauth2::{RedirectUrl, Scope}, url::Url, - Error, client_params::CLIENT_ANDROID, Constants, XalAppParameters, oauth2::{Scope, RedirectUrl, ResourceOwnerUsername, ResourceOwnerPassword}, XalAuthenticator, + Error, XalAppParameters, }; use xal_examples::auth_main; @@ -40,10 +45,13 @@ impl AuthPromptCallback for HttpCallbackHandler { let http_req = from_utf8(&buf)?; println!("HTTP REQ: {http_req}"); - let path = http_req.split(" ").nth(1).unwrap(); + let path = http_req.split(' ').nth(1).unwrap(); println!("Path: {path}"); - Ok(Some(Url::parse(&format!("{}{}", self.redirect_url_base, path))?)) + Ok(Some(Url::parse(&format!( + "{}{}", + self.redirect_url_base, path + ))?)) } } @@ -54,7 +62,8 @@ async fn main() -> Result<(), Error> { app_id: "388ea51c-0b25-4029-aae2-17df49d23905".into(), title_id: None, auth_scopes: vec![ - Scope::new("Xboxlive.signin".into()), Scope::new("Xboxlive.offline_access".into()) + Scope::new("Xboxlive.signin".into()), + Scope::new("Xboxlive.offline_access".into()), ], redirect_uri: Some( RedirectUrl::new("http://localhost:8080/auth/callback".into()).unwrap(), @@ -62,11 +71,14 @@ async fn main() -> Result<(), Error> { }, CLIENT_ANDROID(), "RETAIL".into(), - Constants::RELYING_PARTY_XBOXLIVE.into(), xal::AccessTokenPrefix::D, HttpCallbackHandler { bind_host: "127.0.0.1:8080".into(), redirect_url_base: "http://localhost:8080".into(), - } - ).await + }, + ) + .await + .ok(); + + Ok(()) } diff --git a/examples/src/bin/auth_cli.rs b/examples/src/bin/auth_cli.rs index 378752f..c5d4387 100644 --- a/examples/src/bin/auth_cli.rs +++ b/examples/src/bin/auth_cli.rs @@ -3,8 +3,9 @@ use xal_examples::auth_main_default; #[tokio::main] async fn main() -> Result<(), Error> { - auth_main_default( - AccessTokenPrefix::None, - flows::CliCallbackHandler - ).await + auth_main_default(AccessTokenPrefix::None, flows::CliCallbackHandler) + .await + .ok(); + + Ok(()) } diff --git a/examples/src/bin/auth_minecraft.rs b/examples/src/bin/auth_minecraft.rs new file mode 100644 index 0000000..afcd57a --- /dev/null +++ b/examples/src/bin/auth_minecraft.rs @@ -0,0 +1,67 @@ +use serde_json::json; +use xal::{ + extensions::JsonExDeserializeMiddleware, flows, oauth2::TokenResponse, AccessTokenPrefix, + Error, XalAuthenticator, +}; +use xal_examples::auth_main; + +#[tokio::main] +async fn main() -> Result<(), Error> { + let app_params = xal::app_params::MC_BEDROCK_SWITCH(); + let client_params = xal::client_params::CLIENT_NINTENDO(); + + let ts = auth_main( + app_params, + client_params, + "RETAIL".into(), + AccessTokenPrefix::None, + flows::CliCallbackHandler, + ) + .await?; + + let mut authenticator = XalAuthenticator::from(ts.clone()); + let xsts_mc_services = authenticator + .get_xsts_token( + ts.device_token.as_ref(), + ts.title_token.as_ref(), + ts.user_token.as_ref(), + "rp://api.minecraftservices.com/", + ) + .await?; + + let identity_token = xsts_mc_services.authorization_header_value(); + println!("identityToken: {identity_token}"); + + /* Minecraft stuff */ + // Fetch minecraft token + let mc_token = reqwest::Client::new() + .post("https://api.minecraftservices.com/authentication/login_with_xbox") + .json(&json!({"identityToken": identity_token})) + .send() + .await? + .json_ex::() + .await?; + println!("MC: {mc_token:?}"); + + // Get minecraft entitlements + let entitlements = reqwest::Client::new() + .get("https://api.minecraftservices.com/entitlements/mcstore") + .bearer_auth(mc_token.access_token().secret()) + .send() + .await? + .text() + .await?; + println!("Entitlements: {entitlements}"); + + // Get minecraft profile + let profile = reqwest::Client::new() + .get("https://api.minecraftservices.com/minecraft/profile") + .bearer_auth(mc_token.access_token().secret()) + .send() + .await? + .text() + .await?; + println!("Profile: {profile}"); + + Ok(()) +} diff --git a/examples/src/bin/auth_webview.rs b/examples/src/bin/auth_webview.rs index ffdfeb0..e82ea45 100644 --- a/examples/src/bin/auth_webview.rs +++ b/examples/src/bin/auth_webview.rs @@ -16,7 +16,7 @@ use wry::{ use xal::{ flows::{AuthPromptCallback, AuthPromptData}, url::Url, - Error, XalAuthenticator, AccessTokenPrefix, + AccessTokenPrefix, Error, XalAuthenticator, }; use xal_examples::auth_main_default; @@ -111,5 +111,9 @@ async fn main() -> Result<(), Error> { .to_owned(), }; - auth_main_default(AccessTokenPrefix::None, callback_handler).await + auth_main_default(AccessTokenPrefix::None, callback_handler) + .await + .ok(); + + Ok(()) } diff --git a/examples/src/lib.rs b/examples/src/lib.rs index 612c2b9..31374a8 100644 --- a/examples/src/lib.rs +++ b/examples/src/lib.rs @@ -1,7 +1,10 @@ use clap::{Parser, ValueEnum}; use env_logger::Env; use log::info; -use xal::{flows, Error, XalAppParameters, XalAuthenticator, XalClientParameters, AccessTokenPrefix, Constants}; +use xal::{ + flows, tokenstore::TokenStore, AccessTokenPrefix, Constants, Error, XalAppParameters, + XalAuthenticator, XalClientParameters, +}; /// Common cli arguments #[derive(Parser, Debug)] @@ -54,13 +57,12 @@ pub enum AuthFlow { pub async fn auth_main_default( access_token_prefix: AccessTokenPrefix, - auth_cb: impl flows::AuthPromptCallback -) -> Result<(), Error> { + auth_cb: impl flows::AuthPromptCallback, +) -> Result { auth_main( XalAppParameters::default(), XalClientParameters::default(), "RETAIL".to_owned(), - Constants::RELYING_PARTY_XBOXLIVE.into(), access_token_prefix, auth_cb, ) @@ -72,31 +74,28 @@ pub async fn auth_main( app_params: XalAppParameters, client_params: XalClientParameters, sandbox_id: String, - xsts_relying_party: String, access_token_prefix: AccessTokenPrefix, auth_cb: impl flows::AuthPromptCallback, -) -> Result<(), Error> { +) -> Result { let args = handle_args(); - let mut ts = match flows::try_refresh_tokens_from_file(&args.token_filepath).await { + let mut ts = match flows::try_refresh_live_tokens_from_file(&args.token_filepath).await { Ok((mut authenticator, ts)) => { info!("Tokens refreshed succesfully, proceeding with Xbox Live Authorization"); match args.flow { AuthFlow::Sisu => { info!("Authorize and gather rest of xbox live tokens via sisu"); - flows::xbox_live_sisu_authorization_flow( - &mut authenticator, ts.live_token - ) - .await? - }, + flows::xbox_live_sisu_authorization_flow(&mut authenticator, ts.live_token) + .await? + } _ => { info!("Authorize Xbox Live the traditional way, via individual requests"); flows::xbox_live_authorization_traditional_flow( &mut authenticator, ts.live_token, - xsts_relying_party, + Constants::RELYING_PARTY_XBOXLIVE.into(), access_token_prefix, - false + false, ) .await? } @@ -108,9 +107,12 @@ pub async fn auth_main( info!("Authentication via flow={:?}", args.flow); let ts = match args.flow { - AuthFlow::Sisu => flows::xbox_live_sisu_full_flow(&mut authenticator, auth_cb).await?, + AuthFlow::Sisu => { + flows::xbox_live_sisu_full_flow(&mut authenticator, auth_cb).await? + } AuthFlow::DeviceCode => { - flows::ms_device_code_flow(&mut authenticator, auth_cb, tokio::time::sleep).await? + flows::ms_device_code_flow(&mut authenticator, auth_cb, tokio::time::sleep) + .await? } AuthFlow::Implicit => { flows::ms_authorization_flow(&mut authenticator, auth_cb, true).await? @@ -129,12 +131,12 @@ pub async fn auth_main( flows::xbox_live_authorization_traditional_flow( &mut authenticator, ts.live_token, - xsts_relying_party, + Constants::RELYING_PARTY_XBOXLIVE.into(), access_token_prefix, false, ) .await? - }, + } } } }; @@ -142,5 +144,5 @@ pub async fn auth_main( ts.update_timestamp(); ts.save_to_file(&args.token_filepath)?; - Ok(()) + Ok(ts) } diff --git a/src/authenticator.rs b/src/authenticator.rs index 105fc71..5883c3c 100644 --- a/src/authenticator.rs +++ b/src/authenticator.rs @@ -1,9 +1,9 @@ //! Authentication functionality. -use crate::{RequestSigner, AccessTokenPrefix}; use crate::extensions::{ CorrelationVectorReqwestBuilder, JsonExDeserializeMiddleware, LoggingReqwestRequestHandler, LoggingReqwestResponseHandler, SigningReqwestBuilder, }; +use crate::{AccessTokenPrefix, RequestSigner}; use crate::request::{ XADProperties, XASTProperties, XASUProperties, XSTSProperties, XTokenRequest, @@ -31,50 +31,54 @@ pub struct Constants; impl Constants { /// Redirect URL for implicit / authorization code flow - pub const OAUTH20_DESKTOP_REDIRECT_URL: &str = "https://login.live.com/oauth20_desktop.srf"; + pub const OAUTH20_DESKTOP_REDIRECT_URL: &'static str = + "https://login.live.com/oauth20_desktop.srf"; /// live.com Authorization URL - pub const OAUTH20_AUTHORIZE_URL: &str = "https://login.live.com/oauth20_authorize.srf"; + pub const OAUTH20_AUTHORIZE_URL: &'static str = "https://login.live.com/oauth20_authorize.srf"; /// live.com Device Authorization URL (Device Code flow) - pub const OAUTH20_DEVICE_AUTHORIZE_URL: &str = "https://login.live.com/oauth20_connect.srf"; + pub const OAUTH20_DEVICE_AUTHORIZE_URL: &'static str = + "https://login.live.com/oauth20_connect.srf"; /// live.com Remote Device Authorization URL (Device Code flow) - to assemble device authorization URL incl. OTC - pub const OAUTH20_DEVICE_REMOTEAUTHORIZE_URL: &str = + pub const OAUTH20_DEVICE_REMOTEAUTHORIZE_URL: &'static str = "https://login.live.com/oauth20_remoteconnect.srf"; /// live.com Token URL - pub const OAUTH20_TOKEN_URL: &str = "https://login.live.com/oauth20_token.srf"; + pub const OAUTH20_TOKEN_URL: &'static str = "https://login.live.com/oauth20_token.srf"; /// live.com authentication finish URL /// Called f.e. on end of device code flow - pub const OAUTH20_FINISH_FLOW_URL: &str = "https://login.live.com/ppsecure/post.srf"; + pub const OAUTH20_FINISH_FLOW_URL: &'static str = "https://login.live.com/ppsecure/post.srf"; /// Xbox Title endpoints URL, returns signing policies for supported domains/endpoints - pub const XBOX_TITLE_ENDPOINTS_URL: &str = + pub const XBOX_TITLE_ENDPOINTS_URL: &'static str = "https://title.mgt.xboxlive.com/titles/default/endpoints"; /// Xbox Sisu authentication endpoint - pub const XBOX_SISU_AUTHENTICATE_URL: &str = "https://sisu.xboxlive.com/authenticate"; + pub const XBOX_SISU_AUTHENTICATE_URL: &'static str = "https://sisu.xboxlive.com/authenticate"; /// Xbox Sisu authorization endpoint - pub const XBOX_SISU_AUTHORIZE_URL: &str = "https://sisu.xboxlive.com/authorize"; + pub const XBOX_SISU_AUTHORIZE_URL: &'static str = "https://sisu.xboxlive.com/authorize"; /// Xbox Device Authentication endpoint (XASD token) - pub const XBOX_DEVICE_AUTH_URL: &str = "https://device.auth.xboxlive.com/device/authenticate"; + pub const XBOX_DEVICE_AUTH_URL: &'static str = + "https://device.auth.xboxlive.com/device/authenticate"; /// Xbox Title Authentication endpoint (XAST token) - pub const XBOX_TITLE_AUTH_URL: &str = "https://title.auth.xboxlive.com/title/authenticate"; + pub const XBOX_TITLE_AUTH_URL: &'static str = + "https://title.auth.xboxlive.com/title/authenticate"; /// Xbox User Authentication endpoint (XASU token) - pub const XBOX_USER_AUTH_URL: &str = "https://user.auth.xboxlive.com/user/authenticate"; + pub const XBOX_USER_AUTH_URL: &'static str = "https://user.auth.xboxlive.com/user/authenticate"; /// Xbox Service Authorization endpoint (XSTS token) - pub const XBOX_XSTS_AUTH_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize"; + pub const XBOX_XSTS_AUTH_URL: &'static str = "https://xsts.auth.xboxlive.com/xsts/authorize"; /// Default Xbox Live authorization scope - pub const SCOPE_SERVICE_USER_AUTH: &str = "service::user.auth.xboxlive.com::MBI_SSL"; + pub const SCOPE_SERVICE_USER_AUTH: &'static str = "service::user.auth.xboxlive.com::MBI_SSL"; /// Signin Xbox Live authorization scope (used for custom Azure apps) - pub const SCOPE_XBL_SIGNIN: &str = "Xboxlive.signin"; + pub const SCOPE_XBL_SIGNIN: &'static str = "Xboxlive.signin"; /// Offline access Xbox Live authorization scope (used for custom Azure apps) - pub const SCOPE_XBL_OFFLINE_ACCESS: &str = "Xboxlive.offline_access"; + pub const SCOPE_XBL_OFFLINE_ACCESS: &'static str = "Xboxlive.offline_access"; /// Relying Party Auth Xbox Live - pub const RELYING_PARTY_AUTH_XBOXLIVE: &str = "http://auth.xboxlive.com"; + pub const RELYING_PARTY_AUTH_XBOXLIVE: &'static str = "http://auth.xboxlive.com"; /// Relying Party Xbox Live - pub const RELYING_PARTY_XBOXLIVE: &str = "http://xboxlive.com"; + pub const RELYING_PARTY_XBOXLIVE: &'static str = "http://xboxlive.com"; } /// XAL Authenticator @@ -140,7 +144,7 @@ impl XalAuthenticator { /// ``` /// use xal::XalAuthenticator; /// use xal::oauth2::UserCode; - /// + /// /// let user_code = UserCode::new("abc123".to_string()); /// let verification_uri = XalAuthenticator::get_device_code_verification_uri(&user_code); /// println!("{:?}", verification_uri); @@ -178,7 +182,7 @@ impl XalAuthenticator { .query_pairs() .map(|(k, v)| (k.to_string(), v.to_string())) .collect(); - + if let Some(state) = expected_state { if let Some(state_resp) = query_map.get("state") { if state.secret() != state_resp { @@ -194,24 +198,23 @@ impl XalAuthenticator { )); } } - + if let Some(error) = query_map.get("error") { - let error_resp: StandardErrorResponse = serde_json::from_value( - serde_json::json!({ + let error_resp: StandardErrorResponse = + serde_json::from_value(serde_json::json!({ "error": error, "error_description": query_map.get("error_description").map(|x| x.to_string()), "error_uri": query_map.get("error_uri").map(|x| x.to_string()), - }), - ) - .map_err(|e|Error::JsonError(e))?; - + })) + .map_err(Error::JsonError)?; + return Err(Error::OAuthExecutionError( oauth2::RequestTokenError::ServerResponse(error_resp), )); } else if let Some(code) = query_map.get("code") { return Ok(AuthorizationCode::new(code.to_owned())); } - + Err(Error::GeneralError( "Response neither had 'code' nor 'error' field".into(), )) @@ -237,7 +240,7 @@ impl XalAuthenticator { /// use xal::XalAuthenticator; /// use xal::oauth2::CsrfToken; /// use xal::url::Url; - /// + /// /// let url = Url::parse("https://example.com/callback#access_token=token123&token_type=Bearer&expires_in=3600&state=123abc") /// .unwrap(); /// let state = "123abc".to_string(); @@ -252,10 +255,10 @@ impl XalAuthenticator { let fragment = url .fragment() .ok_or(Error::InvalidRedirectUrl("No fragment found".to_string()))?; - + let mut kv_pairs: HashMap = HashMap::new(); let mut state_resp = None; - + for (k, v) in form_urlencoded::parse(fragment.as_bytes()) { match k.as_ref() { "expires_in" => { @@ -270,7 +273,7 @@ impl XalAuthenticator { } } } - + if let Some(state) = expected_state { if let Some(s) = state_resp { if state.secret() != &s.to_string() { @@ -283,7 +286,7 @@ impl XalAuthenticator { return Err(Error::InvalidRedirectUrl("No state found".to_string())); } } - + Ok(serde_json::from_value(json!(kv_pairs))?) } } @@ -371,13 +374,13 @@ impl XalAuthenticator { /// /// Defining a `redirect_url` in [`crate::XalAppParameters`] is mandatory /// for this authentication flow - /// + /// /// # Examples - /// + /// /// ``` /// use xal::{XalAuthenticator, XalAppParameters, client_params}; /// use xal::oauth2::{RedirectUrl, Scope}; - /// + /// /// # async fn demo_code() { /// let mut authenticator = XalAuthenticator::new( /// XalAppParameters { @@ -392,10 +395,10 @@ impl XalAuthenticator { /// client_params::CLIENT_ANDROID(), /// "RETAIL".into() /// ); - /// + /// /// let (url, state) = authenticator.get_authorization_url(false) /// .unwrap(); - /// + /// /// assert!(url.as_str().starts_with("https://login.live.com/oauth20_desktop.srf")); /// # } /// ``` @@ -422,10 +425,10 @@ impl XalAuthenticator { } /// Initiates the Device Code Authentication Flow. - /// + /// /// After presenting the returned [`crate::oauth2:: EndUserVerificationUrl`] and [`crate::oauth2::UserCode`] /// to the user, call `poll_device_code_auth`. - /// + /// /// You can transform the returned value into [`crate::oauth2::VerificationUriComplete`] by calling `get_device_code_verification_uri`. pub async fn initiate_device_code_auth( &mut self, @@ -441,11 +444,11 @@ impl XalAuthenticator { } /// Poll for device code. - /// + /// /// To be called after presenting the result of `start_device_code_auth` to the user. - /// + /// /// # Arguments - /// + /// /// - `sleep_fn` is the impl of an async sleep function pub async fn poll_device_code_auth( &mut self, @@ -464,16 +467,16 @@ impl XalAuthenticator { } /// Exchange OAuth2 Authorization Token for Windows Live Access Token. - /// + /// /// This method utilizes the PKCE extension to securely obtain an access token from the Microsoft Identity Platform. - /// + /// /// # Arguments - /// + /// /// * `authorization_code` - The authorization code received from the user authentication step. /// * `code_verifier` - The code verifier that was generated earlier in the PKCE process. This parameter is optional. - /// + /// /// # Examples - /// + /// /// ``` /// use xal::XalAuthenticator; /// use xal::oauth2::{AuthorizationCode, TokenResponse}; @@ -483,7 +486,7 @@ impl XalAuthenticator { /// let live_tokens = authenticator /// .exchange_code_for_token(code, None) /// .await?; - /// + /// /// assert!(!live_tokens.access_token().secret().is_empty()); /// # Ok(()) /// # } @@ -495,8 +498,7 @@ impl XalAuthenticator { ) -> Result { let client = self.oauth_client(None)?; - let mut req = client - .exchange_code(authorization_code); + let mut req = client.exchange_code(authorization_code); if let Some(redirect_url) = &self.app_params.redirect_uri { req = req.set_redirect_uri(std::borrow::Cow::Owned(redirect_url.clone())); @@ -512,16 +514,16 @@ impl XalAuthenticator { } /// Refresh an OAuth2 Refresh Token for specific scope(s) and deserialize into custom response type - /// + /// /// This is used when the token response does not align with the standard. - /// + /// /// # Examples - /// + /// /// ``` /// use xal::XalAuthenticator; /// use xal::oauth2::{RefreshToken, Scope}; /// use serde::{Deserialize, Serialize}; - /// + /// /// // Custom JSON response body /// #[derive(Debug, Serialize, Deserialize)] /// pub struct XCloudTokenResponse { @@ -529,7 +531,7 @@ impl XalAuthenticator { /// pub refresh_token: String, /// pub user_id: String, /// } - /// + /// /// # async fn demo_code() { /// # let refresh_token = RefreshToken::new("...refresh token...".into()); /// let mut authenticator = XalAuthenticator::default(); @@ -538,7 +540,7 @@ impl XalAuthenticator { /// "service::http://Passport.NET/purpose::PURPOSE_XBOX_CLOUD_CONSOLE_TRANSFER_TOKEN".into() /// ) /// ]; - /// + /// /// let token_response = authenticator /// .refresh_token_for_scope::( /// &refresh_token, @@ -548,7 +550,7 @@ impl XalAuthenticator { /// .unwrap(); /// # } /// ``` - /// + /// pub async fn refresh_token_for_scope( &mut self, refresh_token: &RefreshToken, @@ -571,22 +573,22 @@ impl XalAuthenticator { Err(RequestTokenError::Parse(_, data)) => { serde_json::from_slice(&data).map_err(std::convert::Into::into) } - Err(e) => Err(e).map_err(std::convert::Into::into), + Err(e) => Err(std::convert::Into::into(e)), } } /// Refresh a Windows Live Refresh & Access Token by providing a Refresh Token - /// + /// /// # Arguments - /// + /// /// * `refresh_token` - The refresh token to use for obtaining a new access token - /// + /// /// # Examples - /// + /// /// ``` /// use xal::XalAuthenticator; /// use xal::oauth2::RefreshToken; - /// + /// /// let authenticator = XalAuthenticator::default(); /// let refresh_token = RefreshToken::new("old_refresh_token".to_string()); /// /* @@ -594,7 +596,7 @@ impl XalAuthenticator { /// .refresh_token(&refresh_token) /// .await /// .unwrap(); - /// + /// /// println!("Refreshed tokens: {refreshed_live_tokens:?}"); /// */ /// ``` @@ -610,25 +612,25 @@ impl XalAuthenticator { /// Xbox Live token functionality impl XalAuthenticator { /// Initiate authentication via SISU flow - /// + /// /// # Parameters - /// + /// /// * `device_token`: A [`response::DeviceToken`] object representing the device token. /// * `code_challenge`: A [`PkceCodeChallenge`] object representing the code challenge. /// * `state`: A [`CsrfToken`] object representing the CSRF token. - /// + /// /// # Errors - /// + /// /// * If `device_token` is missing. /// * If `redirect_uri` is missing. /// * If the Sisu Authentication request fails. /// /// # Examples - /// + /// /// ``` /// use xal::XalAuthenticator; /// use xal::url::Url; - /// + /// /// # async fn demo_code() { /// let mut authenticator = XalAuthenticator::default(); /// let state = XalAuthenticator::generate_random_state(); @@ -636,7 +638,7 @@ impl XalAuthenticator { /// let device_token = authenticator.get_device_token() /// .await /// .unwrap(); - /// + /// /// let (resp, session_id) = authenticator.sisu_authenticate( /// &device_token, /// &pkce_challenge, @@ -644,23 +646,23 @@ impl XalAuthenticator { /// ) /// .await /// .unwrap(); - /// + /// /// println!( /// "Visit this url and pass back the redirect url containing the authorization code {}", /// resp.msa_oauth_redirect /// ); /// let redirect_url = Url::parse("https://example.com/?code=123").unwrap(); - /// + /// /// let authorization_code = XalAuthenticator::parse_authorization_code_response( /// &redirect_url, Some(&state) /// ).unwrap(); - /// + /// /// let live_tokens = authenticator.exchange_code_for_token( /// authorization_code, Some(pkce_verifier) /// ) /// .await /// .unwrap(); - /// + /// /// let sisu_authorization_resp = authenticator.sisu_authorize( /// &live_tokens, &device_token, Some(session_id) /// ) @@ -668,9 +670,9 @@ impl XalAuthenticator { /// .unwrap(); /// # } /// ``` - /// + /// /// # Notes - /// + /// /// It is mandatory to have [`XalAppParameters`] setup with a `redirect_uri` and `title_id`. pub async fn sisu_authenticate( &mut self, @@ -684,9 +686,13 @@ impl XalAuthenticator { ), Error, > { - let title_id = self.app_params.title_id + let title_id = self + .app_params + .title_id .clone() - .ok_or(Error::InvalidRequest("Sisu authentication not possible without title Id (check XalAppParameters)".into()))?; + .ok_or(Error::InvalidRequest( + "Sisu authentication not possible without title Id (check XalAppParameters)".into(), + ))?; let json_body = request::SisuAuthenticationRequest { app_id: &self.app_params.app_id, @@ -732,16 +738,16 @@ impl XalAuthenticator { } /// Authorize via SISU flow after completing OAuth2 Authentication - /// + /// /// This function handles the second step of the SISU flow. /// The response from the server contains a collection of tokens, which can be used for further interaction with the Xbox Live service. - /// + /// /// # Examples - /// + /// /// ``` /// use xal::XalAuthenticator; /// use xal::url::Url; - /// + /// /// # async fn demo_code() { /// let mut authenticator = XalAuthenticator::default(); /// let state = XalAuthenticator::generate_random_state(); @@ -749,7 +755,7 @@ impl XalAuthenticator { /// let device_token = authenticator.get_device_token() /// .await /// .unwrap(); - /// + /// /// let (resp, session_id) = authenticator.sisu_authenticate( /// &device_token, /// &pkce_challenge, @@ -757,23 +763,23 @@ impl XalAuthenticator { /// ) /// .await /// .unwrap(); - /// + /// /// println!( /// "Visit this url and pass back the redirect url containing the authorization code {}", /// resp.msa_oauth_redirect /// ); /// let redirect_url = Url::parse("https://example.com/?code=123").unwrap(); - /// + /// /// let authorization_code = XalAuthenticator::parse_authorization_code_response( /// &redirect_url, Some(&state) /// ).unwrap(); - /// + /// /// let live_tokens = authenticator.exchange_code_for_token( /// authorization_code, Some(pkce_verifier) /// ) /// .await /// .unwrap(); - /// + /// /// let sisu_authorization_resp = authenticator.sisu_authorize( /// &live_tokens, &device_token, Some(session_id) /// ) @@ -818,29 +824,29 @@ impl XalAuthenticator { /// This method returns an `Error` if the POST request fails or the JSON response cannot be parsed. /// /// # Examples - /// + /// /// ``` /// # async fn demo_code() { /// use xal::XalAuthenticator; - /// + /// /// let mut authenticator = XalAuthenticator::default(); /// let device_token = authenticator.get_device_token() /// .await /// .unwrap(); - /// + /// /// assert!(!device_token.token.is_empty()); /// # } /// ``` - /// + /// /// # Notes - /// + /// /// Device tokens can only be requested for devices of the following type: - /// + /// /// - Android /// - iOS /// - Nintendo /// - Win32 - /// + /// /// Xbox devices use a much more sophisticated request method. pub async fn get_device_token(&mut self) -> Result { let device_id = self.device_id.hyphenated().to_string(); @@ -882,7 +888,7 @@ impl XalAuthenticator { /// /// This method sends a POST request to the Xbox Live User Authentication URL, using the provided /// `access_token` and `prefix`. - /// + /// /// The resulting User Token is then used to retrieve the final *XSTS* token to access Xbox Live services. /// /// # Arguments @@ -934,7 +940,7 @@ impl XalAuthenticator { /// /// This method sends a POST request to the Xbox Live Title Authentication URL, using the provided /// `access_token` and `device_token`. - /// + /// /// The resulting Title Token is then used to retrieve the final *XSTS* token to access Xbox Live services. /// /// # Arguments @@ -983,7 +989,7 @@ impl XalAuthenticator { /// /// This method sends a POST request to the Xbox Live XSTS Authentication URL, using the provided `relying_party` /// and optionally `device_token`, `title_token`, and `user_token`. - /// + /// /// The resulting XSTS token can be used to authenticate with various Xbox Live services. /// /// # Arguments @@ -1041,9 +1047,9 @@ impl XalAuthenticator { #[cfg(test)] mod test { - use std::time::Duration; - use oauth2::{basic::BasicTokenType, RequestTokenError}; use super::*; + use oauth2::{basic::BasicTokenType, RequestTokenError}; + use std::time::Duration; fn parse_authorization_code_response( url: &'static str, diff --git a/src/flows.rs b/src/flows.rs index 8adeb80..68537c1 100644 --- a/src/flows.rs +++ b/src/flows.rs @@ -10,7 +10,7 @@ use url::Url; use crate::{ response::{SisuAuthenticationResponse, WindowsLiveTokens}, tokenstore::TokenStore, - Error, XalAuthenticator, AccessTokenPrefix, + AccessTokenPrefix, Error, XalAuthenticator, }; /// Argument passed into [`crate::flows::AuthPromptCallback`] @@ -170,7 +170,7 @@ pub trait AuthPromptCallback { /// /// This function takes an argument of type [`crate::flows::AuthPromptData`], which provides the necessary data for the interactive /// authentication process. - /// + /// /// The function returns a [`Result`] that represents either a successfully completed interactive authentication or an error that /// occurred during the process. /// @@ -219,11 +219,33 @@ impl AuthPromptCallback for CliCallbackHandler { /// This function may return an error if the file cannot be read, fails to deserialize or the /// tokens cannot be refreshed. /// +/// # Examples +/// +/// ``` +/// # use xal::{Error, tokenstore::TokenStore}; +/// use xal::flows; +/// +/// # async fn demo_code() -> Result<(), Error> { +/// // Refresh Windows Live tokens first +/// let (mut authenticator, token_store) = flows::try_refresh_live_tokens_from_file("tokens.json") +/// .await?; +/// +/// // Continue by requesting xbox live tokens +/// let token_store = flows::xbox_live_sisu_authorization_flow( +/// &mut authenticator, +/// token_store.live_token +/// ) +/// .await?; +/// +/// # Ok(()) +/// # } +/// ``` +/// /// # Returns -/// +/// /// If successful, a tuple of [`crate::XalAuthenticator`] and [`crate::tokenstore::TokenStore`] /// is returned. TokenStore will contain the refreshed `live_tokens`. -pub async fn try_refresh_tokens_from_file( +pub async fn try_refresh_live_tokens_from_file( filepath: &str, ) -> Result<(XalAuthenticator, TokenStore), Error> { let mut ts = TokenStore::load_from_file(filepath)?; @@ -233,12 +255,32 @@ pub async fn try_refresh_tokens_from_file( /// Try to read tokens from the token store and refresh the Windows Live tokens if needed. /// +/// # Examples +/// +/// ``` +/// use std::fs::File; +/// use serde_json; +/// use xal::{flows, tokenstore::TokenStore}; +/// +/// # async fn demo_code() -> Result<(), xal::Error> { +/// let mut file = File::open("tokens.json") +/// .expect("Failed to open tokenfile"); +/// let mut ts: TokenStore = serde_json::from_reader(&mut file) +/// .expect("Failed to deserialize TokenStore"); +/// +/// let authenticator = flows::try_refresh_live_tokens_from_tokenstore(&mut ts) +/// .await +/// .expect("Failed refreshing Windows Live tokens"); +/// # Ok(()) +/// # } +/// ``` +/// /// # Errors /// /// This function may return an error if the token store cannot be read or the tokens cannot be refreshed. /// /// # Returns -/// +/// /// If successful, a tuple of [`crate::XalAuthenticator`] and [`crate::tokenstore::TokenStore`] /// is returned. TokenStore will contain the refreshed `live_tokens`. pub async fn try_refresh_live_tokens_from_tokenstore( @@ -259,6 +301,34 @@ pub async fn try_refresh_live_tokens_from_tokenstore( } /// Shorthand for Windows Live device code flow +/// +/// # Examples +/// +/// ``` +/// use xal::{XalAuthenticator, flows, Error, AccessTokenPrefix}; +/// use xal::response::WindowsLiveTokens; +/// +/// # async fn async_sleep_fn(_: std::time::Duration) {} +/// +/// # async fn example() -> Result<(), Error> { +/// let do_implicit_flow = true; +/// let mut authenticator = XalAuthenticator::default(); +/// +/// let token_store = flows::ms_device_code_flow( +/// &mut authenticator, +/// flows::CliCallbackHandler, +/// async_sleep_fn +/// ) +/// .await?; +/// +/// // TokenStore will only contain live tokens +/// assert!(token_store.user_token.is_none()); +/// assert!(token_store.title_token.is_none()); +/// assert!(token_store.device_token.is_none()); +/// assert!(token_store.authorization_token.is_none()); +/// # Ok(()) +/// # } +/// ``` pub async fn ms_device_code_flow( authenticator: &mut XalAuthenticator, cb: impl AuthPromptCallback, @@ -269,9 +339,7 @@ where SF: std::future::Future, { trace!("Initiating device code flow"); - let device_code_flow = authenticator - .initiate_device_code_auth() - .await?; + let device_code_flow = authenticator.initiate_device_code_auth().await?; debug!("Device code={:?}", device_code_flow); trace!("Reaching into callback to notify caller about device code url"); @@ -302,6 +370,32 @@ where /// Shorthand for Windows Live authorization flow /// - Depending on the argument `implicit` the /// methods `implicit grant` or `authorization code` are chosen +/// +/// # Examples +/// +/// ``` +/// use xal::{XalAuthenticator, flows, Error, AccessTokenPrefix}; +/// use xal::response::WindowsLiveTokens; +/// +/// # async fn example() -> Result<(), Error> { +/// let do_implicit_flow = true; +/// let mut authenticator = XalAuthenticator::default(); +/// +/// let token_store = flows::ms_authorization_flow( +/// &mut authenticator, +/// flows::CliCallbackHandler, +/// do_implicit_flow, +/// ) +/// .await?; +/// +/// // TokenStore will only contain live tokens +/// assert!(token_store.user_token.is_none()); +/// assert!(token_store.title_token.is_none()); +/// assert!(token_store.device_token.is_none()); +/// assert!(token_store.authorization_token.is_none()); +/// # Ok(()) +/// # } +/// ``` pub async fn ms_authorization_flow( authenticator: &mut XalAuthenticator, cb: impl AuthPromptCallback, @@ -309,8 +403,7 @@ pub async fn ms_authorization_flow( ) -> Result { trace!("Starting implicit authorization flow"); - let (url, state) = - authenticator.get_authorization_url(implicit)?; + let (url, state) = authenticator.get_authorization_url(implicit)?; trace!("Reaching into callback to receive authentication redirect URL"); let redirect_url = cb @@ -354,6 +447,30 @@ pub async fn ms_authorization_flow( } /// Shorthand for sisu authentication flow +/// +/// # Examples +/// +/// ``` +/// use xal::{XalAuthenticator, flows, Error, AccessTokenPrefix}; +/// use xal::response::WindowsLiveTokens; +/// +/// # async fn example() -> Result<(), Error> { +/// let mut authenticator = XalAuthenticator::default(); +/// +/// let token_store = flows::xbox_live_sisu_full_flow( +/// &mut authenticator, +/// flows::CliCallbackHandler, +/// ) +/// .await?; +/// +/// // TokenStore will contain user/title/device/xsts tokens +/// assert!(token_store.user_token.is_some()); +/// assert!(token_store.title_token.is_some()); +/// assert!(token_store.device_token.is_some()); +/// assert!(token_store.authorization_token.is_some()); +/// # Ok(()) +/// # } +/// ``` pub async fn xbox_live_sisu_full_flow( authenticator: &mut XalAuthenticator, callback: impl AuthPromptCallback, @@ -425,7 +542,7 @@ pub async fn xbox_live_sisu_full_flow( /// /// The method serves as a shorthand for executing the Xbox Live authorization flow by exchanging /// [`crate::models::response::WindowsLiveTokens`] to ultimately acquire an authorized Xbox Live session. -/// +/// /// The authorization flow is designed to be highly modular, allowing for extensive customization /// based on the specific needs of your application. /// @@ -451,26 +568,36 @@ pub async fn xbox_live_sisu_full_flow( /// # Examples /// /// ``` +/// use xal::{XalAuthenticator, flows, Error, AccessTokenPrefix}; +/// use xal::response::WindowsLiveTokens; +/// +/// # async fn async_sleep_fn(_: std::time::Duration) {} +/// /// # async fn example() -> Result<(), Error> { -/// // Assume the following authenticator and tokens have been obtained.. /// let mut authenticator = XalAuthenticator::default(); -/// let live_tokens = WindowsLiveTokens { /*...*/ }; +/// +/// let token_store = flows::ms_device_code_flow( +/// &mut authenticator, +/// flows::CliCallbackHandler, +/// async_sleep_fn +/// ) +/// .await?; /// /// // Execute the Xbox Live authorization flow.. -/// let (authenticator, token_store) = authenticator -/// .do_xbox_live_authorization_flow( -/// live_tokens, -/// "rp://api.minecraftservices.com/".to_string(), -/// AccessTokenPrefix::MSAL, -/// true, -/// ) -/// .await?; +/// let token_store = flows::xbox_live_authorization_traditional_flow( +/// &mut authenticator, +/// token_store.live_token, +/// "rp://api.minecraftservices.com/".to_string(), +/// AccessTokenPrefix::D, +/// true, +/// ) +/// .await?; /// # Ok(()) /// # } /// ``` -/// +/// /// # Notes -/// +/// /// - Requesting a Title Token *standalone* aka. without sisu-flow only works for very few clients, /// currently only "Minecraft" is known. /// - Depending on the client an AccessToken prefix is necessary to have the User Token (XASU) request succeed @@ -534,7 +661,40 @@ pub async fn xbox_live_authorization_traditional_flow( Ok(ts) } -/// bla +/// The authorization part of Sisu +/// +/// # Examples +/// +/// ``` +/// use xal::{XalAuthenticator, flows, Error, AccessTokenPrefix}; +/// use xal::response::WindowsLiveTokens; +/// +/// # async fn async_sleep_fn(_: std::time::Duration) {} +/// +/// # async fn example() -> Result<(), Error> { +/// let mut authenticator = XalAuthenticator::default(); +/// +/// let token_store = flows::ms_device_code_flow( +/// &mut authenticator, +/// flows::CliCallbackHandler, +/// async_sleep_fn +/// ) +/// .await?; +/// +/// let token_store = flows::xbox_live_sisu_authorization_flow( +/// &mut authenticator, +/// token_store.live_token, +/// ) +/// .await?; +/// +/// // TokenStore will contain user/title/device/xsts tokens +/// assert!(token_store.user_token.is_some()); +/// assert!(token_store.title_token.is_some()); +/// assert!(token_store.device_token.is_some()); +/// assert!(token_store.authorization_token.is_some()); +/// # Ok(()) +/// # } +/// ``` pub async fn xbox_live_sisu_authorization_flow( authenticator: &mut XalAuthenticator, live_tokens: WindowsLiveTokens, @@ -545,7 +705,9 @@ pub async fn xbox_live_sisu_authorization_flow( debug!("Device token={:?}", device_token); trace!("Getting user token"); - let resp = authenticator.sisu_authorize(&live_tokens, &device_token, None).await?; + let resp = authenticator + .sisu_authorize(&live_tokens, &device_token, None) + .await?; debug!("Sisu authorization response"); let ts = TokenStore { @@ -562,4 +724,4 @@ pub async fn xbox_live_sisu_authorization_flow( }; Ok(ts) -} \ No newline at end of file +} diff --git a/src/models.rs b/src/models.rs index 81a5cf7..49df472 100644 --- a/src/models.rs +++ b/src/models.rs @@ -312,7 +312,7 @@ pub mod response { issue_instant: "2020-12-15T00:00:00.0000000Z".into(), not_after: "2199-12-15T00:00:00.0000000Z".into(), token: s.to_owned(), - display_claims: None + display_claims: None, } } } @@ -405,9 +405,9 @@ pub mod response { } /// Access Token prefix -/// +/// /// Relevant for fetching the UserToken -/// +/// /// Exact conditions are still unknown, when to use which format. #[derive(Debug, Eq, PartialEq, Clone)] pub enum AccessTokenPrefix { @@ -416,7 +416,7 @@ pub enum AccessTokenPrefix { /// Prefix access token with "t=" T, /// Use token string as-is - None + None, } impl ToString for AccessTokenPrefix { @@ -455,7 +455,7 @@ impl FromStr for DeviceType { "ios" => DeviceType::IOS, "win32" => DeviceType::WIN32, "nintendo" => DeviceType::NINTENDO, - val => DeviceType::Custom(val.to_owned()) + val => DeviceType::Custom(val.to_owned()), }; Ok(enm) } @@ -496,8 +496,8 @@ pub struct XalAppParameters { pub mod app_params { use oauth2::{RedirectUrl, Scope}; - use crate::Constants; use super::XalAppParameters; + use crate::Constants; /// Xbox Beta App pub fn APP_XBOX_BETA() -> XalAppParameters { @@ -583,7 +583,9 @@ pub mod app_params { app_id: "00000000402b5328".into(), title_id: None, auth_scopes: vec![Scope::new(Constants::SCOPE_SERVICE_USER_AUTH.to_string())], - redirect_uri: None, + redirect_uri: Some( + RedirectUrl::new(crate::Constants::OAUTH20_DESKTOP_REDIRECT_URL.into()).unwrap(), + ), } } @@ -593,7 +595,9 @@ pub mod app_params { app_id: "00000000441cc96b".into(), title_id: Some("2047319603".into()), auth_scopes: vec![Scope::new(Constants::SCOPE_SERVICE_USER_AUTH.to_string())], - redirect_uri: None, + redirect_uri: Some( + RedirectUrl::new(crate::Constants::OAUTH20_DESKTOP_REDIRECT_URL.into()).unwrap(), + ), } } @@ -603,7 +607,9 @@ pub mod app_params { app_id: "0000000048183522".into(), title_id: Some("1739947436".into()), auth_scopes: vec![Scope::new(Constants::SCOPE_SERVICE_USER_AUTH.to_string())], - redirect_uri: None, + redirect_uri: Some( + RedirectUrl::new(crate::Constants::OAUTH20_DESKTOP_REDIRECT_URL.into()).unwrap(), + ), } } @@ -613,7 +619,9 @@ pub mod app_params { app_id: "000000004c17c01a".into(), title_id: Some("1810924247".into()), auth_scopes: vec![Scope::new(Constants::SCOPE_SERVICE_USER_AUTH.to_string())], - redirect_uri: None, + redirect_uri: Some( + RedirectUrl::new(crate::Constants::OAUTH20_DESKTOP_REDIRECT_URL.into()).unwrap(), + ), } } diff --git a/src/request_signer.rs b/src/request_signer.rs index 486224d..b337656 100644 --- a/src/request_signer.rs +++ b/src/request_signer.rs @@ -3,13 +3,15 @@ use crate::{ error::Error, + extensions::JsonExDeserializeMiddleware, models::{self, SigningPolicy}, - ProofKey, response::{self, TitleEndpointsResponse}, Constants, extensions::JsonExDeserializeMiddleware, + response::{self, TitleEndpointsResponse}, + Constants, ProofKey, }; use base64ct::{self, Base64, Encoding}; use chrono::prelude::*; -use nt_time::FileTime; use log::warn; +use nt_time::FileTime; use p256::{ ecdsa::{ signature::hazmat::{PrehashSigner, PrehashVerifier}, @@ -17,7 +19,7 @@ use p256::{ }, SecretKey, }; -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ convert::{TryFrom, TryInto}, @@ -333,7 +335,7 @@ impl RequestSigner { let signing_key: SigningKey = self.keypair.clone().into(); let filetime_bytes = FileTime::try_from(timestamp) - .map_err(|e|Error::GeneralError(format!("{e}")))? + .map_err(|e| Error::GeneralError(format!("{e}")))? .to_be_bytes(); let signing_policy_version_bytes = signing_policy_version.to_be_bytes(); @@ -443,8 +445,8 @@ pub async fn get_endpoints() -> Result } /// Signature policy cache -/// -/// +/// +/// #[derive(Debug, Serialize, Deserialize, Clone)] pub struct SignaturePolicyCache { endpoints: TitleEndpointsResponse, @@ -453,9 +455,7 @@ pub struct SignaturePolicyCache { impl SignaturePolicyCache { /// Create a new SignaturePolicyCache. pub fn new(endpoints: TitleEndpointsResponse) -> Self { - Self { - endpoints - } + Self { endpoints } } /// Retrieve the stored TitleEndpointsResponse. @@ -464,46 +464,55 @@ impl SignaturePolicyCache { } /// Find the policy for the given URL. - /// + /// /// If a matching policy is found, returns the corresponding SigningPolicy. Otherwise, returns None. pub fn find_policy_for_url(&self, url: &str) -> Result, Error> { let url = url::Url::parse(url)?; - - if !["http","https"].contains(&url.scheme()) { - return Err(Error::GeneralError(format!("Url with invalid protocol passed, expected http or https, url={url}"))) + + if !["http", "https"].contains(&url.scheme()) { + return Err(Error::GeneralError(format!( + "Url with invalid protocol passed, expected http or https, url={url}" + ))); } - - let matching_endpoint = self.endpoints.end_points + + let matching_endpoint = self + .endpoints + .end_points .iter() .filter(|e| { - e.protocol.eq_ignore_ascii_case(url.scheme()) && - url.host_str().map(|host| { - match e.host_type.as_str() { - "fqdn" => host == e.host, - "wildcard" => host.ends_with(e.host.trim_start_matches('*')), - _ => false, - } - }).unwrap_or(false) && - e.path.as_ref().map(|path| url.path() == path).unwrap_or(true) && - e.signature_policy_index.is_some() + e.protocol.eq_ignore_ascii_case(url.scheme()) + && url + .host_str() + .map(|host| match e.host_type.as_str() { + "fqdn" => host == e.host, + "wildcard" => host.ends_with(e.host.trim_start_matches('*')), + _ => false, + }) + .unwrap_or(false) + && e.path + .as_ref() + .map(|path| url.path() == path) + .unwrap_or(true) + && e.signature_policy_index.is_some() }) .max_by_key(|e| e.host.len()); - + match matching_endpoint { Some(ep) => { println!("Identified Title endpoint={ep:?} for URL={url} {url:?}"); let policy_index = ep.signature_policy_index.unwrap() as usize; - let policy = self.endpoints - .signature_policies - .get(policy_index) - .ok_or(Error::GeneralError(format!("SignaturePolicy at index {policy_index} not found!")))?; - + let policy = self.endpoints.signature_policies.get(policy_index).ok_or( + Error::GeneralError(format!( + "SignaturePolicy at index {policy_index} not found!" + )), + )?; + Ok(Some(policy.to_owned())) - }, + } None => { warn!("No matched SigningPolicy for url={url:?} found"); Ok(None) - }, + } } } } @@ -532,27 +541,90 @@ mod test { #[test] fn find_matching_signing_policy() { - let policy_0: SigningPolicy = SigningPolicy { version: 1, supported_algorithms: vec![SigningAlgorithm::ES256], max_body_bytes: 8192 }; - let policy_1: SigningPolicy = SigningPolicy { version: 1, supported_algorithms: vec![SigningAlgorithm::ES256], max_body_bytes: 4294967295 }; + let policy_0: SigningPolicy = SigningPolicy { + version: 1, + supported_algorithms: vec![SigningAlgorithm::ES256], + max_body_bytes: 8192, + }; + let policy_1: SigningPolicy = SigningPolicy { + version: 1, + supported_algorithms: vec![SigningAlgorithm::ES256], + max_body_bytes: 4294967295, + }; let title_endpoints = serde_json::from_str::( - include_str!("../testdata/title_endpoints.json") - ).unwrap(); + include_str!("../testdata/title_endpoints.json"), + ) + .unwrap(); let cache = SignaturePolicyCache::new(title_endpoints); - assert!(cache.find_policy_for_url("https://unhandled.example.com").unwrap().is_none()); - assert!(cache.find_policy_for_url("https://unhandled.microsoft.com").unwrap().is_none()); + assert!(cache + .find_policy_for_url("https://unhandled.example.com") + .unwrap() + .is_none()); + assert!(cache + .find_policy_for_url("https://unhandled.microsoft.com") + .unwrap() + .is_none()); - assert_eq!(cache.find_policy_for_url("https://experimentation.xboxlive.com").unwrap().unwrap(), policy_0); - assert_eq!(cache.find_policy_for_url("https://xoobe.xboxlive.com").unwrap().unwrap(), policy_0); - assert_eq!(cache.find_policy_for_url("https://xaaa.bbtv.cn/xboxsms/OOBEService/AuthorizationStatus").unwrap().unwrap(), policy_0); + assert_eq!( + cache + .find_policy_for_url("https://experimentation.xboxlive.com") + .unwrap() + .unwrap(), + policy_0 + ); + assert_eq!( + cache + .find_policy_for_url("https://xoobe.xboxlive.com") + .unwrap() + .unwrap(), + policy_0 + ); + assert_eq!( + cache + .find_policy_for_url("https://xaaa.bbtv.cn/xboxsms/OOBEService/AuthorizationStatus") + .unwrap() + .unwrap(), + policy_0 + ); - assert_eq!(cache.find_policy_for_url("https://hello.experimentation.xboxlive.com").unwrap().unwrap(), policy_1); - assert_eq!(cache.find_policy_for_url("https://data-vef.xboxlive.com").unwrap().unwrap(), policy_1); - assert_eq!(cache.find_policy_for_url("https://settings.xboxlive.com").unwrap().unwrap(), policy_1); - assert_eq!(cache.find_policy_for_url("https://device.mgt.xboxlive.com").unwrap().unwrap(), policy_1); - assert_eq!(cache.find_policy_for_url("https://device.mgt.xboxlive.com/devices/current/unlock").unwrap().unwrap(), policy_1); + assert_eq!( + cache + .find_policy_for_url("https://hello.experimentation.xboxlive.com") + .unwrap() + .unwrap(), + policy_1 + ); + assert_eq!( + cache + .find_policy_for_url("https://data-vef.xboxlive.com") + .unwrap() + .unwrap(), + policy_1 + ); + assert_eq!( + cache + .find_policy_for_url("https://settings.xboxlive.com") + .unwrap() + .unwrap(), + policy_1 + ); + assert_eq!( + cache + .find_policy_for_url("https://device.mgt.xboxlive.com") + .unwrap() + .unwrap(), + policy_1 + ); + assert_eq!( + cache + .find_policy_for_url("https://device.mgt.xboxlive.com/devices/current/unlock") + .unwrap() + .unwrap(), + policy_1 + ); } #[test] From 4fd0eddb90e499c06809447171652d2bee6011f1 Mon Sep 17 00:00:00 2001 From: tuxuser <462620+tuxuser@users.noreply.github.com> Date: Tue, 19 Dec 2023 10:01:47 +0100 Subject: [PATCH 07/13] tests: Add testdata --- testdata/title_endpoints.json | 644 ++++++++++++++++++++++++++++++++++ 1 file changed, 644 insertions(+) create mode 100644 testdata/title_endpoints.json diff --git a/testdata/title_endpoints.json b/testdata/title_endpoints.json new file mode 100644 index 0000000..8eb4b3a --- /dev/null +++ b/testdata/title_endpoints.json @@ -0,0 +1,644 @@ +{ + "EndPoints": [ + { + "Protocol": "https", + "Host": "xlink.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://xlink.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "*.dfhosted.net", + "HostType": "wildcard", + "RelyingParty": "http://xlink.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "musicdelivery-ssl.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://music.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "cloudcollection-ssl.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://music.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "music.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://music.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "websockets.platform.bing.com", + "HostType": "fqdn", + "RelyingParty": "http://platform.bing.com/", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "websockets.platform.bing-int.com", + "HostType": "fqdn", + "RelyingParty": "http://platform.bing.com/", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "inventory.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://licensing.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "licensing.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://licensing.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "accountstroubleshooter.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://accounts.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "gamertag.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://accounts.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "help.ui.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://uxservices.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "*.ui.xboxlive.com", + "HostType": "wildcard" + }, + { + "Protocol": "https", + "Host": "data-vef.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://data-vef.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 1 + }, + { + "Protocol": "https", + "Host": "update.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://update.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "updatepc.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://update.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "update-cdn.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://update.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "packages.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://update.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "packagespc.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://update.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "instance.mgt.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://instance.mgt.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 1 + }, + { + "Protocol": "https", + "Host": "device.mgt.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://device.mgt.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 1 + }, + { + "Protocol": "https", + "Host": "device.mgt.xboxlive.com", + "HostType": "fqdn", + "Path": "/registrations/bestv", + "RelyingParty": "http://bestv.device.mgt.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 1 + }, + { + "Protocol": "https", + "Host": "device.mgt.xboxlive.com", + "HostType": "fqdn", + "Path": "/devices/current/unlock", + "RelyingParty": "http://unlock.device.mgt.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 1 + }, + { + "Protocol": "https", + "Host": "xkms.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://xkms.xboxlive.com", + "TokenType": "JWT", + "MinTlsVersion": "1.2", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "privileges.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://banning.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 1 + }, + { + "Protocol": "https", + "Host": "privileges.xboxlive.com", + "HostType": "fqdn", + "Path": "/upsell", + "RelyingParty": "http://xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 1 + }, + { + "Protocol": "https", + "Host": "attestation.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://attestation.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 1 + }, + { + "Protocol": "https", + "Host": "settings.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://xboxlive.com", + "TokenType": "JWT", + "ServerCertIndex": [ + 0, + 1 + ], + "SignaturePolicyIndex": 1 + }, + { + "Protocol": "https", + "Host": "*.experimentation.xboxlive.com", + "HostType": "wildcard", + "RelyingParty": "http://experimentation.xboxlive.com/", + "TokenType": "JWT", + "SignaturePolicyIndex": 1 + }, + { + "Protocol": "https", + "Host": "*.xboxlive.com", + "HostType": "wildcard", + "RelyingParty": "http://xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "http", + "Host": "*.xboxlive.com", + "HostType": "wildcard" + }, + { + "Protocol": "https", + "Host": "xaaa.bbtv.cn", + "HostType": "fqdn", + "Path": "/xboxsms/OOBEService/AuthorizationStatus", + "RelyingParty": "http://bestvrp.bestv.com/", + "SubRelyingParty": "http://www.bestv.com.cn/", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "*.data.microsoft.com", + "HostType": "wildcard", + "RelyingParty": "http://vortex.microsoft.com", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "*.vortex-win-sandbox.data.microsoft.com", + "HostType": "wildcard", + "RelyingParty": "http://vortex-sbx.microsoft.com", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "*.vortex-sandbox.data.microsoft.com", + "HostType": "wildcard", + "RelyingParty": "http://vortex-sbx.microsoft.com", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "vortex-events.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://events.xboxlive.com", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "*.pipe.int.trafficmanager.net", + "HostType": "wildcard", + "RelyingParty": "http://vortex-sbx.microsoft.com", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "*.events-sandbox.data.microsoft.com", + "HostType": "wildcard", + "RelyingParty": "http://vortex-sbx.microsoft.com", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "musicimage.xboxlive.com", + "HostType": "fqdn" + }, + { + "Protocol": "https", + "Host": "*.xboxservices.com", + "HostType": "wildcard", + "RelyingParty": "http://mp.microsoft.com/", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "assets.xboxservices.com", + "HostType": "fqdn" + }, + { + "Protocol": "https", + "Host": "*.mp.microsoft.com", + "HostType": "wildcard", + "RelyingParty": "http://mp.microsoft.com/", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "account.microsoft.com", + "HostType": "fqdn", + "RelyingParty": "http://mp.microsoft.com/", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "*.account.microsoft.com", + "HostType": "wildcard", + "RelyingParty": "http://mp.microsoft.com/", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "controls.cp.microsoft.com", + "HostType": "fqdn", + "RelyingParty": "http://mp.microsoft.com/", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "controls.platform.account.www.microsoft.com", + "HostType": "fqdn", + "RelyingParty": "http://mp.microsoft.com/", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "licensing.mp.microsoft.com", + "HostType": "fqdn", + "RelyingParty": "http://licensing.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "collections.mp.microsoft.com", + "HostType": "fqdn", + "RelyingParty": "http://licensing.xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "api.twitch.tv", + "HostType": "fqdn", + "RelyingParty": "https://twitchxboxrp.twitch.tv/", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "xdes.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://xdes.xboxlive.com/", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "skypexbox.skype.com", + "HostType": "fqdn", + "RelyingParty": "http://xboxliverp.skype.com/", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "*.gameservices.xboxlive.com", + "HostType": "wildcard", + "RelyingParty": "https://gameservices.xboxlive.com/", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "ssl.bing.com", + "HostType": "fqdn", + "Path": "/speechreco/xbox/accessibility", + "RelyingParty": "http://platform.bing.com/", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "speech.bing.com", + "HostType": "fqdn", + "RelyingParty": "http://platform.bing.com/", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "beam.pro", + "HostType": "fqdn", + "RelyingParty": "http://beam.pro/", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "*.beam.pro", + "HostType": "wildcard", + "RelyingParty": "http://beam.pro/", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "mixer.com", + "HostType": "fqdn", + "RelyingParty": "http://beam.pro/", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "*.mixer.com", + "HostType": "wildcard", + "RelyingParty": "http://beam.pro/", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "zto.dds.microsoft.com", + "HostType": "fqdn", + "RelyingParty": "http://dds.microsoft.com", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "user.mgt.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://accounts.xboxlive.com", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "gssv-auth-prod.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://gssv.xboxlive.com/", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "gssv-auth-strs.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://gssv.xboxlive.com/", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "settings-sandbox.data.microsoft.com", + "HostType": "fqdn", + "RelyingParty": "http://onesettings-xbox-rp.com/", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "settings-win.data.microsoft.com", + "HostType": "fqdn", + "RelyingParty": "http://onesettings-xbox-rp.com/", + "TokenType": "JWT", + "ServerCertIndex": [ + 2 + ] + }, + { + "Protocol": "https", + "Host": "settings-ppe.data.microsoft.com", + "HostType": "fqdn", + "RelyingParty": "http://onesettings-xbox-rp.com/", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "settings.data.microsoft.com", + "HostType": "fqdn", + "RelyingParty": "http://onesettings-xbox-rp.com/", + "TokenType": "JWT", + "ServerCertIndex": [ + 3, + 0 + ] + }, + { + "Protocol": "https", + "Host": "playfabapi.com", + "HostType": "fqdn", + "RelyingParty": "http://playfab.xboxlive.com/", + "TokenType": "JWT" + }, + { + "Protocol": "https", + "Host": "sisu.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://sisu.xboxlive.com/", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "beta-sisu.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://sisu.xboxlive.com/", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "*.gamepass.com", + "HostType": "wildcard", + "RelyingParty": "http://xboxlive.com", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "downloadnotifications.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://download-notification.gamepass.com/", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "xflight.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "http://xflight.xboxlive.com/", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "xrap.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "rp://rap.xboxflight.com/", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "streaming.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "rp://streaming.xboxlive.com/", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "*.support.xboxlive.com", + "HostType": "wildcard", + "RelyingParty": "rp://support.xboxlive.com/", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "streaming-ppe.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "rp://streaming.xboxlive.com/", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + }, + { + "Protocol": "https", + "Host": "xoobe.xboxlive.com", + "HostType": "fqdn", + "RelyingParty": "rp://xoobe.xboxlive.com/", + "TokenType": "JWT", + "SignaturePolicyIndex": 0 + } + ], + "SignaturePolicies": [ + { + "Version": 1, + "SupportedAlgorithms": [ + "ES256" + ], + "MaxBodyBytes": 8192 + }, + { + "Version": 1, + "SupportedAlgorithms": [ + "ES256" + ], + "MaxBodyBytes": 4294967295 + } + ], + "Certs": [ + { + "Thumbprint": "54D9D20239080C32316ED9FF980A48988F4ADF2D", + "IsIssuer": true, + "RootCertIndex": 0 + }, + { + "Thumbprint": "D5A9ACDB80066D0E67FF65A939BBBC952F8ED171", + "RootCertIndex": 0 + }, + { + "Thumbprint": "8F43288AD272F3103B6FB1428485EA3014C0BCFE", + "IsIssuer": true, + "RootCertIndex": 1 + }, + { + "Thumbprint": "AD898AC73DF333EB60AC1F5FC6C4B2219DDB79B7", + "IsIssuer": true, + "RootCertIndex": 0 + } + ], + "RootCerts": [ + "MIIDdzCCAl+gAwIBAgIEAgAAuTANBgkqhkiG9w0BAQUFADBaMQswCQYDVQQGEwJJRTESMBAGA1UEChMJQmFsdGltb3JlMRMwEQYDVQQLEwpDeWJlclRydXN0MSIwIAYDVQQDExlCYWx0aW1vcmUgQ3liZXJUcnVzdCBSb290MB4XDTAwMDUxMjE4NDYwMFoXDTI1MDUxMjIzNTkwMFowWjELMAkGA1UEBhMCSUUxEjAQBgNVBAoTCUJhbHRpbW9yZTETMBEGA1UECxMKQ3liZXJUcnVzdDEiMCAGA1UEAxMZQmFsdGltb3JlIEN5YmVyVHJ1c3QgUm9vdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKMEuyKrmD1X6CZymrV51Cni4eiVgLGw41uOKymaZN+hXe2wCQVt2yguzmKiYv60iNoS6zjrIZ3AQSsBUnuId9Mcj8e6uYi1agnnc+gRQKfRzMpijS3ljwumUNKoUMMo6vWrJYeKmpYcqWe4PwzV9/lSEy/CG9VwcPCPwBLKBsua4dnKM3p31vjsufFoREJIE9LAwqSuXmD+tqYF/LTdB1kC1FkYmGP1pWPgkAx9XbIGevOF6uvUA65ehD5f/xXtabz5OTZydc93Uk3zyZAsuT3lySNTPx8kmCFcB5kpvcY67Oduhjprl3RjM71oGDHweI12v/yejl0qhqdNkNwnGjkCAwEAAaNFMEMwHQYDVR0OBBYEFOWdWTCCR1jMrPoIVDaGezq1BE3wMBIGA1UdEwEB/wQIMAYBAf8CAQMwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBBQUAA4IBAQCFDF2O5G9RaEIFoN27TyclhAO992T9Ldcw46QQF+vaKSm2eT929hkTI7gQCvlYpNRhcL0EYWoSihfVCr3FvDB81ukMJY2GQE/szKN+OMY3EU/t3WgxjkzSswF07r51XgdIGn9w/xZchMB5hbgF/X++ZRGjD8ACtPhSNzkE1akxehi/oCr0Epn3o0WC4zxe9Z2etciefC7IpJ5OCBRLbf1wbWsaY71k5h+3zvDyny67G7fyUIhzksLi4xaNmjICq44Y3ekQEe5+NauQrz4wlHrQMz2nZQ/1/I6eYs9HRCwBXbsdtTLSR9I4LtD+gdwyah617jzV/OeBHRnDJELqYzmp", + "MIIF7TCCA9WgAwIBAgIQP4vItfyfspZDtWnWbELhRDANBgkqhkiG9w0BAQsFADCBiDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEyMDAGA1UEAxMpTWljcm9zb2Z0IFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTEwHhcNMTEwMzIyMjIwNTI4WhcNMzYwMzIyMjIxMzA0WjCBiDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEyMDAGA1UEAxMpTWljcm9zb2Z0IFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCygEGqNThNE3IyaCJNuLLx/9VSvGzH9dJKjDbu0cJcfoyKrq8TKG/Ac+M6ztAlqFo6be+ouFmrEyNozQwph9FvgFyPRH9dkAFSWKxRxV8qh9zc2AodwQO5e7BW6KPeZGHCnvjzfLnsDbVU/ky2ZU+I8JxImQxCCwl8MVkXeQZ4KI2JOkwDJb5xalwL54RgpJki49KvhKSn+9GY7Qyp3pSJ4Q6g3MDOmT3qCFK7VnnkH4S6Hri0xElcTzFLh93dBWcmmYDgcRGjuKVB4qRTufcyKYMME782XgSzS0NHL2vikR7TmE/dQgfI6B0S/Jmpaz6SfsjWaTr8ZL22CZ3K/QwLopt3YEsDlKQwaRLWQi3BQUzK3Kr9j1uDRprZ/LHR47PJf0h6zSTwQY9cdNCssBAgBkm3xy0hyFfj0IbzA2j70M5xwYmZSmQBbP3sMJHPQTySx+W6hh1hhMdfgzlirrSSL0fzC/hV66AfWdC7dJse0Hbm8ukG1xDo+mTeacY1logC8Ea4PyeZb8txiSk190gWAjWP1Xl8TQLPX+uKg09FcYj5qQ1OcunCnAfPSRtOBA5jUYxe2ADBVSy2xuDCZU7JNDn1nLPEfuhhbhNfFcRf2X7tHc7uROzLLoax7Dj2cO2rXBPB2Q8Nx4CyVe0096yb5MPa50c8prWPMd/FS6/r8QIDAQABo1EwTzALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUci06AjGQQ7kUBU7h6qfHMdEjiTQwEAYJKwYBBAGCNxUBBAMCAQAwDQYJKoZIhvcNAQELBQADggIBAH9yzw+3xRXbm8BJyiZb/p4T5tPw0tuXX/JLP02zrhmu7deXoKzvqTqjwkGw5biRnhOBJAPmCf0/V0A5ISRW0RAvS0CpNoZLtFNXmvvxfomPEf4YbFGq6O0JlbXlccmh6Yd1phV/yX43VF50k8XDZ8wNT2uoFwxtCJJ+i92Bqi1wIcM9BhS7vyRep4TXPw8hIr1LAAbblxzYXtTFC1yHblCk6MM4pPvLLMWSZpuFXst6bJN8gClYW1e1QGm6CHmmZGIVnYeWRbVmIyADixxzoNOieTPgUFmG2y/lAiXqcyqfABTINseSO+lOAOzYVgm5M0kS0lQLAausR7aRKX1MtHWAUgHoyoL2n8ysnI8X6i8msKtyrAv+nlEex0NVZ09Rs1fWtuzuUrc66U7h14GIvE+OdbtLqPA1qibUZ2dJsnBMO5PcHd94kIZysjik0dySTclY6ysSXNQ7roxrsIPlAT/4CTL2kzU0Iq/dNw13CYArzUgA8YyZGUcFAenRv9FO0OYoQzeZpApKCNmacXPSqs0xE2N2oTdvkjgefRI8ZjLny23h/FKJ3crWZgWalmG+oijHHKOnNlA8OqTfSm7mhzvO6/DggTedEzxSjr25HTTGHdUKaj2YKXCMiSrRq4IQSB/c9O+lxbtVGjhjhE63bK2VVOxlIhBJF7jAHscPrFRH" + ] +} \ No newline at end of file From 9479610840f2b25036a26fcd4d033cddfd672e13 Mon Sep 17 00:00:00 2001 From: tuxuser <462620+tuxuser@users.noreply.github.com> Date: Tue, 19 Dec 2023 11:01:05 +0100 Subject: [PATCH 08/13] Update README, adjust MSRV in CI --- .github/workflows/test.yml | 3 +-- README.md | 12 +++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6b9dd96..dd43de1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - rust: [beta, stable, 1.56.0] + rust: [beta, stable, 1.70.0] steps: - uses: actions/checkout@v3 @@ -31,7 +31,6 @@ jobs: clippy: name: Clippy runs-on: ubuntu-latest - if: github.event_name != 'pull_request' steps: - uses: actions/checkout@v3 - uses: dtolnay/rust-toolchain@nightly diff --git a/README.md b/README.md index dc11e52..87f58c3 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,14 @@ Authenticate with Xbox Live -## Quickstart +## Documentation -```rs -todo!("") -``` +Find the documentation here: +## Minimum supported Rust version -Documentation: +This crate requires at least Rust 1.70 (stable). +## Disclaimer + +This is an unofficial library not endorsed by Microsoft. Use at your own risk! \ No newline at end of file From b999b319e8b7a847849630d753cc4644d0c312dd Mon Sep 17 00:00:00 2001 From: tuxuser <462620+tuxuser@users.noreply.github.com> Date: Tue, 19 Dec 2023 11:05:56 +0100 Subject: [PATCH 09/13] Create LICENSE --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b9a7a8a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Team OpenXbox + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 519c3ad627e23ead40e7e645414e90b4be172889 Mon Sep 17 00:00:00 2001 From: tuxuser <462620+tuxuser@users.noreply.github.com> Date: Tue, 19 Dec 2023 11:22:58 +0100 Subject: [PATCH 10/13] Flatten more structs --- examples/src/bin/auth_azure.rs | 2 +- examples/src/bin/auth_cli.rs | 4 +- examples/src/bin/auth_minecraft.rs | 4 +- examples/src/bin/auth_webview.rs | 2 +- examples/src/lib.rs | 24 +- src/flows.rs | 1013 ++++++++++++++-------------- src/lib.rs | 6 +- src/request_signer.rs | 2 + 8 files changed, 532 insertions(+), 525 deletions(-) diff --git a/examples/src/bin/auth_azure.rs b/examples/src/bin/auth_azure.rs index aaae60a..3484b0a 100644 --- a/examples/src/bin/auth_azure.rs +++ b/examples/src/bin/auth_azure.rs @@ -7,7 +7,7 @@ use tokio::{ }; use xal::{ client_params::CLIENT_ANDROID, - flows::{AuthPromptCallback, AuthPromptData}, + AuthPromptCallback, AuthPromptData, oauth2::{RedirectUrl, Scope}, url::Url, Error, XalAppParameters, diff --git a/examples/src/bin/auth_cli.rs b/examples/src/bin/auth_cli.rs index c5d4387..4e43566 100644 --- a/examples/src/bin/auth_cli.rs +++ b/examples/src/bin/auth_cli.rs @@ -1,9 +1,9 @@ -use xal::{flows, AccessTokenPrefix, Error}; +use xal::{CliCallbackHandler, AccessTokenPrefix, Error}; use xal_examples::auth_main_default; #[tokio::main] async fn main() -> Result<(), Error> { - auth_main_default(AccessTokenPrefix::None, flows::CliCallbackHandler) + auth_main_default(AccessTokenPrefix::None, CliCallbackHandler) .await .ok(); diff --git a/examples/src/bin/auth_minecraft.rs b/examples/src/bin/auth_minecraft.rs index afcd57a..ce98195 100644 --- a/examples/src/bin/auth_minecraft.rs +++ b/examples/src/bin/auth_minecraft.rs @@ -1,6 +1,6 @@ use serde_json::json; use xal::{ - extensions::JsonExDeserializeMiddleware, flows, oauth2::TokenResponse, AccessTokenPrefix, + extensions::JsonExDeserializeMiddleware, CliCallbackHandler, oauth2::TokenResponse, AccessTokenPrefix, Error, XalAuthenticator, }; use xal_examples::auth_main; @@ -15,7 +15,7 @@ async fn main() -> Result<(), Error> { client_params, "RETAIL".into(), AccessTokenPrefix::None, - flows::CliCallbackHandler, + CliCallbackHandler, ) .await?; diff --git a/examples/src/bin/auth_webview.rs b/examples/src/bin/auth_webview.rs index e82ea45..b2a4503 100644 --- a/examples/src/bin/auth_webview.rs +++ b/examples/src/bin/auth_webview.rs @@ -14,7 +14,7 @@ use wry::{ webview::WebViewBuilder, }; use xal::{ - flows::{AuthPromptCallback, AuthPromptData}, + AuthPromptCallback, AuthPromptData, url::Url, AccessTokenPrefix, Error, XalAuthenticator, }; diff --git a/examples/src/lib.rs b/examples/src/lib.rs index 31374a8..3935d46 100644 --- a/examples/src/lib.rs +++ b/examples/src/lib.rs @@ -2,8 +2,8 @@ use clap::{Parser, ValueEnum}; use env_logger::Env; use log::info; use xal::{ - flows, tokenstore::TokenStore, AccessTokenPrefix, Constants, Error, XalAppParameters, - XalAuthenticator, XalClientParameters, + Flows, TokenStore, AccessTokenPrefix, Constants, Error, XalAppParameters, + XalAuthenticator, XalClientParameters, AuthPromptCallback }; /// Common cli arguments @@ -57,7 +57,7 @@ pub enum AuthFlow { pub async fn auth_main_default( access_token_prefix: AccessTokenPrefix, - auth_cb: impl flows::AuthPromptCallback, + auth_cb: impl AuthPromptCallback, ) -> Result { auth_main( XalAppParameters::default(), @@ -75,22 +75,22 @@ pub async fn auth_main( client_params: XalClientParameters, sandbox_id: String, access_token_prefix: AccessTokenPrefix, - auth_cb: impl flows::AuthPromptCallback, + auth_cb: impl AuthPromptCallback, ) -> Result { let args = handle_args(); - let mut ts = match flows::try_refresh_live_tokens_from_file(&args.token_filepath).await { + let mut ts = match Flows::try_refresh_live_tokens_from_file(&args.token_filepath).await { Ok((mut authenticator, ts)) => { info!("Tokens refreshed succesfully, proceeding with Xbox Live Authorization"); match args.flow { AuthFlow::Sisu => { info!("Authorize and gather rest of xbox live tokens via sisu"); - flows::xbox_live_sisu_authorization_flow(&mut authenticator, ts.live_token) + Flows::xbox_live_sisu_authorization_flow(&mut authenticator, ts.live_token) .await? } _ => { info!("Authorize Xbox Live the traditional way, via individual requests"); - flows::xbox_live_authorization_traditional_flow( + Flows::xbox_live_authorization_traditional_flow( &mut authenticator, ts.live_token, Constants::RELYING_PARTY_XBOXLIVE.into(), @@ -108,17 +108,17 @@ pub async fn auth_main( info!("Authentication via flow={:?}", args.flow); let ts = match args.flow { AuthFlow::Sisu => { - flows::xbox_live_sisu_full_flow(&mut authenticator, auth_cb).await? + Flows::xbox_live_sisu_full_flow(&mut authenticator, auth_cb).await? } AuthFlow::DeviceCode => { - flows::ms_device_code_flow(&mut authenticator, auth_cb, tokio::time::sleep) + Flows::ms_device_code_flow(&mut authenticator, auth_cb, tokio::time::sleep) .await? } AuthFlow::Implicit => { - flows::ms_authorization_flow(&mut authenticator, auth_cb, true).await? + Flows::ms_authorization_flow(&mut authenticator, auth_cb, true).await? } AuthFlow::AuthorizationCode => { - flows::ms_authorization_flow(&mut authenticator, auth_cb, false).await? + Flows::ms_authorization_flow(&mut authenticator, auth_cb, false).await? } }; @@ -128,7 +128,7 @@ pub async fn auth_main( info!("Continuing flow via traditional Xbox Live authorization"); // Only required for non-sisu authentication, as // sisu already gathers all the tokens at once - flows::xbox_live_authorization_traditional_flow( + Flows::xbox_live_authorization_traditional_flow( &mut authenticator, ts.live_token, Constants::RELYING_PARTY_XBOXLIVE.into(), diff --git a/src/flows.rs b/src/flows.rs index 68537c1..dc85368 100644 --- a/src/flows.rs +++ b/src/flows.rs @@ -123,7 +123,7 @@ impl AuthPromptData { /// Sisu Auth callback trait /// -/// Used as an argument to [`crate::flows::xbox_live_sisu_full_flow`] +/// Used as an argument to [`crate::Flows::xbox_live_sisu_full_flow`] /// /// /// # Examples @@ -131,8 +131,7 @@ impl AuthPromptData { /// ``` /// # use std::io; /// # use async_trait::async_trait; -/// # use xal::XalAuthenticator; -/// # use xal::flows::{AuthPromptCallback, AuthPromptData}; +/// # use xal::{XalAuthenticator, AuthPromptCallback, AuthPromptData}; /// # use xal::url::Url; /// // Define callback handler for OAuth2 flow /// struct CallbackHandler; @@ -212,516 +211,520 @@ impl AuthPromptCallback for CliCallbackHandler { } } -/// Try to deserialize a JSON TokenStore from filepath and refresh the Windows Live tokens if needed. -/// -/// # Errors -/// -/// This function may return an error if the file cannot be read, fails to deserialize or the -/// tokens cannot be refreshed. -/// -/// # Examples -/// -/// ``` -/// # use xal::{Error, tokenstore::TokenStore}; -/// use xal::flows; -/// -/// # async fn demo_code() -> Result<(), Error> { -/// // Refresh Windows Live tokens first -/// let (mut authenticator, token_store) = flows::try_refresh_live_tokens_from_file("tokens.json") -/// .await?; -/// -/// // Continue by requesting xbox live tokens -/// let token_store = flows::xbox_live_sisu_authorization_flow( -/// &mut authenticator, -/// token_store.live_token -/// ) -/// .await?; -/// -/// # Ok(()) -/// # } -/// ``` -/// -/// # Returns -/// -/// If successful, a tuple of [`crate::XalAuthenticator`] and [`crate::tokenstore::TokenStore`] -/// is returned. TokenStore will contain the refreshed `live_tokens`. -pub async fn try_refresh_live_tokens_from_file( - filepath: &str, -) -> Result<(XalAuthenticator, TokenStore), Error> { - let mut ts = TokenStore::load_from_file(filepath)?; - let authenticator = try_refresh_live_tokens_from_tokenstore(&mut ts).await?; - Ok((authenticator, ts)) -} +/// Higher-level, bundled functionality for common authentication tasks +pub struct Flows; -/// Try to read tokens from the token store and refresh the Windows Live tokens if needed. -/// -/// # Examples -/// -/// ``` -/// use std::fs::File; -/// use serde_json; -/// use xal::{flows, tokenstore::TokenStore}; -/// -/// # async fn demo_code() -> Result<(), xal::Error> { -/// let mut file = File::open("tokens.json") -/// .expect("Failed to open tokenfile"); -/// let mut ts: TokenStore = serde_json::from_reader(&mut file) -/// .expect("Failed to deserialize TokenStore"); -/// -/// let authenticator = flows::try_refresh_live_tokens_from_tokenstore(&mut ts) -/// .await -/// .expect("Failed refreshing Windows Live tokens"); -/// # Ok(()) -/// # } -/// ``` -/// -/// # Errors -/// -/// This function may return an error if the token store cannot be read or the tokens cannot be refreshed. -/// -/// # Returns -/// -/// If successful, a tuple of [`crate::XalAuthenticator`] and [`crate::tokenstore::TokenStore`] -/// is returned. TokenStore will contain the refreshed `live_tokens`. -pub async fn try_refresh_live_tokens_from_tokenstore( - ts: &mut TokenStore, -) -> Result { - let mut authenticator = Into::::into(ts.clone()); - - info!("Refreshing windows live tokens"); - let refreshed_wl_tokens = authenticator - .refresh_token(ts.live_token.refresh_token().unwrap()) - .await - .expect("Failed to exchange refresh token for fresh WL tokens"); - - debug!("Windows Live tokens: {:?}", refreshed_wl_tokens); - ts.live_token = refreshed_wl_tokens.clone(); - - Ok(authenticator) -} +impl Flows { + /// Try to deserialize a JSON TokenStore from filepath and refresh the Windows Live tokens if needed. + /// + /// # Errors + /// + /// This function may return an error if the file cannot be read, fails to deserialize or the + /// tokens cannot be refreshed. + /// + /// # Examples + /// + /// ``` + /// # use xal::{Error, Flows, TokenStore}; + /// + /// # async fn demo_code() -> Result<(), Error> { + /// // Refresh Windows Live tokens first + /// let (mut authenticator, token_store) = Flows::try_refresh_live_tokens_from_file("tokens.json") + /// .await?; + /// + /// // Continue by requesting xbox live tokens + /// let token_store = Flows::xbox_live_sisu_authorization_flow( + /// &mut authenticator, + /// token_store.live_token + /// ) + /// .await?; + /// + /// # Ok(()) + /// # } + /// ``` + /// + /// # Returns + /// + /// If successful, a tuple of [`crate::XalAuthenticator`] and [`crate::tokenstore::TokenStore`] + /// is returned. TokenStore will contain the refreshed `live_tokens`. + pub async fn try_refresh_live_tokens_from_file( + filepath: &str, + ) -> Result<(XalAuthenticator, TokenStore), Error> { + let mut ts = TokenStore::load_from_file(filepath)?; + let authenticator = Self::try_refresh_live_tokens_from_tokenstore(&mut ts).await?; + Ok((authenticator, ts)) + } -/// Shorthand for Windows Live device code flow -/// -/// # Examples -/// -/// ``` -/// use xal::{XalAuthenticator, flows, Error, AccessTokenPrefix}; -/// use xal::response::WindowsLiveTokens; -/// -/// # async fn async_sleep_fn(_: std::time::Duration) {} -/// -/// # async fn example() -> Result<(), Error> { -/// let do_implicit_flow = true; -/// let mut authenticator = XalAuthenticator::default(); -/// -/// let token_store = flows::ms_device_code_flow( -/// &mut authenticator, -/// flows::CliCallbackHandler, -/// async_sleep_fn -/// ) -/// .await?; -/// -/// // TokenStore will only contain live tokens -/// assert!(token_store.user_token.is_none()); -/// assert!(token_store.title_token.is_none()); -/// assert!(token_store.device_token.is_none()); -/// assert!(token_store.authorization_token.is_none()); -/// # Ok(()) -/// # } -/// ``` -pub async fn ms_device_code_flow( - authenticator: &mut XalAuthenticator, - cb: impl AuthPromptCallback, - sleep_fn: S, -) -> Result -where - S: Fn(std::time::Duration) -> SF, - SF: std::future::Future, -{ - trace!("Initiating device code flow"); - let device_code_flow = authenticator.initiate_device_code_auth().await?; - debug!("Device code={:?}", device_code_flow); - - trace!("Reaching into callback to notify caller about device code url"); - cb.call(device_code_flow.clone().into()) - .await - .map_err(|e| Error::GeneralError(format!("Failed getting redirect URL err={e}")))?; - - trace!("Polling for device code"); - let live_tokens = authenticator - .poll_device_code_auth(&device_code_flow, sleep_fn) - .await?; - - let ts = TokenStore { - app_params: authenticator.app_params(), - client_params: authenticator.client_params(), - sandbox_id: authenticator.sandbox_id(), - live_token: live_tokens, - user_token: None, - title_token: None, - device_token: None, - authorization_token: None, - updated: None, - }; - - Ok(ts) -} + /// Try to read tokens from the token store and refresh the Windows Live tokens if needed. + /// + /// # Examples + /// + /// ``` + /// use std::fs::File; + /// use serde_json; + /// use xal::{Flows, TokenStore}; + /// + /// # async fn demo_code() -> Result<(), xal::Error> { + /// let mut file = File::open("tokens.json") + /// .expect("Failed to open tokenfile"); + /// let mut ts: TokenStore = serde_json::from_reader(&mut file) + /// .expect("Failed to deserialize TokenStore"); + /// + /// let authenticator = Flows::try_refresh_live_tokens_from_tokenstore(&mut ts) + /// .await + /// .expect("Failed refreshing Windows Live tokens"); + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// This function may return an error if the token store cannot be read or the tokens cannot be refreshed. + /// + /// # Returns + /// + /// If successful, a tuple of [`crate::XalAuthenticator`] and [`crate::TokenStore`] + /// is returned. TokenStore will contain the refreshed `live_tokens`. + pub async fn try_refresh_live_tokens_from_tokenstore( + ts: &mut TokenStore, + ) -> Result { + let mut authenticator = Into::::into(ts.clone()); + + info!("Refreshing windows live tokens"); + let refreshed_wl_tokens = authenticator + .refresh_token(ts.live_token.refresh_token().unwrap()) + .await + .expect("Failed to exchange refresh token for fresh WL tokens"); + + debug!("Windows Live tokens: {:?}", refreshed_wl_tokens); + ts.live_token = refreshed_wl_tokens.clone(); + + Ok(authenticator) + } -/// Shorthand for Windows Live authorization flow -/// - Depending on the argument `implicit` the -/// methods `implicit grant` or `authorization code` are chosen -/// -/// # Examples -/// -/// ``` -/// use xal::{XalAuthenticator, flows, Error, AccessTokenPrefix}; -/// use xal::response::WindowsLiveTokens; -/// -/// # async fn example() -> Result<(), Error> { -/// let do_implicit_flow = true; -/// let mut authenticator = XalAuthenticator::default(); -/// -/// let token_store = flows::ms_authorization_flow( -/// &mut authenticator, -/// flows::CliCallbackHandler, -/// do_implicit_flow, -/// ) -/// .await?; -/// -/// // TokenStore will only contain live tokens -/// assert!(token_store.user_token.is_none()); -/// assert!(token_store.title_token.is_none()); -/// assert!(token_store.device_token.is_none()); -/// assert!(token_store.authorization_token.is_none()); -/// # Ok(()) -/// # } -/// ``` -pub async fn ms_authorization_flow( - authenticator: &mut XalAuthenticator, - cb: impl AuthPromptCallback, - implicit: bool, -) -> Result { - trace!("Starting implicit authorization flow"); - - let (url, state) = authenticator.get_authorization_url(implicit)?; - - trace!("Reaching into callback to receive authentication redirect URL"); - let redirect_url = cb - .call(url.into()) - .await - .map_err(|e| Error::GeneralError(format!("Failed getting redirect URL err={e}")))? - .ok_or(Error::GeneralError( - "Failed receiving redirect URL".to_string(), - ))?; - - debug!("From callback: Redirect URL={:?}", redirect_url); - - let live_tokens = if implicit { - trace!("Parsing (implicit grant) redirect URI"); - XalAuthenticator::parse_implicit_grant_url(&redirect_url, Some(&state))? - } else { - trace!("Parsing (authorization code) redirect URI"); + /// Shorthand for Windows Live device code flow + /// + /// # Examples + /// + /// ``` + /// use xal::{XalAuthenticator, Flows, Error, AccessTokenPrefix, CliCallbackHandler}; + /// use xal::response::WindowsLiveTokens; + /// + /// # async fn async_sleep_fn(_: std::time::Duration) {} + /// + /// # async fn example() -> Result<(), Error> { + /// let do_implicit_flow = true; + /// let mut authenticator = XalAuthenticator::default(); + /// + /// let token_store = Flows::ms_device_code_flow( + /// &mut authenticator, + /// CliCallbackHandler, + /// async_sleep_fn + /// ) + /// .await?; + /// + /// // TokenStore will only contain live tokens + /// assert!(token_store.user_token.is_none()); + /// assert!(token_store.title_token.is_none()); + /// assert!(token_store.device_token.is_none()); + /// assert!(token_store.authorization_token.is_none()); + /// # Ok(()) + /// # } + /// ``` + pub async fn ms_device_code_flow( + authenticator: &mut XalAuthenticator, + cb: impl AuthPromptCallback, + sleep_fn: S, + ) -> Result + where + S: Fn(std::time::Duration) -> SF, + SF: std::future::Future, + { + trace!("Initiating device code flow"); + let device_code_flow = authenticator.initiate_device_code_auth().await?; + debug!("Device code={:?}", device_code_flow); + + trace!("Reaching into callback to notify caller about device code url"); + cb.call(device_code_flow.clone().into()) + .await + .map_err(|e| Error::GeneralError(format!("Failed getting redirect URL err={e}")))?; + + trace!("Polling for device code"); + let live_tokens = authenticator + .poll_device_code_auth(&device_code_flow, sleep_fn) + .await?; + + let ts = TokenStore { + app_params: authenticator.app_params(), + client_params: authenticator.client_params(), + sandbox_id: authenticator.sandbox_id(), + live_token: live_tokens, + user_token: None, + title_token: None, + device_token: None, + authorization_token: None, + updated: None, + }; + + Ok(ts) + } + + /// Shorthand for Windows Live authorization flow + /// - Depending on the argument `implicit` the + /// methods `implicit grant` or `authorization code` are chosen + /// + /// # Examples + /// + /// ``` + /// use xal::{XalAuthenticator, Flows, Error, AccessTokenPrefix, CliCallbackHandler}; + /// use xal::response::WindowsLiveTokens; + /// + /// # async fn example() -> Result<(), Error> { + /// let do_implicit_flow = true; + /// let mut authenticator = XalAuthenticator::default(); + /// + /// let token_store = Flows::ms_authorization_flow( + /// &mut authenticator, + /// CliCallbackHandler, + /// do_implicit_flow, + /// ) + /// .await?; + /// + /// // TokenStore will only contain live tokens + /// assert!(token_store.user_token.is_none()); + /// assert!(token_store.title_token.is_none()); + /// assert!(token_store.device_token.is_none()); + /// assert!(token_store.authorization_token.is_none()); + /// # Ok(()) + /// # } + /// ``` + pub async fn ms_authorization_flow( + authenticator: &mut XalAuthenticator, + cb: impl AuthPromptCallback, + implicit: bool, + ) -> Result { + trace!("Starting implicit authorization flow"); + + let (url, state) = authenticator.get_authorization_url(implicit)?; + + trace!("Reaching into callback to receive authentication redirect URL"); + let redirect_url = cb + .call(url.into()) + .await + .map_err(|e| Error::GeneralError(format!("Failed getting redirect URL err={e}")))? + .ok_or(Error::GeneralError( + "Failed receiving redirect URL".to_string(), + ))?; + + debug!("From callback: Redirect URL={:?}", redirect_url); + + let live_tokens = if implicit { + trace!("Parsing (implicit grant) redirect URI"); + XalAuthenticator::parse_implicit_grant_url(&redirect_url, Some(&state))? + } else { + trace!("Parsing (authorization code) redirect URI"); + let authorization_code = + XalAuthenticator::parse_authorization_code_response(&redirect_url, Some(&state))?; + debug!("Authorization Code: {:?}", &authorization_code); + + trace!("Getting Windows Live tokens (exchange code)"); + authenticator + .exchange_code_for_token(authorization_code, None) + .await? + }; + + let ts = TokenStore { + app_params: authenticator.app_params(), + client_params: authenticator.client_params(), + sandbox_id: authenticator.sandbox_id(), + live_token: live_tokens, + user_token: None, + title_token: None, + device_token: None, + authorization_token: None, + updated: None, + }; + + Ok(ts) + } + + /// Shorthand for sisu authentication flow + /// + /// # Examples + /// + /// ``` + /// use xal::{XalAuthenticator, Flows, Error, AccessTokenPrefix, CliCallbackHandler}; + /// use xal::response::WindowsLiveTokens; + /// + /// # async fn example() -> Result<(), Error> { + /// let mut authenticator = XalAuthenticator::default(); + /// + /// let token_store = Flows::xbox_live_sisu_full_flow( + /// &mut authenticator, + /// CliCallbackHandler, + /// ) + /// .await?; + /// + /// // TokenStore will contain user/title/device/xsts tokens + /// assert!(token_store.user_token.is_some()); + /// assert!(token_store.title_token.is_some()); + /// assert!(token_store.device_token.is_some()); + /// assert!(token_store.authorization_token.is_some()); + /// # Ok(()) + /// # } + /// ``` + pub async fn xbox_live_sisu_full_flow( + authenticator: &mut XalAuthenticator, + callback: impl AuthPromptCallback, + ) -> Result { + trace!("Getting device token"); + let device_token = authenticator.get_device_token().await?; + debug!("Device token={:?}", device_token); + let (code_challenge, code_verifier) = XalAuthenticator::generate_code_verifier(); + trace!("Generated Code verifier={:?}", code_verifier); + trace!("Generated Code challenge={:?}", code_challenge); + let state = XalAuthenticator::generate_random_state(); + trace!("Generated random state={:?}", state); + + trace!("Fetching SISU authentication URL and Session Id"); + let (auth_resp, session_id) = authenticator + .sisu_authenticate(&device_token, &code_challenge, &state) + .await?; + debug!( + "SISU Authenticate response={:?} Session Id={:?}", + auth_resp, session_id + ); + + // Passing redirect URL to callback and expecting redirect url + authorization token back + trace!("Reaching into callback to receive authentication redirect URL"); + let redirect_url = callback + .call(auth_resp.into()) + .await + .map_err(|e| Error::GeneralError(format!("Failed getting redirect URL err={e}")))? + .ok_or(Error::GeneralError( + "Did not receive any Redirect URL from RedirectUrl callback".to_string(), + ))?; + + debug!("From callback: Redirect URL={:?}", redirect_url); + + trace!("Parsing redirect URI"); let authorization_code = XalAuthenticator::parse_authorization_code_response(&redirect_url, Some(&state))?; - debug!("Authorization Code: {:?}", &authorization_code); + debug!("Authorization Code: {:?}", &authorization_code); trace!("Getting Windows Live tokens (exchange code)"); - authenticator - .exchange_code_for_token(authorization_code, None) - .await? - }; - - let ts = TokenStore { - app_params: authenticator.app_params(), - client_params: authenticator.client_params(), - sandbox_id: authenticator.sandbox_id(), - live_token: live_tokens, - user_token: None, - title_token: None, - device_token: None, - authorization_token: None, - updated: None, - }; - - Ok(ts) -} + let live_tokens = authenticator + .exchange_code_for_token(authorization_code, Some(code_verifier)) + .await?; + debug!("Windows live tokens={:?}", &live_tokens); -/// Shorthand for sisu authentication flow -/// -/// # Examples -/// -/// ``` -/// use xal::{XalAuthenticator, flows, Error, AccessTokenPrefix}; -/// use xal::response::WindowsLiveTokens; -/// -/// # async fn example() -> Result<(), Error> { -/// let mut authenticator = XalAuthenticator::default(); -/// -/// let token_store = flows::xbox_live_sisu_full_flow( -/// &mut authenticator, -/// flows::CliCallbackHandler, -/// ) -/// .await?; -/// -/// // TokenStore will contain user/title/device/xsts tokens -/// assert!(token_store.user_token.is_some()); -/// assert!(token_store.title_token.is_some()); -/// assert!(token_store.device_token.is_some()); -/// assert!(token_store.authorization_token.is_some()); -/// # Ok(()) -/// # } -/// ``` -pub async fn xbox_live_sisu_full_flow( - authenticator: &mut XalAuthenticator, - callback: impl AuthPromptCallback, -) -> Result { - trace!("Getting device token"); - let device_token = authenticator.get_device_token().await?; - debug!("Device token={:?}", device_token); - let (code_challenge, code_verifier) = XalAuthenticator::generate_code_verifier(); - trace!("Generated Code verifier={:?}", code_verifier); - trace!("Generated Code challenge={:?}", code_challenge); - let state = XalAuthenticator::generate_random_state(); - trace!("Generated random state={:?}", state); - - trace!("Fetching SISU authentication URL and Session Id"); - let (auth_resp, session_id) = authenticator - .sisu_authenticate(&device_token, &code_challenge, &state) - .await?; - debug!( - "SISU Authenticate response={:?} Session Id={:?}", - auth_resp, session_id - ); - - // Passing redirect URL to callback and expecting redirect url + authorization token back - trace!("Reaching into callback to receive authentication redirect URL"); - let redirect_url = callback - .call(auth_resp.into()) - .await - .map_err(|e| Error::GeneralError(format!("Failed getting redirect URL err={e}")))? - .ok_or(Error::GeneralError( - "Did not receive any Redirect URL from RedirectUrl callback".to_string(), - ))?; - - debug!("From callback: Redirect URL={:?}", redirect_url); - - trace!("Parsing redirect URI"); - let authorization_code = - XalAuthenticator::parse_authorization_code_response(&redirect_url, Some(&state))?; - - debug!("Authorization Code: {:?}", &authorization_code); - trace!("Getting Windows Live tokens (exchange code)"); - let live_tokens = authenticator - .exchange_code_for_token(authorization_code, Some(code_verifier)) - .await?; - debug!("Windows live tokens={:?}", &live_tokens); - - trace!("Getting Sisu authorization response"); - let sisu_resp = authenticator - .sisu_authorize(&live_tokens, &device_token, Some(session_id)) - .await?; - debug!("Sisu authorizatione response={:?}", sisu_resp); - - let ts = TokenStore { - app_params: authenticator.app_params(), - client_params: authenticator.client_params(), - sandbox_id: authenticator.sandbox_id(), - live_token: live_tokens, - device_token: Some(device_token), - user_token: Some(sisu_resp.user_token), - title_token: Some(sisu_resp.title_token), - authorization_token: Some(sisu_resp.authorization_token), - - updated: None, - }; - - Ok(ts) -} + trace!("Getting Sisu authorization response"); + let sisu_resp = authenticator + .sisu_authorize(&live_tokens, &device_token, Some(session_id)) + .await?; + debug!("Sisu authorizatione response={:?}", sisu_resp); + + let ts = TokenStore { + app_params: authenticator.app_params(), + client_params: authenticator.client_params(), + sandbox_id: authenticator.sandbox_id(), + live_token: live_tokens, + device_token: Some(device_token), + user_token: Some(sisu_resp.user_token), + title_token: Some(sisu_resp.title_token), + authorization_token: Some(sisu_resp.authorization_token), + + updated: None, + }; -/// Implements the traditional Xbox Live authorization flow. -/// -/// The method serves as a shorthand for executing the Xbox Live authorization flow by exchanging -/// [`crate::models::response::WindowsLiveTokens`] to ultimately acquire an authorized Xbox Live session. -/// -/// The authorization flow is designed to be highly modular, allowing for extensive customization -/// based on the specific needs of your application. -/// -/// # Arguments -/// -/// - `xsts_relying_party` XSTS Relying Party URL (see #Notes) -/// - `access_token_prefix` Whether AccessToken needs to be prefixed for the Xbox UserToken (XASU) Request (see #Notes). -/// - `request_title_token` Whether to request a Title Token (see #Notes) -/// -/// # Errors -/// -/// This method may return an error if any of the intermediate token requests fail. -/// For a more detailed explanation of the error, refer to the documentation of the -/// [`crate::XalAuthenticator`] methods. -/// -/// # Returns -/// -/// This method returns a `Result` containing a tuple with two elements: -/// -/// - The updated `XalAuthenticator` instance, with an incremented [`crate::cvlib::CorrelationVector`] -/// - A `TokenStore` struct, with all the tokens necessary exchanged during the authorization flow. -/// -/// # Examples -/// -/// ``` -/// use xal::{XalAuthenticator, flows, Error, AccessTokenPrefix}; -/// use xal::response::WindowsLiveTokens; -/// -/// # async fn async_sleep_fn(_: std::time::Duration) {} -/// -/// # async fn example() -> Result<(), Error> { -/// let mut authenticator = XalAuthenticator::default(); -/// -/// let token_store = flows::ms_device_code_flow( -/// &mut authenticator, -/// flows::CliCallbackHandler, -/// async_sleep_fn -/// ) -/// .await?; -/// -/// // Execute the Xbox Live authorization flow.. -/// let token_store = flows::xbox_live_authorization_traditional_flow( -/// &mut authenticator, -/// token_store.live_token, -/// "rp://api.minecraftservices.com/".to_string(), -/// AccessTokenPrefix::D, -/// true, -/// ) -/// .await?; -/// # Ok(()) -/// # } -/// ``` -/// -/// # Notes -/// -/// - Requesting a Title Token *standalone* aka. without sisu-flow only works for very few clients, -/// currently only "Minecraft" is known. -/// - Depending on the client an AccessToken prefix is necessary to have the User Token (XASU) request succeed -/// - Success of authorizing (device, user, ?title?) tokens for XSTS relies on the target relying party -pub async fn xbox_live_authorization_traditional_flow( - authenticator: &mut XalAuthenticator, - live_tokens: WindowsLiveTokens, - xsts_relying_party: String, - access_token_prefix: AccessTokenPrefix, - request_title_token: bool, -) -> Result { - debug!("Windows live tokens={:?}", &live_tokens); - trace!("Getting device token"); - let device_token = authenticator.get_device_token().await?; - debug!("Device token={:?}", device_token); - - trace!("Getting user token"); - let user_token = authenticator - .get_user_token(&live_tokens, access_token_prefix) - .await?; - - debug!("User token={:?}", user_token); - - let maybe_title_token = if request_title_token { - trace!("Getting title token"); - let title_token = authenticator - .get_title_token(&live_tokens, &device_token) + Ok(ts) + } + + /// Implements the traditional Xbox Live authorization flow. + /// + /// The method serves as a shorthand for executing the Xbox Live authorization flow by exchanging + /// [`crate::models::response::WindowsLiveTokens`] to ultimately acquire an authorized Xbox Live session. + /// + /// The authorization flow is designed to be highly modular, allowing for extensive customization + /// based on the specific needs of your application. + /// + /// # Arguments + /// + /// - `xsts_relying_party` XSTS Relying Party URL (see #Notes) + /// - `access_token_prefix` Whether AccessToken needs to be prefixed for the Xbox UserToken (XASU) Request (see #Notes). + /// - `request_title_token` Whether to request a Title Token (see #Notes) + /// + /// # Errors + /// + /// This method may return an error if any of the intermediate token requests fail. + /// For a more detailed explanation of the error, refer to the documentation of the + /// [`crate::XalAuthenticator`] methods. + /// + /// # Returns + /// + /// This method returns a `Result` containing a tuple with two elements: + /// + /// - The updated `XalAuthenticator` instance, with an incremented [`crate::cvlib::CorrelationVector`] + /// - A `TokenStore` struct, with all the tokens necessary exchanged during the authorization flow. + /// + /// # Examples + /// + /// ``` + /// use xal::{XalAuthenticator, Flows, CliCallbackHandler, Error, AccessTokenPrefix}; + /// use xal::response::WindowsLiveTokens; + /// + /// # async fn async_sleep_fn(_: std::time::Duration) {} + /// + /// # async fn example() -> Result<(), Error> { + /// let mut authenticator = XalAuthenticator::default(); + /// + /// let token_store = Flows::ms_device_code_flow( + /// &mut authenticator, + /// CliCallbackHandler, + /// async_sleep_fn + /// ) + /// .await?; + /// + /// // Execute the Xbox Live authorization flow.. + /// let token_store = Flows::xbox_live_authorization_traditional_flow( + /// &mut authenticator, + /// token_store.live_token, + /// "rp://api.minecraftservices.com/".to_string(), + /// AccessTokenPrefix::D, + /// true, + /// ) + /// .await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// # Notes + /// + /// - Requesting a Title Token *standalone* aka. without sisu-flow only works for very few clients, + /// currently only "Minecraft" is known. + /// - Depending on the client an AccessToken prefix is necessary to have the User Token (XASU) request succeed + /// - Success of authorizing (device, user, ?title?) tokens for XSTS relies on the target relying party + pub async fn xbox_live_authorization_traditional_flow( + authenticator: &mut XalAuthenticator, + live_tokens: WindowsLiveTokens, + xsts_relying_party: String, + access_token_prefix: AccessTokenPrefix, + request_title_token: bool, + ) -> Result { + debug!("Windows live tokens={:?}", &live_tokens); + trace!("Getting device token"); + let device_token = authenticator.get_device_token().await?; + debug!("Device token={:?}", device_token); + + trace!("Getting user token"); + let user_token = authenticator + .get_user_token(&live_tokens, access_token_prefix) .await?; - debug!("Title token={:?}", title_token); - - Some(title_token) - } else { - debug!("Skipping title token request.."); - None - }; - - trace!("Getting XSTS token"); - let authorization_token = authenticator - .get_xsts_token( - Some(&device_token), - maybe_title_token.as_ref(), - Some(&user_token), - &xsts_relying_party, - ) - .await?; - debug!("XSTS token={:?}", authorization_token); - - let ts = TokenStore { - app_params: authenticator.app_params(), - client_params: authenticator.client_params(), - sandbox_id: authenticator.sandbox_id(), - live_token: live_tokens, - device_token: Some(device_token), - user_token: Some(user_token), - title_token: maybe_title_token, - authorization_token: Some(authorization_token), - - updated: None, - }; - - Ok(ts) -} -/// The authorization part of Sisu -/// -/// # Examples -/// -/// ``` -/// use xal::{XalAuthenticator, flows, Error, AccessTokenPrefix}; -/// use xal::response::WindowsLiveTokens; -/// -/// # async fn async_sleep_fn(_: std::time::Duration) {} -/// -/// # async fn example() -> Result<(), Error> { -/// let mut authenticator = XalAuthenticator::default(); -/// -/// let token_store = flows::ms_device_code_flow( -/// &mut authenticator, -/// flows::CliCallbackHandler, -/// async_sleep_fn -/// ) -/// .await?; -/// -/// let token_store = flows::xbox_live_sisu_authorization_flow( -/// &mut authenticator, -/// token_store.live_token, -/// ) -/// .await?; -/// -/// // TokenStore will contain user/title/device/xsts tokens -/// assert!(token_store.user_token.is_some()); -/// assert!(token_store.title_token.is_some()); -/// assert!(token_store.device_token.is_some()); -/// assert!(token_store.authorization_token.is_some()); -/// # Ok(()) -/// # } -/// ``` -pub async fn xbox_live_sisu_authorization_flow( - authenticator: &mut XalAuthenticator, - live_tokens: WindowsLiveTokens, -) -> Result { - debug!("Windows live tokens={:?}", &live_tokens); - trace!("Getting device token"); - let device_token = authenticator.get_device_token().await?; - debug!("Device token={:?}", device_token); - - trace!("Getting user token"); - let resp = authenticator - .sisu_authorize(&live_tokens, &device_token, None) - .await?; - debug!("Sisu authorization response"); - - let ts = TokenStore { - app_params: authenticator.app_params(), - client_params: authenticator.client_params(), - sandbox_id: authenticator.sandbox_id(), - live_token: live_tokens, - device_token: Some(device_token), - user_token: Some(resp.user_token), - title_token: Some(resp.title_token), - authorization_token: Some(resp.authorization_token), - - updated: None, - }; - - Ok(ts) -} + debug!("User token={:?}", user_token); + + let maybe_title_token = if request_title_token { + trace!("Getting title token"); + let title_token = authenticator + .get_title_token(&live_tokens, &device_token) + .await?; + debug!("Title token={:?}", title_token); + + Some(title_token) + } else { + debug!("Skipping title token request.."); + None + }; + + trace!("Getting XSTS token"); + let authorization_token = authenticator + .get_xsts_token( + Some(&device_token), + maybe_title_token.as_ref(), + Some(&user_token), + &xsts_relying_party, + ) + .await?; + debug!("XSTS token={:?}", authorization_token); + + let ts = TokenStore { + app_params: authenticator.app_params(), + client_params: authenticator.client_params(), + sandbox_id: authenticator.sandbox_id(), + live_token: live_tokens, + device_token: Some(device_token), + user_token: Some(user_token), + title_token: maybe_title_token, + authorization_token: Some(authorization_token), + + updated: None, + }; + + Ok(ts) + } + + /// The authorization part of Sisu + /// + /// # Examples + /// + /// ``` + /// use xal::{XalAuthenticator, Flows, CliCallbackHandler, Error, AccessTokenPrefix}; + /// use xal::response::WindowsLiveTokens; + /// + /// # async fn async_sleep_fn(_: std::time::Duration) {} + /// + /// # async fn example() -> Result<(), Error> { + /// let mut authenticator = XalAuthenticator::default(); + /// + /// let token_store = Flows::ms_device_code_flow( + /// &mut authenticator, + /// CliCallbackHandler, + /// async_sleep_fn + /// ) + /// .await?; + /// + /// let token_store = Flows::xbox_live_sisu_authorization_flow( + /// &mut authenticator, + /// token_store.live_token, + /// ) + /// .await?; + /// + /// // TokenStore will contain user/title/device/xsts tokens + /// assert!(token_store.user_token.is_some()); + /// assert!(token_store.title_token.is_some()); + /// assert!(token_store.device_token.is_some()); + /// assert!(token_store.authorization_token.is_some()); + /// # Ok(()) + /// # } + /// ``` + pub async fn xbox_live_sisu_authorization_flow( + authenticator: &mut XalAuthenticator, + live_tokens: WindowsLiveTokens, + ) -> Result { + debug!("Windows live tokens={:?}", &live_tokens); + trace!("Getting device token"); + let device_token = authenticator.get_device_token().await?; + debug!("Device token={:?}", device_token); + + trace!("Getting user token"); + let resp = authenticator + .sisu_authorize(&live_tokens, &device_token, None) + .await?; + debug!("Sisu authorization response"); + + let ts = TokenStore { + app_params: authenticator.app_params(), + client_params: authenticator.client_params(), + sandbox_id: authenticator.sandbox_id(), + live_token: live_tokens, + device_token: Some(device_token), + user_token: Some(resp.user_token), + title_token: Some(resp.title_token), + authorization_token: Some(resp.authorization_token), + + updated: None, + }; + + Ok(ts) + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 36ee563..8f6eefa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,13 +17,15 @@ pub use url; mod authenticator; mod error; +mod flows; mod models; mod request_signer; +mod tokenstore; pub mod extensions; -pub mod flows; -pub mod tokenstore; pub use authenticator::*; pub use error::Error; +pub use flows::*; pub use models::*; pub use request_signer::*; +pub use tokenstore::*; \ No newline at end of file diff --git a/src/request_signer.rs b/src/request_signer.rs index b337656..ed97468 100644 --- a/src/request_signer.rs +++ b/src/request_signer.rs @@ -431,6 +431,8 @@ impl RequestSigner { /// Get Xbox Live endpoint descriptions required for dynamically signing HTTP requests /// based on target domain / endpoint +/// +/// Can be used to instantiate [`SignaturePolicyCache`]. pub async fn get_endpoints() -> Result { let resp = reqwest::Client::new() .get(Constants::XBOX_TITLE_ENDPOINTS_URL) From fbc5c924922f8574b538dd598e288c270d8efeff Mon Sep 17 00:00:00 2001 From: tuxuser <462620+tuxuser@users.noreply.github.com> Date: Wed, 20 Dec 2023 14:13:36 +0100 Subject: [PATCH 11/13] feat: Use SignaturePolicyCache in RequestSigner --- Cargo.toml | 1 + examples/src/bin/auth_azure.rs | 3 +- examples/src/bin/auth_cli.rs | 2 +- examples/src/bin/auth_minecraft.rs | 4 +- examples/src/bin/auth_webview.rs | 4 +- examples/src/lib.rs | 4 +- src/authenticator.rs | 18 ++- src/extensions.rs | 12 +- src/flows.rs | 2 +- src/lib.rs | 2 +- src/request_signer.rs | 245 ++++++++++++++++++++++------- 11 files changed, 213 insertions(+), 84 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1a61c82..9be38e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ nt-time = { version = "0.6.5", features = ["chrono"] } [dev-dependencies] hex-literal = "0.3.4" +tokio = { version = "1", features = ["macros"] } [workspace] members = [ diff --git a/examples/src/bin/auth_azure.rs b/examples/src/bin/auth_azure.rs index 3484b0a..61eabbf 100644 --- a/examples/src/bin/auth_azure.rs +++ b/examples/src/bin/auth_azure.rs @@ -7,10 +7,9 @@ use tokio::{ }; use xal::{ client_params::CLIENT_ANDROID, - AuthPromptCallback, AuthPromptData, oauth2::{RedirectUrl, Scope}, url::Url, - Error, XalAppParameters, + AuthPromptCallback, AuthPromptData, Error, XalAppParameters, }; use xal_examples::auth_main; diff --git a/examples/src/bin/auth_cli.rs b/examples/src/bin/auth_cli.rs index 4e43566..1e60841 100644 --- a/examples/src/bin/auth_cli.rs +++ b/examples/src/bin/auth_cli.rs @@ -1,4 +1,4 @@ -use xal::{CliCallbackHandler, AccessTokenPrefix, Error}; +use xal::{AccessTokenPrefix, CliCallbackHandler, Error}; use xal_examples::auth_main_default; #[tokio::main] diff --git a/examples/src/bin/auth_minecraft.rs b/examples/src/bin/auth_minecraft.rs index ce98195..a303521 100644 --- a/examples/src/bin/auth_minecraft.rs +++ b/examples/src/bin/auth_minecraft.rs @@ -1,7 +1,7 @@ use serde_json::json; use xal::{ - extensions::JsonExDeserializeMiddleware, CliCallbackHandler, oauth2::TokenResponse, AccessTokenPrefix, - Error, XalAuthenticator, + extensions::JsonExDeserializeMiddleware, oauth2::TokenResponse, AccessTokenPrefix, + CliCallbackHandler, Error, XalAuthenticator, }; use xal_examples::auth_main; diff --git a/examples/src/bin/auth_webview.rs b/examples/src/bin/auth_webview.rs index b2a4503..04943fc 100644 --- a/examples/src/bin/auth_webview.rs +++ b/examples/src/bin/auth_webview.rs @@ -14,9 +14,7 @@ use wry::{ webview::WebViewBuilder, }; use xal::{ - AuthPromptCallback, AuthPromptData, - url::Url, - AccessTokenPrefix, Error, XalAuthenticator, + url::Url, AccessTokenPrefix, AuthPromptCallback, AuthPromptData, Error, XalAuthenticator, }; use xal_examples::auth_main_default; diff --git a/examples/src/lib.rs b/examples/src/lib.rs index 3935d46..0ed9dcd 100644 --- a/examples/src/lib.rs +++ b/examples/src/lib.rs @@ -2,8 +2,8 @@ use clap::{Parser, ValueEnum}; use env_logger::Env; use log::info; use xal::{ - Flows, TokenStore, AccessTokenPrefix, Constants, Error, XalAppParameters, - XalAuthenticator, XalClientParameters, AuthPromptCallback + AccessTokenPrefix, AuthPromptCallback, Constants, Error, Flows, TokenStore, XalAppParameters, + XalAuthenticator, XalClientParameters, }; /// Common cli arguments diff --git a/src/authenticator.rs b/src/authenticator.rs index 5883c3c..3669f05 100644 --- a/src/authenticator.rs +++ b/src/authenticator.rs @@ -718,7 +718,8 @@ impl XalAuthenticator { .header("x-xbl-contract-version", "1") .add_cv(&mut self.ms_cv)? .json(&json_body) - .sign(&self.request_signer, None)? + .sign(&mut self.request_signer, None) + .await? .send() .await?; @@ -807,7 +808,8 @@ impl XalAuthenticator { .post(Constants::XBOX_SISU_AUTHORIZE_URL) .add_cv(&mut self.ms_cv)? .json(&json_body) - .sign(&self.request_signer, None)? + .sign(&mut self.request_signer, None) + .await? .send() .await? .json_ex::() @@ -876,7 +878,8 @@ impl XalAuthenticator { .header("x-xbl-contract-version", "1") .add_cv(&mut self.ms_cv)? .json(&json_body) - .sign(&self.request_signer, None)? + .sign(&mut self.request_signer, None) + .await? .send() .await? .json_ex::() @@ -924,7 +927,8 @@ impl XalAuthenticator { .header("x-xbl-contract-version", "1") .add_cv(&mut self.ms_cv)? .json(&json_body) - .sign(&self.request_signer, None)? + .sign(&mut self.request_signer, None) + .await? .log() .await? .send() @@ -973,7 +977,8 @@ impl XalAuthenticator { .header("x-xbl-contract-version", "1") .add_cv(&mut self.ms_cv)? .json(&json_body) - .sign(&self.request_signer, None)? + .sign(&mut self.request_signer, None) + .await? .log() .await? .send() @@ -1032,7 +1037,8 @@ impl XalAuthenticator { .header("x-xbl-contract-version", "1") .add_cv(&mut self.ms_cv)? .json(&json_body) - .sign(&self.request_signer, None)? + .sign(&mut self.request_signer, None) + .await? .log() .await? .send() diff --git a/src/extensions.rs b/src/extensions.rs index e7e4257..090e11c 100644 --- a/src/extensions.rs +++ b/src/extensions.rs @@ -98,26 +98,28 @@ impl JsonExDeserializeMiddleware for reqwest::Response { } /// Extension to [`reqwest::RequestBuilder`] for signing HTTP requests according to Xbox Live specs +#[async_trait] pub trait SigningReqwestBuilder { /// Sign HTTP request for Xbox Live - fn sign( + async fn sign( self, - signer: &RequestSigner, + signer: &mut RequestSigner, timestamp: Option>, ) -> Result; } +#[async_trait] impl SigningReqwestBuilder for reqwest::RequestBuilder { - fn sign( + async fn sign( self, - signer: &RequestSigner, + signer: &mut RequestSigner, timestamp: Option>, ) -> Result { match self.try_clone() { Some(rb) => { let request = rb.build()?; // Fallback to Utc::now() internally - let signed = signer.sign_request(request, timestamp)?; + let signed = signer.sign_request(request, timestamp).await?; let body_bytes = signed .body() .ok_or(Error::InvalidRequest("Failed getting request body".into()))? diff --git a/src/flows.rs b/src/flows.rs index dc85368..85aa4e5 100644 --- a/src/flows.rs +++ b/src/flows.rs @@ -727,4 +727,4 @@ impl Flows { Ok(ts) } -} \ No newline at end of file +} diff --git a/src/lib.rs b/src/lib.rs index 8f6eefa..c47ed2d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,4 +28,4 @@ pub use error::Error; pub use flows::*; pub use models::*; pub use request_signer::*; -pub use tokenstore::*; \ No newline at end of file +pub use tokenstore::*; diff --git a/src/request_signer.rs b/src/request_signer.rs index ed97468..a2e8718 100644 --- a/src/request_signer.rs +++ b/src/request_signer.rs @@ -4,13 +4,14 @@ use crate::{ error::Error, extensions::JsonExDeserializeMiddleware, - models::{self, SigningPolicy}, + models::SigningPolicy, response::{self, TitleEndpointsResponse}, Constants, ProofKey, }; +use async_trait::async_trait; use base64ct::{self, Base64, Encoding}; use chrono::prelude::*; -use log::warn; +use log::{info, warn}; use nt_time::FileTime; use p256::{ ecdsa::{ @@ -28,15 +29,21 @@ use std::{ }; /// Request signing trait +#[async_trait] pub trait RequestSigning { /// Sign a request - fn sign_request(&self, rhs: Rhs, timestamp: Option>) -> Result; + async fn sign_request( + &mut self, + rhs: Rhs, + timestamp: Option>, + ) -> Result; } /// Request verification trait +#[async_trait] pub trait RequestVerification { /// Verify a request's signature - fn verify(&self, rhs: Rhs) -> Result; + async fn verify(&mut self, rhs: Rhs) -> Result; } /// Helper structure which describes the components of a Xbox Live HTTP Signature @@ -183,25 +190,24 @@ impl TryFrom>> for HttpMessageToSign { /// Request signer /// /// Calculates the `Signature` header for certain Xbox Live HTTP request -#[derive(Debug, PartialEq, Eq, Clone)] +#[derive(Debug, Clone)] pub struct RequestSigner { /// Elliptic curve keypair pub keypair: SecretKey, - /// Signing policy - /// - /// Specific signing policies can be gathered by [`get_endpoints`] - pub signing_policy: models::SigningPolicy, + /// Signing policy cache + pub signature_policy_cache: SignaturePolicyCache, } impl Default for RequestSigner { fn default() -> Self { - Self::new(SigningPolicy::default()) + Self::new() } } +#[async_trait] impl RequestSigning for RequestSigner { - fn sign_request( - &self, + async fn sign_request( + &mut self, rhs: reqwest::Request, timestamp: Option>, ) -> Result { @@ -209,11 +215,20 @@ impl RequestSigning for RequestSigner { // Gather data from request used for signing let to_sign = rhs.try_into()?; + let signing_policy = self + .signature_policy_cache + .find_policy_for_url(clone_request.url().as_str()) + .await? + .ok_or(Error::GeneralError( + "No signature policy found for url".into(), + ))?; + // Create signature let signature = self.sign( - self.signing_policy.version, + signing_policy.version, timestamp.unwrap_or_else(Utc::now), &to_sign, + signing_policy.max_body_bytes, )?; // Replace request body with byte representation (so signature creation is deterministic) @@ -228,8 +243,9 @@ impl RequestSigning for RequestSigner { } } +#[async_trait] impl RequestVerification for RequestSigner { - fn verify(&self, rhs: reqwest::Request) -> Result { + async fn verify(&mut self, rhs: reqwest::Request) -> Result { let request_clone = rhs .try_clone() .ok_or(Error::InvalidRequest("Failed cloning request".into()))?; @@ -246,18 +262,28 @@ impl RequestVerification for RequestSigner { })? .to_owned(); + let signing_policy = self + .signature_policy_cache + .find_policy_for_url(rhs.url().as_str()) + .await? + .ok_or(Error::GeneralError( + "No signature policy found for url".into(), + ))?; + self.verify_message( XboxWebSignatureBytes::from_str(&signature)?, &request_clone.try_into()?, + signing_policy.max_body_bytes, )?; Ok(rhs) } } +#[async_trait] impl RequestSigning>> for RequestSigner { - fn sign_request( - &self, + async fn sign_request( + &mut self, rhs: http::Request>, timestamp: Option>, ) -> Result>, Error> { @@ -270,11 +296,20 @@ impl RequestSigning>> for RequestSigner { rhs.body().clone(), ); + let signing_policy = self + .signature_policy_cache + .find_policy_for_url(&rhs.uri().to_string()) + .await? + .ok_or(Error::GeneralError( + "No signature policy found for url".into(), + ))?; + // Create signature let signature = self.sign( - self.signing_policy.version, + signing_policy.version, timestamp.unwrap_or_else(Utc::now), &rhs.try_into()?, + signing_policy.max_body_bytes, )?; // Assign Signature-header in request @@ -292,10 +327,10 @@ impl RequestSigning>> for RequestSigner { impl RequestSigner { /// Creates a new instance of [`RequestSigner`] - pub fn new(policy: models::SigningPolicy) -> Self { + pub fn new() -> Self { Self { keypair: SecretKey::random(&mut rand::thread_rng()), - signing_policy: policy, + signature_policy_cache: SignaturePolicyCache::default(), } } @@ -310,6 +345,7 @@ impl RequestSigner { signing_policy_version: i32, timestamp: DateTime, request: &HttpMessageToSign, + max_body_bytes: usize, ) -> Result { self.sign_raw( signing_policy_version, @@ -318,11 +354,13 @@ impl RequestSigner { &request.path_and_query, &request.authorization, &request.body, + max_body_bytes, ) .map_err(std::convert::Into::into) } /// Create signature from low-level parts + #[allow(clippy::too_many_arguments)] fn sign_raw( &self, signing_policy_version: i32, @@ -331,6 +369,7 @@ impl RequestSigner { path_and_query: &str, authorization: &str, body: &[u8], + max_body_bytes: usize, ) -> Result { let signing_key: SigningKey = self.keypair.clone().into(); @@ -347,7 +386,7 @@ impl RequestSigner { path_and_query, authorization, body, - self.signing_policy.max_body_bytes, + max_body_bytes, ); // Sign the message @@ -366,6 +405,7 @@ impl RequestSigner { &self, signature: XboxWebSignatureBytes, request: &HttpMessageToSign, + max_body_bytes: usize, ) -> Result<(), Error> { let verifier: VerifyingKey = self.keypair.public_key().into(); @@ -377,7 +417,7 @@ impl RequestSigner { &request.path_and_query, &request.authorization, &request.body, - self.signing_policy.max_body_bytes, + max_body_bytes, ); verifier @@ -431,7 +471,7 @@ impl RequestSigner { /// Get Xbox Live endpoint descriptions required for dynamically signing HTTP requests /// based on target domain / endpoint -/// +/// /// Can be used to instantiate [`SignaturePolicyCache`]. pub async fn get_endpoints() -> Result { let resp = reqwest::Client::new() @@ -450,25 +490,30 @@ pub async fn get_endpoints() -> Result /// /// #[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Default)] pub struct SignaturePolicyCache { - endpoints: TitleEndpointsResponse, + endpoints: Option, } + + impl SignaturePolicyCache { /// Create a new SignaturePolicyCache. pub fn new(endpoints: TitleEndpointsResponse) -> Self { - Self { endpoints } + Self { + endpoints: Some(endpoints), + } } /// Retrieve the stored TitleEndpointsResponse. - pub fn get_endpoints(&self) -> TitleEndpointsResponse { + pub fn get_endpoints(&self) -> Option { self.endpoints.clone() } /// Find the policy for the given URL. /// /// If a matching policy is found, returns the corresponding SigningPolicy. Otherwise, returns None. - pub fn find_policy_for_url(&self, url: &str) -> Result, Error> { + pub async fn find_policy_for_url(&mut self, url: &str) -> Result, Error> { let url = url::Url::parse(url)?; if !["http", "https"].contains(&url.scheme()) { @@ -477,8 +522,17 @@ impl SignaturePolicyCache { ))); } - let matching_endpoint = self - .endpoints + let endpoints = match self.endpoints.as_ref() { + Some(eps) => eps.to_owned(), + None => { + info!("No cached TitleEndpoints found, attempting download of new copy"); + let eps = get_endpoints().await?; + self.endpoints = Some(eps.clone()); + eps + } + }; + + let matching_endpoint = endpoints .end_points .iter() .filter(|e| { @@ -503,11 +557,13 @@ impl SignaturePolicyCache { Some(ep) => { println!("Identified Title endpoint={ep:?} for URL={url} {url:?}"); let policy_index = ep.signature_policy_index.unwrap() as usize; - let policy = self.endpoints.signature_policies.get(policy_index).ok_or( - Error::GeneralError(format!( - "SignaturePolicy at index {policy_index} not found!" - )), - )?; + let policy = + endpoints + .signature_policies + .get(policy_index) + .ok_or(Error::GeneralError(format!( + "SignaturePolicy at index {policy_index} not found!" + )))?; Ok(Some(policy.to_owned())) } @@ -528,8 +584,16 @@ mod test { use hex_literal::hex; use reqwest::{Body, Client}; + const MAX_BODY_BYTES: usize = 8192; const PRIVATE_KEY_PEM: &str = "MHcCAQEEIObr5IVtB+DQcn25+R9n4K/EyUUSbVvxIJY7WhVeELUuoAoGCCqGSM49AwEHoUQDQgAEOKyCQ9qH5U4lZcS0c5/LxIyKvOpKe0l3x4Eg5OgDbzezKNLRgT28fd4Fq3rU/1OQKmx6jSq0vTB5Ao/48m0iGg=="; + fn get_title_endpoints() -> TitleEndpointsResponse { + serde_json::from_str::(include_str!( + "../testdata/title_endpoints.json" + )) + .unwrap() + } + fn get_request_signer() -> RequestSigner { let private_key = Base64::decode_vec(PRIVATE_KEY_PEM).expect("Failed deserializing EC private key"); @@ -537,12 +601,12 @@ mod test { RequestSigner { keypair: SecretKey::from_sec1_der(&private_key) .expect("Failed deserializing private key"), - signing_policy: Default::default(), + signature_policy_cache: SignaturePolicyCache::new(get_title_endpoints()), } } - #[test] - fn find_matching_signing_policy() { + #[tokio::test] + async fn find_matching_signing_policy() { let policy_0: SigningPolicy = SigningPolicy { version: 1, supported_algorithms: vec![SigningAlgorithm::ES256], @@ -554,25 +618,23 @@ mod test { max_body_bytes: 4294967295, }; - let title_endpoints = serde_json::from_str::( - include_str!("../testdata/title_endpoints.json"), - ) - .unwrap(); - - let cache = SignaturePolicyCache::new(title_endpoints); + let mut cache = SignaturePolicyCache::new(get_title_endpoints()); assert!(cache .find_policy_for_url("https://unhandled.example.com") + .await .unwrap() .is_none()); assert!(cache .find_policy_for_url("https://unhandled.microsoft.com") + .await .unwrap() .is_none()); assert_eq!( cache .find_policy_for_url("https://experimentation.xboxlive.com") + .await .unwrap() .unwrap(), policy_0 @@ -580,6 +642,55 @@ mod test { assert_eq!( cache .find_policy_for_url("https://xoobe.xboxlive.com") + .await + .unwrap() + .unwrap(), + policy_0 + ); + assert_eq!( + cache + .find_policy_for_url("https://sisu.xboxlive.com/authenticate") + .await + .unwrap() + .unwrap(), + policy_0 + ); + assert_eq!( + cache + .find_policy_for_url("https://sisu.xboxlive.com/authorize") + .await + .unwrap() + .unwrap(), + policy_0 + ); + assert_eq!( + cache + .find_policy_for_url("https://device.auth.xboxlive.com/device/authenticate") + .await + .unwrap() + .unwrap(), + policy_0 + ); + assert_eq!( + cache + .find_policy_for_url("https://title.auth.xboxlive.com/title/authenticate") + .await + .unwrap() + .unwrap(), + policy_0 + ); + assert_eq!( + cache + .find_policy_for_url("https://user.auth.xboxlive.com/user_authenticate") + .await + .unwrap() + .unwrap(), + policy_0 + ); + assert_eq!( + cache + .find_policy_for_url("https://xsts.auth.xboxlive.com/xsts/authorize") + .await .unwrap() .unwrap(), policy_0 @@ -587,6 +698,7 @@ mod test { assert_eq!( cache .find_policy_for_url("https://xaaa.bbtv.cn/xboxsms/OOBEService/AuthorizationStatus") + .await .unwrap() .unwrap(), policy_0 @@ -595,6 +707,7 @@ mod test { assert_eq!( cache .find_policy_for_url("https://hello.experimentation.xboxlive.com") + .await .unwrap() .unwrap(), policy_1 @@ -602,6 +715,7 @@ mod test { assert_eq!( cache .find_policy_for_url("https://data-vef.xboxlive.com") + .await .unwrap() .unwrap(), policy_1 @@ -609,6 +723,7 @@ mod test { assert_eq!( cache .find_policy_for_url("https://settings.xboxlive.com") + .await .unwrap() .unwrap(), policy_1 @@ -616,6 +731,7 @@ mod test { assert_eq!( cache .find_policy_for_url("https://device.mgt.xboxlive.com") + .await .unwrap() .unwrap(), policy_1 @@ -623,6 +739,7 @@ mod test { assert_eq!( cache .find_policy_for_url("https://device.mgt.xboxlive.com/devices/current/unlock") + .await .unwrap() .unwrap(), policy_1 @@ -649,11 +766,12 @@ mod test { &request.path_and_query, &request.authorization, &request.body, + MAX_BODY_BYTES, ) .expect("Signing failed!"); signer - .verify_message(signature, &request) + .verify_message(signature, &request, MAX_BODY_BYTES) .expect("Verification failed") } @@ -671,7 +789,7 @@ mod test { "/path?query=1", "XBL3.0 x=userid;jsonwebtoken", "thebodygoeshere".as_bytes(), - 8192, + MAX_BODY_BYTES, ); assert_eq!( @@ -680,14 +798,14 @@ mod test { ); } - #[test] - fn sign_reqwest() { - let signer = get_request_signer(); + #[tokio::test] + async fn sign_reqwest() { + let mut signer = get_request_signer(); let timestamp = Utc.timestamp_opt(1586999965, 0).unwrap(); let client = reqwest::Client::new(); let mut request = client - .post("https://example.com/path") + .post("https://example.xboxlive.com/path") .query(&[("query", 1)]) .header( reqwest::header::AUTHORIZATION, @@ -699,12 +817,13 @@ mod test { request = signer .sign_request(request, Some(timestamp)) + .await .expect("FAILED signing request"); let signature = request.headers().get("Signature"); assert!(signature.is_some()); - assert!(signer.verify(request).is_ok()); + assert!(signer.verify(request).await.is_ok()); } #[test] @@ -715,7 +834,7 @@ mod test { let signer = RequestSigner { keypair: SecretKey::from_sec1_der(&private_key).unwrap(), - signing_policy: Default::default(), + signature_policy_cache: SignaturePolicyCache::new(get_title_endpoints()), }; let request = HttpMessageToSign { @@ -727,28 +846,32 @@ mod test { let signature = XboxWebSignatureBytes::from_str("AAAAAQHY4xgs5DyIujFG5E5MZ4D1xjd9Up+H4AKLoyBHd95MAUZcabUN//Y/gijed4vvKtlfp4Cd4dJzVhpK0m+sYZcYRqQjBEKAZw==") .expect("Failed to deserialize into XboxWebSignatureBytes"); - assert!(signer.verify_message(signature, &request).is_ok()); + assert!(signer + .verify_message(signature, &request, MAX_BODY_BYTES) + .is_ok()); } - #[test] - fn build_signed_get_request() { - let signer = get_request_signer(); + #[tokio::test] + async fn build_signed_get_request() { + let mut signer = get_request_signer(); let request = Client::new() - .get("https://example.com") - .sign(&signer, None) + .get("https://example.xboxlive.com") + .sign(&mut signer, None) + .await .expect("Failed to sign HTTP GET request") .build(); assert!(request.is_ok()); } - #[test] - fn build_signed_post_request() { - let signer = get_request_signer(); + #[tokio::test] + async fn build_signed_post_request() { + let mut signer = get_request_signer(); let request = Client::new() - .post("https://example.com") + .post("https://example.xboxlive.com") .body(Body::from(b"somedata".to_vec())) - .sign(&signer, None) + .sign(&mut signer, None) + .await .expect("Failed to sign HTTP POST request") .build(); From 20288dbde880a69799203b7fa58ade90c138a9eb Mon Sep 17 00:00:00 2001 From: tuxuser <462620+tuxuser@users.noreply.github.com> Date: Wed, 20 Dec 2023 23:14:03 +0100 Subject: [PATCH 12/13] Add more documentation / examples --- Cargo.toml | 1 + examples/src/bin/xbl_signed_request.rs | 45 +++++ src/authenticator.rs | 219 +++++++++++++++++++++++-- src/extensions.rs | 140 +++++++++++++++- src/flows.rs | 47 ++++-- src/lib.rs | 176 +++++++++++++++++++- src/models.rs | 48 +++++- src/request_signer.rs | 51 +++++- src/tokenstore.rs | 104 +++++++++++- 9 files changed, 786 insertions(+), 45 deletions(-) create mode 100644 examples/src/bin/xbl_signed_request.rs diff --git a/Cargo.toml b/Cargo.toml index 9be38e9..dba0a5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ nt-time = { version = "0.6.5", features = ["chrono"] } [dev-dependencies] hex-literal = "0.3.4" tokio = { version = "1", features = ["macros"] } +tokio-test = "0.4.3" [workspace] members = [ diff --git a/examples/src/bin/xbl_signed_request.rs b/examples/src/bin/xbl_signed_request.rs new file mode 100644 index 0000000..33e9bcc --- /dev/null +++ b/examples/src/bin/xbl_signed_request.rs @@ -0,0 +1,45 @@ +use env_logger::Env; +use xal::{ + cvlib::CorrelationVector, + extensions::{ + CorrelationVectorReqwestBuilder, LoggingReqwestRequestHandler, + LoggingReqwestResponseHandler, SigningReqwestBuilder, + }, + Error, RequestSigner, TokenStore, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + env_logger::Builder::from_env(Env::default().default_filter_or("trace")).init(); + + // Load tokens from JSON + let token_store = TokenStore::load_from_file("tokens.json")?; + + // Create new instances of Correlation vector and request signer + let mut cv = CorrelationVector::new(); + let mut signer = RequestSigner::new(); + + // Check if XSTS token exists + let xsts_token = token_store + .authorization_token + .ok_or(Error::GeneralError("No XSTS token was acquired".into()))?; + + // Send a http request + // Request will get signed and MS-CV header populated + let userpresence = reqwest::Client::new() + .get("https://userpresence.xboxlive.com/users/me?level=all") + .header("x-xbl-contract-version", "3") + .header("Authorization", xsts_token.authorization_header_value()) + .add_cv(&mut cv)? + .sign(&mut signer, None) + .await? + .log() + .await? + .send() + .await? + .log() + .await?; + + println!("{:?}", userpresence); + Ok(()) +} diff --git a/src/authenticator.rs b/src/authenticator.rs index 3669f05..2c5d0c7 100644 --- a/src/authenticator.rs +++ b/src/authenticator.rs @@ -117,11 +117,25 @@ impl Default for XalAuthenticator { /// Static methods impl XalAuthenticator { /// Generate OAuth2 random state + /// + /// Examples + /// + /// ``` + /// # use xal::XalAuthenticator; + /// let state = XalAuthenticator::generate_random_state(); + /// ``` pub fn generate_random_state() -> CsrfToken { CsrfToken::new_random() } /// Generate OAuth2 code verifier + /// + /// # Examples + /// + /// ``` + /// # use xal::XalAuthenticator; + /// let (pkce_challenge, pkce_verifier) = XalAuthenticator::generate_code_verifier(); + /// ``` pub fn generate_code_verifier() -> (PkceCodeChallenge, PkceCodeVerifier) { PkceCodeChallenge::new_random_sha256() } @@ -174,6 +188,22 @@ impl XalAuthenticator { /// /// * `Error::GeneralError` - If there is a problem with the response url. /// * `Error::OAuthExecutionError` - If there is an error with the server response or if the authorization code is not present. + /// + /// # Examples + /// + /// ``` + /// use xal::{ + /// XalAuthenticator, url::Url, + /// oauth2::CsrfToken, + /// }; + /// let url = Url::parse("https://example.com/?code=123&state=ABC").unwrap(); + /// let code = XalAuthenticator::parse_authorization_code_response( + /// &url, + /// Some(&CsrfToken::new("ABC".into())), + /// ).unwrap(); + /// + /// assert_eq!(code.secret(), "123"); + /// ``` pub fn parse_authorization_code_response( redirect_url: &Url, expected_state: Option<&CsrfToken>, @@ -238,15 +268,20 @@ impl XalAuthenticator { /// /// ``` /// use xal::XalAuthenticator; - /// use xal::oauth2::CsrfToken; + /// use xal::oauth2::{CsrfToken, TokenResponse}; /// use xal::url::Url; /// - /// let url = Url::parse("https://example.com/callback#access_token=token123&token_type=Bearer&expires_in=3600&state=123abc") - /// .unwrap(); - /// let state = "123abc".to_string(); - /// let expected_state = CsrfToken::new(state); - /// let parsed_response = XalAuthenticator::parse_implicit_grant_url(&url, Some(&expected_state)).unwrap(); - /// println!("{:?}", parsed_response); + /// let url = Url::parse( + /// "https://example.com/callback#access_token=token123&token_type=Bearer&expires_in=3600&state=123abc" + /// ) + /// .unwrap(); + /// + /// let live_tokens = XalAuthenticator::parse_implicit_grant_url( + /// &url, + /// Some(&CsrfToken::new("123abc".into())) + /// ).unwrap(); + /// + /// assert_eq!(live_tokens.access_token().secret(), "token123"); /// ``` pub fn parse_implicit_grant_url( url: &Url, @@ -303,6 +338,23 @@ impl XalAuthenticator { /// /// See constants in [`crate::models::app_params`] for [`crate::XalAppParameters`] and /// [`crate::models::client_params`] for [`crate::XalClientParameters`]. + /// + /// # Examples + /// + /// Instantiate explicitly with app/client parameters + /// + /// ``` + /// use xal::{XalAuthenticator, app_params, client_params}; + /// let authenticator = XalAuthenticator::new( + /// app_params::APP_GAMEPASS_BETA(), + /// client_params::CLIENT_ANDROID(), + /// "RETAIL".into(), + /// ); + /// ``` + /// + /// # Notes + /// + /// If you don't have specific needs for client parameters, use [`crate::XalAuthenticator::default`] pub fn new( app_params: XalAppParameters, client_params: XalClientParameters, @@ -352,7 +404,9 @@ impl XalAuthenticator { .map(|url| Url::parse(&url).unwrap()) } - /// Create an internal OAuth2 client with provided scopes + /// Create an internal [`oauth2::Client`] + /// + /// Refer to [`oauth2`] crate for it's usage pub fn oauth_client(&self, client_secret: Option) -> Result { let client = OAuthClient::new( ClientId::new(self.app_params.app_id.to_string()), @@ -430,6 +484,27 @@ impl XalAuthenticator { /// to the user, call `poll_device_code_auth`. /// /// You can transform the returned value into [`crate::oauth2::VerificationUriComplete`] by calling `get_device_code_verification_uri`. + /// + /// # Examples + /// + /// ```no_run + /// use xal::XalAuthenticator; + /// + /// let mut authenticator = XalAuthenticator::default(); + /// # tokio_test::block_on(async { + /// let device_code_resp = authenticator + /// .initiate_device_code_auth() + /// .await + /// .unwrap(); + /// // Present authentication parameters from `device_code_resp` to user + /// let live_tokens = authenticator + /// .poll_device_code_auth(&device_code_resp, tokio::time::sleep) + /// .await + /// .unwrap(); + /// + /// println!("{live_tokens:?}"); + /// # }); + /// ``` pub async fn initiate_device_code_auth( &mut self, ) -> Result { @@ -450,6 +525,27 @@ impl XalAuthenticator { /// # Arguments /// /// - `sleep_fn` is the impl of an async sleep function + /// + /// # Examples + /// + /// ```no_run + /// use xal::XalAuthenticator; + /// + /// let mut authenticator = XalAuthenticator::default(); + /// # tokio_test::block_on(async { + /// let device_code_resp = authenticator + /// .initiate_device_code_auth() + /// .await + /// .unwrap(); + /// // Present authentication parameters from `device_code_resp` to user + /// let live_tokens = authenticator + /// .poll_device_code_auth(&device_code_resp, tokio::time::sleep) + /// .await + /// .unwrap(); + /// + /// println!("{live_tokens:?}"); + /// # }); + /// ``` pub async fn poll_device_code_auth( &mut self, device_auth_resp: &StandardDeviceAuthorizationResponse, @@ -585,20 +681,20 @@ impl XalAuthenticator { /// /// # Examples /// - /// ``` + /// ```no_run /// use xal::XalAuthenticator; /// use xal::oauth2::RefreshToken; /// - /// let authenticator = XalAuthenticator::default(); + /// let mut authenticator = XalAuthenticator::default(); /// let refresh_token = RefreshToken::new("old_refresh_token".to_string()); - /// /* + /// # tokio_test::block_on(async { /// let refreshed_live_tokens = authenticator /// .refresh_token(&refresh_token) /// .await /// .unwrap(); /// /// println!("Refreshed tokens: {refreshed_live_tokens:?}"); - /// */ + /// # }); /// ``` pub async fn refresh_token( &mut self, @@ -903,6 +999,30 @@ impl XalAuthenticator { /// /// This method returns an [`crate::Error`] if the POST request fails or the JSON response cannot be parsed. /// + /// # Examples + /// + /// ``` + /// use xal::{XalAuthenticator, Flows, Error, AccessTokenPrefix, CliCallbackHandler}; + /// use xal::response::WindowsLiveTokens; + /// + /// # async fn example() -> Result<(), Error> { + /// let mut authenticator = XalAuthenticator::default(); + /// + /// let token_store = Flows::ms_device_code_flow( + /// &mut authenticator, + /// CliCallbackHandler, + /// tokio::time::sleep + /// ) + /// .await?; + /// + /// let user_token = authenticator.get_user_token( + /// &token_store.live_token, + /// AccessTokenPrefix::D, + /// ) + /// .await?; + /// # Ok(()) + /// # } + /// ``` pub async fn get_user_token( &mut self, access_token: &response::WindowsLiveTokens, @@ -956,6 +1076,36 @@ impl XalAuthenticator { /// /// This method returns an `Error` if the POST request fails or the JSON response cannot be parsed. /// + /// # Examples + /// + /// ``` + /// use xal::{XalAuthenticator, Flows, Error, AccessTokenPrefix, CliCallbackHandler}; + /// + /// # async fn example() -> Result<(), Error> { + /// let mut authenticator = XalAuthenticator::new( + /// xal::app_params::MC_BEDROCK_SWITCH(), + /// xal::client_params::CLIENT_NINTENDO(), + /// "RETAIL".into() + /// ); + /// + /// let token_store = Flows::ms_device_code_flow( + /// &mut authenticator, + /// CliCallbackHandler, + /// tokio::time::sleep + /// ) + /// .await?; + /// + /// let device_token = authenticator.get_device_token() + /// .await?; + /// + /// let title_token = authenticator.get_title_token( + /// &token_store.live_token, + /// &device_token, + /// ) + /// .await?; + /// # Ok(()) + /// # } + /// ``` pub async fn get_title_token( &mut self, access_token: &response::WindowsLiveTokens, @@ -1007,6 +1157,51 @@ impl XalAuthenticator { /// # Errors /// /// This method returns an `Error` if the POST request fails or the JSON response cannot be parsed. + /// + /// # Examples + /// + /// ``` + /// use xal::{XalAuthenticator, Flows, Error, AccessTokenPrefix, CliCallbackHandler}; + /// use xal::response::WindowsLiveTokens; + /// + /// # async fn example() -> Result<(), Error> { + /// let mut authenticator = XalAuthenticator::new( + /// xal::app_params::MC_BEDROCK_SWITCH(), + /// xal::client_params::CLIENT_NINTENDO(), + /// "RETAIL".into() + /// ); + /// + /// let token_store = Flows::ms_device_code_flow( + /// &mut authenticator, + /// CliCallbackHandler, + /// tokio::time::sleep + /// ) + /// .await?; + /// + /// let device_token = authenticator.get_device_token() + /// .await?; + /// + /// let title_token = authenticator.get_title_token( + /// &token_store.live_token, + /// &device_token, + /// ) + /// .await?; + /// + /// let user_token = authenticator.get_user_token( + /// &token_store.live_token, + /// AccessTokenPrefix::None, + /// ) + /// .await?; + /// + /// let xsts_token = authenticator.get_xsts_token( + /// Some(&device_token), + /// Some(&title_token), + /// Some(&user_token), + /// "rp://api.minecraftservices.com/", + /// ).await?; + /// # Ok(()) + /// # } + /// ``` pub async fn get_xsts_token( &mut self, device_token: Option<&response::DeviceToken>, diff --git a/src/extensions.rs b/src/extensions.rs index 090e11c..28d0de3 100644 --- a/src/extensions.rs +++ b/src/extensions.rs @@ -1,5 +1,6 @@ //! Extensions to reqwest HTTP client library. //! +//! See the respective trait for implementation examples. use async_trait::async_trait; use chrono::{DateTime, Utc}; use cvlib::CorrelationVector; @@ -10,6 +11,29 @@ use reqwest::ResponseBuilderExt; use crate::{error::Error, request_signer::RequestSigner, RequestSigning}; /// Extension to [`reqwest::RequestBuilder`] allowing for verbosely logging the request +/// +/// # Examples +/// +/// ``` +/// use xal::Error; +/// use xal::extensions::LoggingReqwestRequestHandler; +/// use reqwest::Client; +/// +/// async fn demo_log_request() -> Result<(), Error> { +/// /* Initialize loglevel */ +/// // simple_logger::init_with_level(log::Level::Debug).unwrap(); +/// +/// // Log the full request to DEBUG-loglevel before sending it +/// let _resp = Client::new() +/// .get("https://example.com") +/// .log() +/// .await? +/// .send() +/// .await?; +/// +/// Ok(()) +/// } +/// ``` #[async_trait] pub trait LoggingReqwestRequestHandler { /// Log request (debug-loglevel) @@ -36,7 +60,30 @@ impl LoggingReqwestRequestHandler for reqwest::RequestBuilder { } } -/// Extension to [`reqwest::Response`] allowing for verbosely logging the request +/// Extension to [`reqwest::RequestBuilder`] allowing for verbosely logging the response +/// +/// # Examples +/// +/// ``` +/// use xal::Error; +/// use xal::extensions::LoggingReqwestResponseHandler; +/// use reqwest::Client; +/// +/// async fn demo_log_response() -> Result<(), Error> { +/// /* Initialize loglevel */ +/// // simple_logger::init_with_level(log::Level::Debug).unwrap(); +/// +/// // Log the full request to DEBUG-loglevel before sending it +/// let _resp = Client::new() +/// .get("https://example.com") +/// .send() +/// .await? +/// .log() +/// .await?; +/// +/// Ok(()) +/// } +/// ``` #[async_trait] pub trait LoggingReqwestResponseHandler { /// Log response (debug-loglevel) @@ -51,14 +98,15 @@ impl LoggingReqwestResponseHandler for reqwest::Response { .status(self.status()); let headers = self.headers().clone(); - let hdr_mut = response_builder.headers_mut().unwrap(); + let hdr_mut = response_builder.headers_mut().ok_or(Error::GeneralError( + "Failed to get mut ref to header".into(), + ))?; + headers.into_iter().for_each(|(key, val)| { hdr_mut.insert(key.unwrap(), val); }); - let new_resp = response_builder - .body(self.bytes().await?) - .expect("Failed to attach body to new response"); + let new_resp = response_builder.body(self.bytes().await?)?; debug!("[*] Response: {:?}", new_resp); @@ -68,6 +116,42 @@ impl LoggingReqwestResponseHandler for reqwest::Response { /// Extension to [`reqwest::Response`] allowing for returning more-verbose error /// on deserialization failure +/// +/// # Examples +/// +/// ``` +/// use xal::Error; +/// use xal::extensions::JsonExDeserializeMiddleware; +/// use serde::Deserialize; +/// use reqwest::Client; +/// +/// #[derive(Debug, Deserialize)] +/// struct DemoStruct { +/// pub some_key: String, +/// } +/// +/// async fn demo_json_deserialize_ex() -> Result<(), Error> { +/// // Return a detailed error in case the JSON Deserialization fails +/// let result = Client::new() +/// .get("https://example.com") +/// .send() +/// .await? +/// .json_ex::() +/// .await; +/// +/// match result { +/// Err(Error::JsonHttpResponseError{status,url,headers,body,inner}) => { +/// eprintln!( +/// "Failed deserializing body into struct! +/// {status:?} {url:?} {headers:?} {body:?} {inner:?}" +/// ); +/// }, +/// _ => {} +/// }; +/// +/// Ok(()) +/// } +/// ``` #[async_trait] pub trait JsonExDeserializeMiddleware { /// Deserialize JSON response into struct implementing [`serde::de::DeserializeOwned`] @@ -98,6 +182,30 @@ impl JsonExDeserializeMiddleware for reqwest::Response { } /// Extension to [`reqwest::RequestBuilder`] for signing HTTP requests according to Xbox Live specs +/// +/// # Examples +/// +/// ``` +/// use xal::{RequestSigner, Error}; +/// use xal::extensions::SigningReqwestBuilder; +/// use reqwest::Client; +/// use serde_json::json; +/// +/// async fn demo_sign_request() -> Result<(), Error> { +/// // Construct request signer +/// let mut request_signer = RequestSigner::new(); +/// +/// let response = Client::new() +/// .post("https://example.xboxlive.com") +/// .json(&json!({"some": "value"})) +/// .sign(&mut request_signer, None) +/// .await? +/// .send() +/// .await?; +/// +/// Ok(()) +/// } +/// ``` #[async_trait] pub trait SigningReqwestBuilder { /// Sign HTTP request for Xbox Live @@ -138,6 +246,28 @@ impl SigningReqwestBuilder for reqwest::RequestBuilder { } /// Extension to [`reqwest::RequestBuilder`] for adding [`cvlib::CorrelationVector`] to request headers +/// +/// # Examples +/// +/// ``` +/// use xal::Error; +/// use xal::cvlib::CorrelationVector; +/// use xal::extensions::CorrelationVectorReqwestBuilder; +/// use reqwest::Client; +/// +/// async fn demo_cv_request() -> Result<(), Error> { +/// // Construct correlation vector +/// let mut cv = CorrelationVector::new(); +/// +/// let response = Client::new() +/// .post("https://example.xboxlive.com") +/// .add_cv(&mut cv)? +/// .send() +/// .await?; +/// +/// Ok(()) +/// } +/// ``` pub trait CorrelationVectorReqwestBuilder { /// Add HTTP header `MS-CV` into headers fn add_cv(self, cv: &mut CorrelationVector) -> Result; diff --git a/src/flows.rs b/src/flows.rs index 85aa4e5..236fec6 100644 --- a/src/flows.rs +++ b/src/flows.rs @@ -183,6 +183,27 @@ pub trait AuthPromptCallback { } /// Implementation of a cli callback handler +/// +/// # Examples +/// +/// Using the [`CliCallbackHandler`] will prompt the user via commandline for an action. +/// e.g. Browsing to an authentication URL and pasting back the redirect URL incl. authorization data. +/// +/// ```no_run +/// use xal::{XalAuthenticator, Flows, Error, CliCallbackHandler}; +/// +/// # async fn example() -> Result<(), Error> { +/// let mut authenticator = XalAuthenticator::default(); +/// +/// let token_store = Flows::xbox_live_sisu_full_flow( +/// &mut authenticator, +/// CliCallbackHandler, +/// ) +/// .await?; +/// +/// # Ok(()) +/// # } +/// ``` pub struct CliCallbackHandler; #[async_trait] @@ -224,7 +245,7 @@ impl Flows { /// /// # Examples /// - /// ``` + /// ```no_run /// # use xal::{Error, Flows, TokenStore}; /// /// # async fn demo_code() -> Result<(), Error> { @@ -259,7 +280,7 @@ impl Flows { /// /// # Examples /// - /// ``` + /// ```no_run /// use std::fs::File; /// use serde_json; /// use xal::{Flows, TokenStore}; @@ -306,14 +327,13 @@ impl Flows { /// /// # Examples /// - /// ``` + /// ```no_run /// use xal::{XalAuthenticator, Flows, Error, AccessTokenPrefix, CliCallbackHandler}; /// use xal::response::WindowsLiveTokens; /// /// # async fn async_sleep_fn(_: std::time::Duration) {} /// /// # async fn example() -> Result<(), Error> { - /// let do_implicit_flow = true; /// let mut authenticator = XalAuthenticator::default(); /// /// let token_store = Flows::ms_device_code_flow( @@ -375,7 +395,7 @@ impl Flows { /// /// # Examples /// - /// ``` + /// ```no_run /// use xal::{XalAuthenticator, Flows, Error, AccessTokenPrefix, CliCallbackHandler}; /// use xal::response::WindowsLiveTokens; /// @@ -452,9 +472,8 @@ impl Flows { /// /// # Examples /// - /// ``` - /// use xal::{XalAuthenticator, Flows, Error, AccessTokenPrefix, CliCallbackHandler}; - /// use xal::response::WindowsLiveTokens; + /// ```no_run + /// use xal::{XalAuthenticator, Flows, Error, CliCallbackHandler}; /// /// # async fn example() -> Result<(), Error> { /// let mut authenticator = XalAuthenticator::default(); @@ -569,19 +588,17 @@ impl Flows { /// /// # Examples /// - /// ``` + /// ```no_run /// use xal::{XalAuthenticator, Flows, CliCallbackHandler, Error, AccessTokenPrefix}; /// use xal::response::WindowsLiveTokens; /// - /// # async fn async_sleep_fn(_: std::time::Duration) {} - /// /// # async fn example() -> Result<(), Error> { /// let mut authenticator = XalAuthenticator::default(); /// /// let token_store = Flows::ms_device_code_flow( /// &mut authenticator, /// CliCallbackHandler, - /// async_sleep_fn + /// tokio::time::sleep /// ) /// .await?; /// @@ -667,19 +684,17 @@ impl Flows { /// /// # Examples /// - /// ``` + /// ```no_run /// use xal::{XalAuthenticator, Flows, CliCallbackHandler, Error, AccessTokenPrefix}; /// use xal::response::WindowsLiveTokens; /// - /// # async fn async_sleep_fn(_: std::time::Duration) {} - /// /// # async fn example() -> Result<(), Error> { /// let mut authenticator = XalAuthenticator::default(); /// /// let token_store = Flows::ms_device_code_flow( /// &mut authenticator, /// CliCallbackHandler, - /// async_sleep_fn + /// tokio::time::sleep /// ) /// .await?; /// diff --git a/src/lib.rs b/src/lib.rs index c47ed2d..90fa309 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,13 +2,185 @@ #![deny(rustdoc::broken_intra_doc_links)] //! XAL - Xbox Live Authentication Library for Rust //! +//! This library aims at giving a high range of configurability to the user, so that authentication +//! can be targeted inidividually for each required scenario. +//! //! Features: -//! - OAuth2 Authentication via SISU App-Flow - see [`crate::XalAuthenticator`] -//! - HTTP Request Signing - see [`crate::RequestSigner`] +//! - (Lower level) OAuth2 Authentication - see [`crate::XalAuthenticator`] +//! - (Higher level) Authentication flows - see [`crate::Flows`] +//! - (Standalone) HTTP Request Signing - see [`crate::RequestSigner`] +//! - Container for storing tokens / authentication parameters - see [`crate::TokenStore`] //! - Extensions for Reqwest HTTP client library - see [`crate::extensions`] +//! - Verbose errors for JSON deserialization - see [`crate::extensions::JsonExDeserializeMiddleware`] +//! - Debug logging for HTTP requests - see [`crate::extensions::LoggingReqwestRequestHandler`] +//! - Debug logging for HTTP responses - see [`crate::extensions::LoggingReqwestResponseHandler`] +//! - Signing HTTP requests - see [`crate::extensions::SigningReqwestBuilder`] +//! - Adding `MS-CV` header to requests - see [`crate::extensions::CorrelationVectorReqwestBuilder`] +//! +//! # Quick Start +//! +//! Authenticate and save tokens to JSON file `tokens.json` +//! +//! ```no_run +//! use xal::{XalAuthenticator, Flows, CliCallbackHandler}; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! let mut authenticator = XalAuthenticator::default(); +//! +//! // Do full SISU auth flow +//! let mut token_store = Flows::xbox_live_sisu_full_flow( +//! &mut authenticator, +//! CliCallbackHandler +//! ).await?; +//! +//! // User will be prompted on commandline to proceed with authentication +//! +//! token_store.update_timestamp(); +//! token_store.save_to_file("tokens.json")?; +//! +//! Ok(()) +//! } +//! ``` +//! +//! Load tokens from file and refresh them +//! +//! ```no_run +//! use xal::{XalAuthenticator, Flows, CliCallbackHandler}; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! println!("Trying to refresh tokens..."); +//! let mut token_store = match Flows::try_refresh_live_tokens_from_file("tokens.json").await { +//! Ok((mut authenticator, ts)) => { +//! println!("Tokens refreshed succesfully, proceeding with Xbox Live Authorization"); +//! Flows::xbox_live_sisu_authorization_flow(&mut authenticator, ts.live_token) +//! .await? +//! }, +//! Err(err) => { +//! eprintln!("Refreshing tokens failed err={err}"); +//! let mut authenticator = XalAuthenticator::default(); +//! println!("Authentication via SISU"); +//! Flows::xbox_live_sisu_full_flow(&mut authenticator, CliCallbackHandler) +//! .await? +//! } +//! }; +//! +//! token_store.update_timestamp(); +//! token_store.save_to_file("tokens.json")?; +//! Ok(()) +//! } +//! ``` +//! +//! Make use of acquired XSTS token +//! +//! ```no_run +//! use xal::{XalAuthenticator, Flows, CliCallbackHandler}; +//! use xal::extensions::JsonExDeserializeMiddleware; +//! use xal::oauth2::TokenResponse; +//! use serde_json::json; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! // Create an authenticator with minecraft client parameters +//! let mut authenticator = XalAuthenticator::new( +//! xal::app_params::MC_BEDROCK_SWITCH(), +//! xal::client_params::CLIENT_NINTENDO(), +//! "RETAIL".into(), +//! ); +//! +//! // Do full SISU authentication flow +//! let mut token_store = Flows::xbox_live_sisu_full_flow( +//! &mut authenticator, +//! CliCallbackHandler +//! ).await?; +//! +//! // Authorize to XSTS endpoint via Minecraft RelyingParty +//! let xsts_mc_services = authenticator +//! .get_xsts_token( +//! token_store.device_token.as_ref(), +//! token_store.title_token.as_ref(), +//! token_store.user_token.as_ref(), +//! "rp://api.minecraftservices.com/" +//! ) +//! .await?; +//! +//! let identity_token = xsts_mc_services.authorization_header_value(); +//! println!("identityToken: {identity_token}"); +//! +//! /* Minecraft stuff */ +//! // Exchange XSTS Token against Minecraft Token +//! let mc_token = reqwest::Client::new() +//! .post("https://api.minecraftservices.com/authentication/login_with_xbox") +//! .json(&json!({"identityToken": identity_token})) +//! .send() +//! .await? +//! .json_ex::() +//! .await?; +//! println!("MC: {mc_token:?}"); +//! +//! // Get minecraft profile, use Minecraft Token as Bearer Auth +//! let profile = reqwest::Client::new() +//! .get("https://api.minecraftservices.com/minecraft/profile") +//! .bearer_auth(mc_token.access_token().secret()) +//! .send() +//! .await? +//! .text() +//! .await?; +//! println!("Profile: {profile}"); +//! Ok(()) +//! } +//! ``` +//! +//! Loading tokens from file and sending a signed a request +//! +//! ```no_run +//! use xal::{ +//! RequestSigner, TokenStore, Error, +//! extensions::{ +//! SigningReqwestBuilder, +//! CorrelationVectorReqwestBuilder, +//! +//! }, +//! cvlib::CorrelationVector, +//! }; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! // Load tokens from JSON +//! let token_store = TokenStore::load_from_file("tokens.json")?; +//! +//! // Create new instances of Correlation vector and request signer +//! let mut cv = CorrelationVector::new(); +//! let mut signer = RequestSigner::new(); +//! +//! // Check if XSTS token exists +//! let xsts_token = token_store.authorization_token +//! .ok_or(Error::GeneralError("No XSTS token was acquired".into()))?; +//! +//! // Send a http request +//! // Request will get signed and MS-CV header populated +//! let userpresence = reqwest::Client::new() +//! .get("https://userpresence.xboxlive.com/users/me?level=all") +//! .header("x-xbl-contract-version", "3") +//! .header("Authorization", xsts_token.authorization_header_value()) +//! .add_cv(&mut cv)? +//! .sign(&mut signer, None) +//! .await? +//! .send() +//! .await?; +//! +//! println!("{:?}", userpresence); +//! Ok(()) +//! } +//! ``` //! //! # Examples //! +//! Check out the [xal-examples](https://github.com/OpenXbox/xal-rs/tree/master/examples). +//! +//! # Advanced +//! //! For advanced usage, see [`crate::XalAuthenticator`]. pub use cvlib; diff --git a/src/models.rs b/src/models.rs index 49df472..df2e131 100644 --- a/src/models.rs +++ b/src/models.rs @@ -25,6 +25,19 @@ pub struct ProofKey { impl ProofKey { /// Create new instance of proof key + /// + /// # Examples + /// + /// ``` + /// use xal::ProofKey; + /// use p256::SecretKey; + /// + /// let secret_key = SecretKey::random(&mut rand::thread_rng()); + /// let proof_key = ProofKey::new(&secret_key); + /// + /// let serialized = serde_json::to_string(&proof_key).unwrap(); + /// println!("{serialized}"); + /// ``` pub fn new(key: &SecretKey) -> Self { let point = key.public_key().to_encoded_point(false); Self { @@ -477,7 +490,7 @@ impl ToString for DeviceType { /// XAL App parameters /// /// Mandatory for XAL authentication flow -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] pub struct XalAppParameters { /// App Id (For authorization/permission scope) pub app_id: String, @@ -492,6 +505,21 @@ pub struct XalAppParameters { /// Application parameter constants /// /// Used for instantiating [`crate::XalAuthenticator`] +/// +/// # Examples +/// +/// ``` +/// use xal::{XalAuthenticator, XalClientParameters, app_params}; +/// +/// let mut authenticator = XalAuthenticator::new( +/// app_params::APP_GAMEPASS_BETA(), +/// XalClientParameters::default(), +/// "RETAIL".into() +/// ); +/// +/// assert_eq!(authenticator.app_params(), app_params::APP_GAMEPASS_BETA()); +/// assert_ne!(authenticator.app_params(), app_params::APP_XBOX_BETA()); +/// ``` #[allow(non_snake_case)] pub mod app_params { use oauth2::{RedirectUrl, Scope}; @@ -652,7 +680,7 @@ impl Default for XalAppParameters { /// XAL Client parameters /// /// Metadata from the client which attempts authentication -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] pub struct XalClientParameters { /// HTTP User Agent pub user_agent: String, @@ -667,6 +695,22 @@ pub struct XalClientParameters { /// Client parameter constants /// /// Used for instantiating [`crate::XalAuthenticator`] +/// +/// +/// # Examples +/// +/// ``` +/// use xal::{XalAuthenticator, XalAppParameters, client_params}; +/// +/// let mut authenticator = XalAuthenticator::new( +/// XalAppParameters::default(), +/// client_params::CLIENT_ANDROID(), +/// "RETAIL".into() +/// ); +/// +/// assert_eq!(authenticator.client_params(), client_params::CLIENT_ANDROID()); +/// assert_ne!(authenticator.client_params(), client_params::CLIENT_IOS()); +/// ``` #[allow(non_snake_case)] pub mod client_params { use super::{DeviceType, XalClientParameters}; diff --git a/src/request_signer.rs b/src/request_signer.rs index a2e8718..f702b2c 100644 --- a/src/request_signer.rs +++ b/src/request_signer.rs @@ -11,7 +11,7 @@ use crate::{ use async_trait::async_trait; use base64ct::{self, Base64, Encoding}; use chrono::prelude::*; -use log::{info, warn}; +use log::{debug, info, warn}; use nt_time::FileTime; use p256::{ ecdsa::{ @@ -190,6 +190,8 @@ impl TryFrom>> for HttpMessageToSign { /// Request signer /// /// Calculates the `Signature` header for certain Xbox Live HTTP request +/// +/// Use the [`crate::extensions::SigningReqwestBuilder`] for signing HTTP requests more comfortably. #[derive(Debug, Clone)] pub struct RequestSigner { /// Elliptic curve keypair @@ -335,6 +337,14 @@ impl RequestSigner { } /// Returns the proof key as JWK + /// + /// # Examples + /// + /// ``` + /// # use xal::RequestSigner; + /// let proof_key = RequestSigner::new().get_proof_key(); + /// let proof_key_json = serde_json::to_string(&proof_key); + /// ``` pub fn get_proof_key(&self) -> ProofKey { ProofKey::new(&self.keypair) } @@ -489,16 +499,24 @@ pub async fn get_endpoints() -> Result /// Signature policy cache /// /// -#[derive(Debug, Serialize, Deserialize, Clone)] -#[derive(Default)] +#[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct SignaturePolicyCache { endpoints: Option, } - - impl SignaturePolicyCache { /// Create a new SignaturePolicyCache. + /// + /// # Examples + /// + /// ``` + /// use xal::{SignaturePolicyCache, get_endpoints}; + /// + /// # tokio_test::block_on(async { + /// let endpoints = get_endpoints().await.unwrap(); + /// let policy_cache = SignaturePolicyCache::new(endpoints); + /// # }) + /// ``` pub fn new(endpoints: TitleEndpointsResponse) -> Self { Self { endpoints: Some(endpoints), @@ -513,6 +531,27 @@ impl SignaturePolicyCache { /// Find the policy for the given URL. /// /// If a matching policy is found, returns the corresponding SigningPolicy. Otherwise, returns None. + /// + /// # Examples + /// + /// ``` + /// # use xal::SignaturePolicyCache; + /// # tokio_test::block_on(async { + /// let mut policy_cache = SignaturePolicyCache::default(); + /// # let endpoints = serde_json::from_str(include_str!("../testdata/title_endpoints.json")).unwrap(); + /// # let mut policy_cache = SignaturePolicyCache::new(endpoints); + /// + /// let policy = policy_cache.find_policy_for_url("https://example.xboxlive.com") + /// .await + /// .unwrap(); + /// assert!(policy.is_some()); + /// + /// let policy_not_found = policy_cache.find_policy_for_url("https://example.com") + /// .await + /// .unwrap(); + /// assert!(policy_not_found.is_none()); + /// # }) + /// ``` pub async fn find_policy_for_url(&mut self, url: &str) -> Result, Error> { let url = url::Url::parse(url)?; @@ -555,7 +594,7 @@ impl SignaturePolicyCache { match matching_endpoint { Some(ep) => { - println!("Identified Title endpoint={ep:?} for URL={url} {url:?}"); + debug!("Identified Title endpoint={ep:?} for URL={url} {url:?}"); let policy_index = ep.signature_policy_index.unwrap() as usize; let policy = endpoints diff --git a/src/tokenstore.rs b/src/tokenstore.rs index f2929f0..786de34 100644 --- a/src/tokenstore.rs +++ b/src/tokenstore.rs @@ -58,7 +58,20 @@ impl TokenStore { /// Load a tokenstore from a file by providing the filename/path to the /// serialized JSON /// - /// Returns the json string if possible + /// Returns the TokenStore instance on success + /// + /// # Examples + /// + /// Load tokenstore from file + /// + /// ``` + /// # use xal::TokenStore; + /// # fn demo() -> Result<(), xal::Error> { + /// let tokenstore = TokenStore::load_from_file("tokens.json")?; + /// # Ok(()) + /// # } + /// // refresh tokens etc. .. + /// ``` pub fn load_from_file(filepath: &str) -> Result { trace!("Trying to load tokens from filepath={:?}", filepath); let mut file = std::fs::File::options().read(true).open(filepath)?; @@ -69,13 +82,38 @@ impl TokenStore { Self::deserialize_from_string(&json) } - /// Load tokens from file + /// Load tokens from JSON string + /// + /// # Examples + /// + /// ``` + /// # use xal::TokenStore; + /// # fn demo() -> Result<(), xal::Error> { + /// let tokens_json = std::fs::read_to_string("tokens.json")?; + /// let tokenstore = TokenStore::deserialize_from_string(&tokens_json)?; + /// # Ok(()) + /// # } + /// // refresh tokens etc. .. + /// ``` pub fn deserialize_from_string(json: &str) -> Result { trace!("Attempting to deserialize token data"); serde_json::from_str(json).map_err(std::convert::Into::into) } /// Save tokens to writer + /// + /// # Examples + /// + /// ``` + /// # use xal::TokenStore; + /// # fn demo() -> Result<(), xal::Error> { + /// let tokenstore = TokenStore::load_from_file("tokens.json")?; + /// // refresh tokens ... + /// let file = std::fs::File::create("tokens.json")?; + /// tokenstore.save_to_writer(&file).ok(); + /// # Ok(()) + /// # } + /// ``` pub fn save_to_writer(&self, writer: impl std::io::Write) -> Result<(), Error> { serde_json::to_writer_pretty(writer, self).map_err(std::convert::Into::into) } @@ -83,6 +121,18 @@ impl TokenStore { /// Save the tokens to a JSON file /// /// NOTE: If the file already exists it will be overwritten + /// + /// # Examples + /// + /// ``` + /// # use xal::TokenStore; + /// # fn demo() -> Result<(), xal::Error> { + /// let tokenstore = TokenStore::load_from_file("tokens.json")?; + /// // refresh tokens ... + /// tokenstore.save_to_file("tokens.json").ok(); + /// # Ok(()) + /// # } + /// ``` pub fn save_to_file(&self, filepath: &str) -> Result<(), Error> { trace!( "Trying to open tokenfile read/write/create path={:?}", @@ -102,6 +152,19 @@ impl TokenStore { } /// Update the timestamp of this instance + /// + /// # Examples + /// + /// ``` + /// # use xal::TokenStore; + /// # fn demo() -> Result<(), xal::Error> { + /// let mut tokenstore = TokenStore::load_from_file("tokens.json")?; + /// // refresh tokens ... + /// tokenstore.update_timestamp(); + /// tokenstore.save_to_file("tokens.json").ok(); + /// # Ok(()) + /// # } + /// ``` pub fn update_timestamp(&mut self) { self.updated = Some(chrono::offset::Utc::now()); } @@ -109,6 +172,7 @@ impl TokenStore { #[cfg(test)] mod tests { + use oauth2::TokenResponse; use rand::distributions::{Alphanumeric, DistString}; use super::*; @@ -123,4 +187,40 @@ mod tests { assert!(res.is_err()); } + + #[test] + fn read_from_string() { + let tokens_str = r#"{"app_params":{"app_id":"00000000441cc96b","title_id":"42","auth_scopes":["service::user.auth.xboxlive.com::MBI_SSL"],"redirect_uri":"https://login.live.com/oauth20_desktop.srf"},"client_params":{"user_agent":"XAL","device_type":"NINTENDO","client_version":"0.0.0","query_display":"touch"},"sandbox_id":"RETAIL","live_token":{"access_token":"accessTokenABC","token_type":"bearer","expires_in":86400,"refresh_token":"refreshTokenABC","scope":"service::user.auth.xboxlive.com::MBI_SSL"}}"#; + let ts = TokenStore::deserialize_from_string(tokens_str).unwrap(); + + assert_eq!(ts.app_params.app_id, "00000000441cc96b"); + assert_eq!(ts.app_params.title_id, Some("42".into())); + assert_eq!( + ts.app_params.auth_scopes.first().unwrap().as_str(), + "service::user.auth.xboxlive.com::MBI_SSL" + ); + assert_eq!( + ts.app_params.redirect_uri.unwrap().as_str(), + "https://login.live.com/oauth20_desktop.srf" + ); + + assert_eq!(ts.client_params.client_version, "0.0.0"); + assert_eq!(ts.client_params.device_type.to_string(), "Nintendo"); + assert_eq!(ts.client_params.query_display, "touch"); + assert_eq!(ts.client_params.user_agent, "XAL"); + + assert_eq!(ts.live_token.access_token().secret(), "accessTokenABC"); + assert_eq!( + ts.live_token.refresh_token().unwrap().secret(), + "refreshTokenABC" + ); + assert_eq!( + ts.live_token.expires_in().unwrap(), + std::time::Duration::from_secs(86400) + ); + assert_eq!( + ts.live_token.scopes().unwrap().first().unwrap().as_str(), + "service::user.auth.xboxlive.com::MBI_SSL" + ); + } } From c964c4fbfe5251743b8b3595918d2fe333784b58 Mon Sep 17 00:00:00 2001 From: tuxuser <462620+tuxuser@users.noreply.github.com> Date: Wed, 20 Dec 2023 23:15:17 +0100 Subject: [PATCH 13/13] readme: Add note about examples --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 87f58c3..6ac512a 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ Authenticate with Xbox Live Find the documentation here: +## Examples + +Check out [xal-examples](./examples/) + ## Minimum supported Rust version This crate requires at least Rust 1.70 (stable).