diff options
| author | Adam Reisenauer <58893124+Mythbusters123@users.noreply.github.com> | 2023-06-24 18:09:43 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-06-24 17:09:43 -0500 |
| commit | 5e4699688207b8ac722ae7f96c49428242f49a9d (patch) | |
| tree | 52b0f9b8507fa380376dab81411cb74eed413c95 | |
| parent | fe687f9bdbdf3e0214ac4ac6da47a181e4dc23dd (diff) | |
| download | azalea-drasl-5e4699688207b8ac722ae7f96c49428242f49a9d.tar.xz | |
Add functions `auth_with_link_code`, `get_ms_link_code`, and `get_ms_auth_token`. (#88)
* Add option for grabbing authentication code for Microsoft seperately. Created two new functions, one that outputs the DeviceCodeResponse and one that uses this response to authenticate an actual account.
* Added documentation and cleaned up function names. Still wondering about code repeition
* reduce code duplication, more docs, cleanup
* clippy
---------
Co-authored-by: mat <git@matdoes.dev>
| -rw-r--r-- | Cargo.lock | 1 | ||||
| -rwxr-xr-x | azalea-auth/examples/auth_manual.rs | 32 | ||||
| -rwxr-xr-x | azalea-auth/src/auth.rs | 220 | ||||
| -rwxr-xr-x | azalea-auth/src/cache.rs | 9 | ||||
| -rwxr-xr-x | azalea-auth/src/lib.rs | 2 | ||||
| -rw-r--r-- | azalea-client/Cargo.toml | 1 | ||||
| -rwxr-xr-x | azalea-client/src/account.rs | 89 | ||||
| -rw-r--r-- | azalea-client/src/inventory.rs | 2 | ||||
| -rw-r--r-- | azalea-inventory/azalea-inventory-macros/src/menu_impl.rs | 1 |
9 files changed, 268 insertions, 89 deletions
@@ -309,6 +309,7 @@ dependencies = [ "once_cell", "parking_lot", "regex", + "reqwest", "thiserror", "tokio", "uuid", diff --git a/azalea-auth/examples/auth_manual.rs b/azalea-auth/examples/auth_manual.rs new file mode 100755 index 00000000..6a9510a8 --- /dev/null +++ b/azalea-auth/examples/auth_manual.rs @@ -0,0 +1,32 @@ +//! Authenticate with Microsoft and get a Minecraft profile, but don't cache and +//! use our own code to display the link code. +//! +//! If you still want it to cache, look at the code in [`azalea_auth::auth`] and +//! see how that does it. + +use std::error::Error; + +use azalea_auth::ProfileResponse; + +#[tokio::main] +async fn main() -> Result<(), Box<dyn Error>> { + env_logger::init(); + + let profile = auth().await?; + println!("Logged in as {}", profile.name); + + Ok(()) +} + +async fn auth() -> Result<ProfileResponse, Box<dyn Error>> { + let client = reqwest::Client::new(); + + let res = azalea_auth::get_ms_link_code(&client).await?; + println!( + "Go to {} and enter the code {}", + res.verification_uri, res.user_code + ); + let msa = azalea_auth::get_ms_auth_token(&client, res).await?; + let auth_result = azalea_auth::get_minecraft_token(&client, &msa.data.access_token).await?; + Ok(azalea_auth::get_profile(&client, &auth_result.minecraft_access_token).await?) +} diff --git a/azalea-auth/src/auth.rs b/azalea-auth/src/auth.rs index bb72c8c4..b0b30e1f 100755 --- a/azalea-auth/src/auth.rs +++ b/azalea-auth/src/auth.rs @@ -55,6 +55,10 @@ pub enum AuthError { /// The email is technically only used as a cache key, so it *could* be /// anything. You should just have it be the actual email so it's not confusing /// though, and in case the Microsoft API does start providing the real email. +/// +/// If you want to use your own code to cache or show the auth code to the user +/// in a different way, use [`get_ms_link_code`], [`get_ms_auth_token`], +/// [`get_minecraft_token`] and [`get_profile`] instead instead. pub async fn auth(email: &str, opts: AuthOpts) -> Result<AuthResult, AuthError> { let cached_account = if let Some(cache_file) = &opts.cache_file { cache::get_account_in_cache(cache_file, email).await @@ -62,16 +66,15 @@ pub async fn auth(email: &str, opts: AuthOpts) -> Result<AuthResult, AuthError> None }; - // these two MUST be set by the end, since we return them in AuthResult - let profile: ProfileResponse; - let minecraft_access_token: String; - if cached_account.is_some() && !cached_account.as_ref().unwrap().mca.is_expired() { let account = cached_account.as_ref().unwrap(); // the minecraft auth data is cached and not expired, so we can just // use that instead of doing auth all over again :) - profile = account.profile.clone(); - minecraft_access_token = account.mca.data.access_token.clone(); + + Ok(AuthResult { + access_token: account.mca.data.access_token.clone(), + profile: account.profile.clone(), + }) } else { let client = reqwest::Client::new(); let mut msa = if let Some(account) = cached_account { @@ -83,37 +86,20 @@ pub async fn auth(email: &str, opts: AuthOpts) -> Result<AuthResult, AuthError> log::trace!("refreshing Microsoft auth token"); msa = refresh_ms_auth_token(&client, &msa.data.refresh_token).await?; } - let ms_access_token = &msa.data.access_token; - log::trace!("Got access token: {}", ms_access_token); - - let xbl_auth = auth_with_xbox_live(&client, ms_access_token).await?; - - let xsts_token = obtain_xsts_for_minecraft( - &client, - &xbl_auth - .get() - .expect("Xbox Live auth token shouldn't have expired yet") - .token, - ) - .await?; - // Minecraft auth - let mca = auth_with_minecraft(&client, &xbl_auth.data.user_hash, &xsts_token).await?; + let msa_token = &msa.data.access_token; + log::trace!("Got access token: {msa_token}"); - minecraft_access_token = mca - .get() - .expect("Minecraft auth shouldn't have expired yet") - .access_token - .to_string(); + let res = get_minecraft_token(&client, msa_token).await?; if opts.check_ownership { - let has_game = check_ownership(&client, &minecraft_access_token).await?; + let has_game = check_ownership(&client, &res.minecraft_access_token).await?; if !has_game { return Err(AuthError::DoesNotOwnGame); } } - profile = get_profile(&client, &minecraft_access_token).await?; + let profile: ProfileResponse = get_profile(&client, &res.minecraft_access_token).await?; if let Some(cache_file) = opts.cache_file { if let Err(e) = cache::set_account_in_cache( @@ -121,9 +107,9 @@ pub async fn auth(email: &str, opts: AuthOpts) -> Result<AuthResult, AuthError> email, CachedAccount { email: email.to_string(), - mca, + mca: res.mca, msa, - xbl: xbl_auth, + xbl: res.xbl, profile: profile.clone(), }, ) @@ -132,15 +118,60 @@ pub async fn auth(email: &str, opts: AuthOpts) -> Result<AuthResult, AuthError> log::error!("{}", e); } } + + Ok(AuthResult { + access_token: res.minecraft_access_token, + profile, + }) } +} + +/// Authenticate with Minecraft when we already have a Microsoft auth token. +/// +/// Usually you don't need this since [`auth`] will call it for you, but it's +/// useful if you want more control over what it does. +/// +/// If you don't have a Microsoft auth token, you can get it from +/// [`get_ms_link_code`] and then [`get_ms_auth_token`]. +pub async fn get_minecraft_token( + client: &reqwest::Client, + msa: &str, +) -> Result<MinecraftTokenResponse, AuthError> { + let xbl_auth = auth_with_xbox_live(client, msa).await?; - Ok(AuthResult { - access_token: minecraft_access_token, - profile, + let xsts_token = obtain_xsts_for_minecraft( + client, + &xbl_auth + .get() + .expect("Xbox Live auth token shouldn't have expired yet") + .token, + ) + .await?; + + // Minecraft auth + let mca = auth_with_minecraft(client, &xbl_auth.data.user_hash, &xsts_token).await?; + + let minecraft_access_token: String = mca + .get() + .expect("Minecraft auth shouldn't have expired yet") + .access_token + .to_string(); + + Ok(MinecraftTokenResponse { + mca, + xbl: xbl_auth, + minecraft_access_token, }) } #[derive(Debug)] +pub struct MinecraftTokenResponse { + pub mca: ExpiringValue<MinecraftAuthResponse>, + pub xbl: ExpiringValue<XboxLiveAuth>, + pub minecraft_access_token: String, +} + +#[derive(Debug)] pub struct AuthResult { pub access_token: String, pub profile: ProfileResponse, @@ -148,64 +179,63 @@ pub struct AuthResult { #[derive(Debug, Deserialize)] pub struct DeviceCodeResponse { - user_code: String, - device_code: String, - verification_uri: String, - expires_in: u64, - interval: u64, + pub user_code: String, + pub device_code: String, + pub verification_uri: String, + pub expires_in: u64, + pub interval: u64, } #[allow(unused)] -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, Clone)] pub struct AccessTokenResponse { - token_type: String, - expires_in: u64, - scope: String, - access_token: String, - refresh_token: String, - user_id: String, + pub token_type: String, + pub expires_in: u64, + pub scope: String, + pub access_token: String, + pub refresh_token: String, + pub user_id: String, } #[allow(unused)] #[derive(Debug, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct XboxLiveAuthResponse { - issue_instant: String, - not_after: String, - token: String, - display_claims: HashMap<String, Vec<HashMap<String, String>>>, + pub issue_instant: String, + pub not_after: String, + pub token: String, + pub display_claims: HashMap<String, Vec<HashMap<String, String>>>, } /// Just the important data #[derive(Serialize, Deserialize, Debug)] pub struct XboxLiveAuth { - token: String, - user_hash: String, + pub token: String, + pub user_hash: String, } #[allow(unused)] #[derive(Debug, Deserialize, Serialize)] pub struct MinecraftAuthResponse { - username: String, - roles: Vec<String>, - access_token: String, - token_type: String, - expires_in: u64, + pub username: String, + pub roles: Vec<String>, + pub access_token: String, + pub token_type: String, + pub expires_in: u64, } -#[allow(unused)] #[derive(Debug, Deserialize)] pub struct GameOwnershipResponse { - items: Vec<GameOwnershipItem>, - signature: String, - key_id: String, + pub items: Vec<GameOwnershipItem>, + pub signature: String, + pub key_id: String, } #[allow(unused)] #[derive(Debug, Deserialize)] pub struct GameOwnershipItem { - name: String, - signature: String, + pub name: String, + pub signature: String, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -227,12 +257,33 @@ pub enum GetMicrosoftAuthTokenError { Timeout, } -/// Asks the user to go to a webpage and log in with Microsoft. -async fn interactive_get_ms_auth_token( +/// Get the Microsoft link code that's shown to the user for logging into +/// Microsoft. +/// +/// You should call [`get_ms_auth_token`] right after showing the user the +/// [`verification_uri`](DeviceCodeResponse::verification_uri) and +/// [`user_code`](DeviceCodeResponse::user_code). +/// +/// If showing the link code in the terminal is acceptable, then you can just +/// use [`interactive_get_ms_auth_token`] instead. +/// +/// ``` +/// # async fn example(client: &reqwest::Client) -> Result<(), Box<dyn std::error::Error>> { +/// let res = azalea_auth::get_ms_link_code(&client).await?; +/// println!( +/// "Go to {} and enter the code {}", +/// res.verification_uri, res.user_code +/// ); +/// let msa = azalea_auth::get_ms_auth_token(client, res).await?; +/// let minecraft = azalea_auth::get_minecraft_token(client, &msa.data.access_token).await?; +/// let profile = azalea_auth::get_profile(&client, &minecraft.minecraft_access_token).await?; +/// # Ok(()) +/// # } +/// ``` +pub async fn get_ms_link_code( client: &reqwest::Client, - email: &str, -) -> Result<ExpiringValue<AccessTokenResponse>, GetMicrosoftAuthTokenError> { - let res = client +) -> Result<DeviceCodeResponse, GetMicrosoftAuthTokenError> { + Ok(client .post("https://login.live.com/oauth20_connect.srf") .form(&vec![ ("scope", "service::user.auth.xboxlive.com::MBI_SSL"), @@ -242,13 +293,17 @@ async fn interactive_get_ms_auth_token( .send() .await? .json::<DeviceCodeResponse>() - .await?; - log::trace!("Device code response: {:?}", res); - println!( - "Go to \x1b[1m{}\x1b[m and enter the code \x1b[1m{}\x1b[m for \x1b[1m{}\x1b[m", - res.verification_uri, res.user_code, email - ); + .await?) +} +/// Wait until the user logged into Microsoft with the given code. You get the +/// device code response needed for this function from [`get_ms_link_code`]. +/// +/// You should pass the response from this to [`get_minecraft_token`]. +pub async fn get_ms_auth_token( + client: &reqwest::Client, + res: DeviceCodeResponse, +) -> Result<ExpiringValue<AccessTokenResponse>, GetMicrosoftAuthTokenError> { let login_expires_at = Instant::now() + std::time::Duration::from_secs(res.expires_in); while Instant::now() < login_expires_at { @@ -285,13 +340,30 @@ async fn interactive_get_ms_auth_token( Err(GetMicrosoftAuthTokenError::Timeout) } +/// Asks the user to go to a webpage and log in with Microsoft. If you need to +/// access the code, then use [`get_ms_link_code`] and then +/// [`get_ms_auth_token`] instead. +pub async fn interactive_get_ms_auth_token( + client: &reqwest::Client, + email: &str, +) -> Result<ExpiringValue<AccessTokenResponse>, GetMicrosoftAuthTokenError> { + let res = get_ms_link_code(client).await?; + log::trace!("Device code response: {:?}", res); + println!( + "Go to \x1b[1m{}\x1b[m and enter the code \x1b[1m{}\x1b[m for \x1b[1m{}\x1b[m", + res.verification_uri, res.user_code, email + ); + + get_ms_auth_token(client, res).await +} + #[derive(Debug, Error)] pub enum RefreshMicrosoftAuthTokenError { #[error("Http error: {0}")] Http(#[from] reqwest::Error), } -async fn refresh_ms_auth_token( +pub async fn refresh_ms_auth_token( client: &reqwest::Client, refresh_token: &str, ) -> Result<ExpiringValue<AccessTokenResponse>, RefreshMicrosoftAuthTokenError> { diff --git a/azalea-auth/src/cache.rs b/azalea-auth/src/cache.rs index 404b05ff..1e8aee10 100755 --- a/azalea-auth/src/cache.rs +++ b/azalea-auth/src/cache.rs @@ -58,6 +58,15 @@ impl<T> ExpiringValue<T> { } } +impl<T: Clone> Clone for ExpiringValue<T> { + fn clone(&self) -> Self { + Self { + expires_at: self.expires_at, + data: self.data.clone(), + } + } +} + async fn get_entire_cache(cache_file: &Path) -> Result<Vec<CachedAccount>, CacheError> { let mut cache: Vec<CachedAccount> = Vec::new(); if cache_file.exists() { diff --git a/azalea-auth/src/lib.rs b/azalea-auth/src/lib.rs index cf0d0401..bd151eb3 100755 --- a/azalea-auth/src/lib.rs +++ b/azalea-auth/src/lib.rs @@ -1,7 +1,7 @@ #![doc = include_str!("../README.md")] mod auth; -mod cache; +pub mod cache; pub mod certs; pub mod game_profile; pub mod sessionserver; diff --git a/azalea-client/Cargo.toml b/azalea-client/Cargo.toml index 09a682b0..0303000f 100644 --- a/azalea-client/Cargo.toml +++ b/azalea-client/Cargo.toml @@ -9,6 +9,7 @@ version = "0.7.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +reqwest = { version = "0.11.12", default-features = false } anyhow = "1.0.59" async-trait = "0.1.58" azalea-auth = { path = "../azalea-auth", version = "0.7.0" } diff --git a/azalea-client/src/account.rs b/azalea-client/src/account.rs index 5c6056c1..2d1b766c 100755 --- a/azalea-client/src/account.rs +++ b/azalea-client/src/account.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use crate::get_mc_dir; use azalea_auth::certs::{Certificates, FetchCertificatesError}; +use azalea_auth::AccessTokenResponse; use parking_lot::Mutex; use thiserror::Error; use uuid::Uuid; @@ -55,8 +56,15 @@ pub struct Account { /// The parameters that were passed for creating the associated [`Account`]. #[derive(Clone, Debug)] pub enum AccountOpts { - Offline { username: String }, - Microsoft { email: String }, + Offline { + username: String, + }, + Microsoft { + email: String, + }, + MicrosoftWithAccessToken { + msa: Arc<Mutex<azalea_auth::cache::ExpiringValue<AccessTokenResponse>>>, + }, } impl Account { @@ -106,27 +114,82 @@ impl Account { }) } + /// This will create an online-mode account through + /// [`azalea_auth::get_minecraft_token`] so you can have more control over + /// the authentication process (like doing your own caching or + /// displaying the Microsoft user code to the user in a different way). + /// + /// Note that this will not refresh the token when it expires. + /// + /// ``` + /// # use azalea_client::Account; + /// # async fn example() -> Result<(), Box<dyn std::error::Error>> { + /// let client = reqwest::Client::new(); + /// + /// let res = azalea_auth::get_ms_link_code(&client).await?; + /// println!( + /// "Go to {} and enter the code {}", + /// res.verification_uri, res.user_code + /// ); + /// let msa = azalea_auth::get_ms_auth_token(&client, res).await?; + /// Account::with_microsoft_access_token(msa).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn with_microsoft_access_token( + mut msa: azalea_auth::cache::ExpiringValue<AccessTokenResponse>, + ) -> Result<Self, azalea_auth::AuthError> { + let client = reqwest::Client::new(); + + if msa.is_expired() { + log::trace!("refreshing Microsoft auth token"); + msa = azalea_auth::refresh_ms_auth_token(&client, &msa.data.refresh_token).await?; + } + + let msa_token = &msa.data.access_token; + + let res = azalea_auth::get_minecraft_token(&client, msa_token).await?; + + let profile = azalea_auth::get_profile(&client, &res.minecraft_access_token).await?; + + Ok(Self { + username: profile.name, + access_token: Some(Arc::new(Mutex::new(res.minecraft_access_token))), + uuid: Some(profile.id), + account_opts: AccountOpts::MicrosoftWithAccessToken { + msa: Arc::new(Mutex::new(msa)), + }, + certs: None, + }) + } /// Refresh the access_token for this account to be valid again. /// /// This requires the `auth_opts` field to be set correctly (which is done /// by default if you used the constructor functions). Note that if the - /// Account is offline-mode, this function won't do anything. + /// Account is offline-mode then this function won't do anything. pub async fn refresh(&self) -> Result<(), azalea_auth::AuthError> { match &self.account_opts { // offline mode doesn't need to refresh so just don't do anything lol AccountOpts::Offline { .. } => Ok(()), AccountOpts::Microsoft { email } => { let new_account = Account::microsoft(email).await?; - let access_token = self - .access_token - .as_ref() - .expect("Access token should always be set for Microsoft accounts"); - let new_access_token = new_account - .access_token - .expect("Access token should always be set for Microsoft accounts") - .lock() - .clone(); - *access_token.lock() = new_access_token; + let access_token_mutex = self.access_token.as_ref().unwrap(); + let new_access_token = new_account.access_token.unwrap().lock().clone(); + *access_token_mutex.lock() = new_access_token; + Ok(()) + } + AccountOpts::MicrosoftWithAccessToken { msa } => { + let msa_value = msa.lock().clone(); + let new_account = Account::with_microsoft_access_token(msa_value).await?; + + let access_token_mutex = self.access_token.as_ref().unwrap(); + let new_access_token = new_account.access_token.unwrap().lock().clone(); + + *access_token_mutex.lock() = new_access_token; + let AccountOpts::MicrosoftWithAccessToken { msa: new_msa } = + new_account.account_opts else { unreachable!() }; + *msa.lock() = new_msa.lock().clone(); + Ok(()) } } diff --git a/azalea-client/src/inventory.rs b/azalea-client/src/inventory.rs index f8c2b2a4..b7269ef1 100644 --- a/azalea-client/src/inventory.rs +++ b/azalea-client/src/inventory.rs @@ -79,7 +79,7 @@ pub struct InventoryComponent { /// The item that is currently held by the cursor. `Slot::Empty` if nothing /// is currently being held. /// - /// This is different from [`Self::hotbar_selected_index`], which is the + /// This is different from [`Self::selected_hotbar_slot`], which is the /// item that's selected in the hotbar. pub carried: ItemSlot, /// An identifier used by the server to track client inventory desyncs. This diff --git a/azalea-inventory/azalea-inventory-macros/src/menu_impl.rs b/azalea-inventory/azalea-inventory-macros/src/menu_impl.rs index 194577ad..a4f78370 100644 --- a/azalea-inventory/azalea-inventory-macros/src/menu_impl.rs +++ b/azalea-inventory/azalea-inventory-macros/src/menu_impl.rs @@ -179,6 +179,7 @@ pub fn generate(input: &DeclareMenus) -> TokenStream { /// Get the range of slot indexes that contain the player's hotbar. This may be different for each menu. /// /// ``` + /// # let inventory = azalea_inventory::Menu::Player(azalea_inventory::Player::default()); /// let hotbar_items = &inventory.slots()[inventory.hotbar_slots_range()]; /// ``` pub fn hotbar_slots_range(&self) -> RangeInclusive<usize> { |
