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] 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