From 4cef62e8e4aa04e44048eb67e5091c12a73d2a09 Mon Sep 17 00:00:00 2001 From: mat <27899617+mat-1@users.noreply.github.com> Date: Sun, 16 Oct 2022 22:54:54 -0500 Subject: Microsoft Authentication (#29) * a * try to do more work on auth signing (untested) * well auth works when i remove the d= so * auth stuff * sessionserver stuff * add auth in azalea-protocol/client * caching* refreshing microsoft auth tokens isn't implemented yet, also i haven't tested it * how did i not notice that i had the code duplicated * fix cache * add refreshing msa token * replace some printlns with log::trace * auth works! * Update main.rs * fix clippy warnings --- azalea-auth/src/auth.rs | 482 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 482 insertions(+) create mode 100644 azalea-auth/src/auth.rs (limited to 'azalea-auth/src/auth.rs') diff --git a/azalea-auth/src/auth.rs b/azalea-auth/src/auth.rs new file mode 100644 index 00000000..5f96d4be --- /dev/null +++ b/azalea-auth/src/auth.rs @@ -0,0 +1,482 @@ +//! Handle Minecraft (Xbox) authentication. + +use crate::cache::{self, CachedAccount, ExpiringValue}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::{ + collections::HashMap, + path::PathBuf, + time::{Instant, SystemTime, UNIX_EPOCH}, +}; +use thiserror::Error; + +#[derive(Default)] +pub struct AuthOpts { + /// Whether we should check if the user actually owns the game. This will + /// fail if the user has Xbox Game Pass! Note that this isn't really + /// necessary, since getting the user profile will check this anyways. + pub check_ownership: bool, + // /// Whether we should get the Minecraft profile data (i.e. username, uuid, + // /// skin, etc) for the player. + // pub get_profile: bool, + /// The directory to store the cache in. If this is not set, caching is not + /// done. + pub cache_file: Option, +} + +#[derive(Debug, Error)] +pub enum AuthError { + #[error( + "The Minecraft API is indicating that you don't own the game. \ + If you're using Xbox Game Pass, set `check_ownership` to false in the auth options." + )] + DoesNotOwnGame, + #[error("Error getting Microsoft auth token: {0}")] + GetMicrosoftAuthToken(#[from] GetMicrosoftAuthTokenError), + #[error("Error refreshing Microsoft auth token: {0}")] + RefreshMicrosoftAuthToken(#[from] RefreshMicrosoftAuthTokenError), + #[error("Error getting Xbox Live auth token: {0}")] + GetXboxLiveAuthToken(#[from] MinecraftXstsAuthError), + #[error("Error getting Minecraft profile: {0}")] + GetMinecraftProfile(#[from] GetProfileError), + #[error("Error checking ownership: {0}")] + CheckOwnership(#[from] CheckOwnershipError), + #[error("Error getting Minecraft auth token: {0}")] + GetMinecraftAuthToken(#[from] MinecraftAuthError), + #[error("Error authenticating with Xbox Live: {0}")] + GetXboxLiveAuth(#[from] XboxLiveAuthError), +} + +/// Authenticate with authenticate with Microsoft. If the data isn't cached, +/// they'll be asked to go to log into Microsoft in a web page. +/// +/// 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. +pub async fn auth(email: &str, opts: AuthOpts) -> Result { + let cached_account = if let Some(cache_file) = &opts.cache_file && let Some(account) = cache::get_account_in_cache(cache_file, email).await { + Some(account) + } else { None }; + + // these two MUST be set by the end, since we return them in AuthResult + let profile: ProfileResponse; + let minecraft_access_token: String; + + if let Some(account) = &cached_account && !account.mca.is_expired() { + // 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(); + } else { + let client = reqwest::Client::new(); + let mut msa = if let Some(account) = cached_account { + account.msa + } else { + interactive_get_ms_auth_token(&client).await? + }; + if msa.is_expired() { + 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?; + + minecraft_access_token = mca + .get() + .expect("Minecraft auth shouldn't have expired yet") + .access_token + .to_string(); + + if opts.check_ownership { + let has_game = check_ownership(&client, &minecraft_access_token).await?; + if !has_game { + return Err(AuthError::DoesNotOwnGame); + } + } + + profile = get_profile(&client, &minecraft_access_token).await?; + + if let Some(cache_file) = opts.cache_file { + if let Err(e) = cache::set_account_in_cache( + &cache_file, + email, + CachedAccount { + email: email.to_string(), + mca, + msa, + xbl: xbl_auth, + profile: profile.clone(), + }, + ) + .await { + log::warn!("Error while caching auth data: {}", e); + } + } + } + + Ok(AuthResult { + access_token: minecraft_access_token, + profile, + }) +} + +#[derive(Debug)] +pub struct AuthResult { + pub access_token: String, + pub profile: ProfileResponse, +} + +#[derive(Debug, Deserialize)] +pub struct DeviceCodeResponse { + user_code: String, + device_code: String, + verification_uri: String, + expires_in: u64, + interval: u64, +} + +#[allow(unused)] +#[derive(Debug, Deserialize, Serialize)] +pub struct AccessTokenResponse { + token_type: String, + expires_in: u64, + scope: String, + access_token: String, + refresh_token: String, + 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>>, +} + +/// Just the important data +#[derive(Serialize, Deserialize, Debug)] +pub struct XboxLiveAuth { + token: String, + user_hash: String, +} + +#[allow(unused)] +#[derive(Debug, Deserialize, Serialize)] +pub struct MinecraftAuthResponse { + username: String, + roles: Vec, + access_token: String, + token_type: String, + expires_in: u64, +} + +#[allow(unused)] +#[derive(Debug, Deserialize)] +pub struct GameOwnershipResponse { + items: Vec, + signature: String, + key_id: String, +} + +#[allow(unused)] +#[derive(Debug, Deserialize)] +pub struct GameOwnershipItem { + name: String, + signature: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ProfileResponse { + pub id: String, + pub name: String, + pub skins: Vec, + pub capes: Vec, +} + +// nintendo switch (so it works for accounts that are under 18 years old) +const CLIENT_ID: &str = "00000000441cc96b"; + +#[derive(Debug, Error)] +pub enum GetMicrosoftAuthTokenError { + #[error("Http error: {0}")] + Http(#[from] reqwest::Error), + #[error("Authentication timed out")] + Timeout, +} + +/// Asks the user to go to a webpage and log in with Microsoft. +async fn interactive_get_ms_auth_token( + client: &reqwest::Client, +) -> Result, GetMicrosoftAuthTokenError> { + let res = client + .post("https://login.live.com/oauth20_connect.srf") + .form(&vec![ + ("scope", "service::user.auth.xboxlive.com::MBI_SSL"), + ("client_id", CLIENT_ID), + ("response_type", "device_code"), + ]) + .send() + .await? + .json::() + .await?; + log::trace!("Device code response: {:?}", res); + println!( + "Go to \x1b[1m{}\x1b[m and enter the code \x1b[1m{}\x1b[m", + res.verification_uri, res.user_code + ); + + let login_expires_at = Instant::now() + std::time::Duration::from_secs(res.expires_in); + + while Instant::now() < login_expires_at { + tokio::time::sleep(std::time::Duration::from_secs(res.interval)).await; + + log::trace!("Polling to check if user has logged in..."); + if let Ok(access_token_response) = client + .post(format!( + "https://login.live.com/oauth20_token.srf?client_id={}", + CLIENT_ID + )) + .form(&vec![ + ("client_id", CLIENT_ID), + ("device_code", &res.device_code), + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ]) + .send() + .await? + .json::() + .await + { + log::trace!("access_token_response: {:?}", access_token_response); + let expires_at = SystemTime::now() + + std::time::Duration::from_secs(access_token_response.expires_in); + return Ok(ExpiringValue { + data: access_token_response, + expires_at: expires_at + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(), + }); + } + } + + Err(GetMicrosoftAuthTokenError::Timeout) +} + +#[derive(Debug, Error)] +pub enum RefreshMicrosoftAuthTokenError { + #[error("Http error: {0}")] + Http(#[from] reqwest::Error), +} + +async fn refresh_ms_auth_token( + client: &reqwest::Client, + refresh_token: &str, +) -> Result, RefreshMicrosoftAuthTokenError> { + let access_token_response = client + .post("https://login.live.com/oauth20_token.srf") + .form(&vec![ + ("scope", "service::user.auth.xboxlive.com::MBI_SSL"), + ("client_id", CLIENT_ID), + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ]) + .send() + .await? + .json::() + .await?; + + let expires_at = + SystemTime::now() + std::time::Duration::from_secs(access_token_response.expires_in); + Ok(ExpiringValue { + data: access_token_response, + expires_at: expires_at + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(), + }) +} + +#[derive(Debug, Error)] +pub enum XboxLiveAuthError { + #[error("Http error: {0}")] + Http(#[from] reqwest::Error), + #[error("Invalid expiry date: {0}")] + InvalidExpiryDate(String), +} + +async fn auth_with_xbox_live( + client: &reqwest::Client, + access_token: &str, +) -> Result, XboxLiveAuthError> { + let auth_json = json!({ + "Properties": { + "AuthMethod": "RPS", + "SiteName": "user.auth.xboxlive.com", + // i thought this was supposed to be d={} but it doesn't work for + // me when i add it ?????? + "RpsTicket": format!("{}", access_token) + }, + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT" + }); + let payload = auth_json.to_string(); + log::trace!("auth_json: {:#?}", auth_json); + let res = client + .post("https://user.auth.xboxlive.com/user/authenticate") + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("x-xbl-contract-version", "1") + // .header("Cache-Control", "no-store, must-revalidate, no-cache") + // .header("Signature", base64::encode(signature)) + .body(payload) + .send() + .await? + .json::() + .await?; + log::trace!("Xbox Live auth response: {:?}", res); + + // not_after looks like 2020-12-21T19:52:08.4463796Z + let expires_at = DateTime::parse_from_rfc3339(&res.not_after) + .map_err(|e| XboxLiveAuthError::InvalidExpiryDate(format!("{}: {}", res.not_after, e)))? + .with_timezone(&Utc) + .timestamp() as u64; + Ok(ExpiringValue { + data: XboxLiveAuth { + token: res.token, + user_hash: res.display_claims["xui"].get(0).unwrap()["uhs"].clone(), + }, + expires_at, + }) +} + +#[derive(Debug, Error)] +pub enum MinecraftXstsAuthError { + #[error("Http error: {0}")] + Http(#[from] reqwest::Error), +} + +async fn obtain_xsts_for_minecraft( + client: &reqwest::Client, + xbl_auth_token: &str, +) -> Result { + let res = client + .post("https://xsts.auth.xboxlive.com/xsts/authorize") + .header("Accept", "application/json") + .json(&json!({ + "Properties": { + "SandboxId": "RETAIL", + "UserTokens": [xbl_auth_token.to_string()] + }, + "RelyingParty": "rp://api.minecraftservices.com/", + "TokenType": "JWT" + })) + .send() + .await? + .json::() + .await?; + log::trace!("Xbox Live auth response (for XSTS): {:?}", res); + + Ok(res.token) +} + +#[derive(Debug, Error)] +pub enum MinecraftAuthError { + #[error("Http error: {0}")] + Http(#[from] reqwest::Error), +} + +async fn auth_with_minecraft( + client: &reqwest::Client, + user_hash: &str, + xsts_token: &str, +) -> Result, MinecraftAuthError> { + let res = client + .post("https://api.minecraftservices.com/authentication/login_with_xbox") + .header("Accept", "application/json") + .json(&json!({ + "identityToken": format!("XBL3.0 x={};{}", user_hash, xsts_token) + })) + .send() + .await? + .json::() + .await?; + log::trace!("{:?}", res); + + let expires_at = SystemTime::now() + std::time::Duration::from_secs(res.expires_in); + Ok(ExpiringValue { + data: res, + // to seconds since epoch + expires_at: expires_at.duration_since(UNIX_EPOCH).unwrap().as_secs(), + }) +} + +#[derive(Debug, Error)] +pub enum CheckOwnershipError { + #[error("Http error: {0}")] + Http(#[from] reqwest::Error), +} + +async fn check_ownership( + client: &reqwest::Client, + minecraft_access_token: &str, +) -> Result { + let res = client + .get("https://api.minecraftservices.com/entitlements/mcstore") + .header( + "Authorization", + format!("Bearer {}", minecraft_access_token), + ) + .send() + .await? + .json::() + .await?; + log::trace!("{:?}", res); + + // vanilla checks here to make sure the signatures are right, but it's not + // actually required so we just don't + + Ok(!res.items.is_empty()) +} + +#[derive(Debug, Error)] +pub enum GetProfileError { + #[error("Http error: {0}")] + Http(#[from] reqwest::Error), +} + +async fn get_profile( + client: &reqwest::Client, + minecraft_access_token: &str, +) -> Result { + let res = client + .get("https://api.minecraftservices.com/minecraft/profile") + .header( + "Authorization", + format!("Bearer {}", minecraft_access_token), + ) + .send() + .await? + .json::() + .await?; + log::trace!("{:?}", res); + + Ok(res) +} -- cgit v1.2.3