diff options
| author | mat <27899617+mat-1@users.noreply.github.com> | 2022-10-16 22:54:54 -0500 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-10-16 22:54:54 -0500 |
| commit | 4cef62e8e4aa04e44048eb67e5091c12a73d2a09 (patch) | |
| tree | 1c3b03bad262bdcab878cd42d445676290000bea /azalea-auth | |
| parent | 993914d175609e5d291e7caafc1983379642e7fe (diff) | |
| download | azalea-drasl-4cef62e8e4aa04e44048eb67e5091c12a73d2a09.tar.xz | |
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
Diffstat (limited to 'azalea-auth')
| -rwxr-xr-x | azalea-auth/Cargo.toml | 13 | ||||
| -rw-r--r-- | azalea-auth/README.md | 4 | ||||
| -rw-r--r-- | azalea-auth/examples/auth.rs | 19 | ||||
| -rw-r--r-- | azalea-auth/src/auth.rs | 482 | ||||
| -rw-r--r-- | azalea-auth/src/cache.rs | 105 | ||||
| -rwxr-xr-x | azalea-auth/src/lib.rs | 7 | ||||
| -rw-r--r-- | azalea-auth/src/sessionserver.rs | 79 |
7 files changed, 707 insertions, 2 deletions
diff --git a/azalea-auth/Cargo.toml b/azalea-auth/Cargo.toml index 2f817354..34fec8f7 100755 --- a/azalea-auth/Cargo.toml +++ b/azalea-auth/Cargo.toml @@ -9,4 +9,17 @@ version = "0.1.0" [dependencies] azalea-buf = {path = "../azalea-buf", version = "^0.1.0"} +azalea-crypto = {path = "../azalea-crypto", version = "^0.1.0"} +chrono = {version = "0.4.22", default-features = false} +log = "0.4.17" +num-bigint = "0.4.3" +reqwest = {version = "0.11.12", features = ["json"]} +serde = {version = "1.0.145", features = ["derive"]} +serde_json = "1.0.86" +thiserror = "1.0.37" +tokio = "1.21.2" uuid = "^1.1.2" + +[dev-dependencies] +env_logger = "0.9.1" +tokio = {version = "1.21.2", features = ["full"]} diff --git a/azalea-auth/README.md b/azalea-auth/README.md index fa87afca..aa290c94 100644 --- a/azalea-auth/README.md +++ b/azalea-auth/README.md @@ -1,3 +1,5 @@ # Azalea Auth -A port of Mojang's Authlib, except authentication isn't actually implemented yet. +A port of Mojang's Authlib and launcher authentication. + +Thanks to [wiki.vg contributors](https://wiki.vg/Microsoft_Authentication_Scheme), [Overhash](https://gist.github.com/OverHash/a71b32846612ba09d8f79c9d775bfadf), and [prismarine-auth contributors](https://github.com/PrismarineJS/prismarine-auth). diff --git a/azalea-auth/examples/auth.rs b/azalea-auth/examples/auth.rs new file mode 100644 index 00000000..8f7cf7f9 --- /dev/null +++ b/azalea-auth/examples/auth.rs @@ -0,0 +1,19 @@ +use std::path::PathBuf; + +#[tokio::main] +async fn main() { + env_logger::init(); + + let cache_file = PathBuf::from("example_cache.json"); + + let auth_result = azalea_auth::auth( + "example@example.com", + azalea_auth::AuthOpts { + cache_file: Some(cache_file), + ..Default::default() + }, + ) + .await + .unwrap(); + println!("{:?}", auth_result); +} 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<PathBuf>, +} + +#[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<AuthResult, AuthError> { + 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<String, Vec<HashMap<String, String>>>, +} + +/// 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<String>, + access_token: String, + token_type: String, + expires_in: u64, +} + +#[allow(unused)] +#[derive(Debug, Deserialize)] +pub struct GameOwnershipResponse { + items: Vec<GameOwnershipItem>, + 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<serde_json::Value>, + pub capes: Vec<serde_json::Value>, +} + +// 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<ExpiringValue<AccessTokenResponse>, 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::<DeviceCodeResponse>() + .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::<AccessTokenResponse>() + .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<ExpiringValue<AccessTokenResponse>, 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::<AccessTokenResponse>() + .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<ExpiringValue<XboxLiveAuth>, 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::<XboxLiveAuthResponse>() + .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<String, MinecraftXstsAuthError> { + 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::<XboxLiveAuthResponse>() + .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<ExpiringValue<MinecraftAuthResponse>, 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::<MinecraftAuthResponse>() + .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<bool, CheckOwnershipError> { + let res = client + .get("https://api.minecraftservices.com/entitlements/mcstore") + .header( + "Authorization", + format!("Bearer {}", minecraft_access_token), + ) + .send() + .await? + .json::<GameOwnershipResponse>() + .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<ProfileResponse, GetProfileError> { + let res = client + .get("https://api.minecraftservices.com/minecraft/profile") + .header( + "Authorization", + format!("Bearer {}", minecraft_access_token), + ) + .send() + .await? + .json::<ProfileResponse>() + .await?; + log::trace!("{:?}", res); + + Ok(res) +} diff --git a/azalea-auth/src/cache.rs b/azalea-auth/src/cache.rs new file mode 100644 index 00000000..8af9e171 --- /dev/null +++ b/azalea-auth/src/cache.rs @@ -0,0 +1,105 @@ +//! Cache auth information + +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; +use thiserror::Error; +use tokio::fs::File; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[derive(Debug, Error)] +pub enum CacheError { + #[error("Failed to read cache file: {0}")] + Read(std::io::Error), + #[error("Failed to write cache file: {0}")] + Write(std::io::Error), + #[error("Failed to parse cache file: {0}")] + Parse(serde_json::Error), +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct CachedAccount { + pub email: String, + /// Microsoft auth + pub msa: ExpiringValue<crate::auth::AccessTokenResponse>, + /// Xbox Live auth + pub xbl: ExpiringValue<crate::auth::XboxLiveAuth>, + /// Minecraft auth + pub mca: ExpiringValue<crate::auth::MinecraftAuthResponse>, + /// The user's Minecraft profile (i.e. username, UUID, skin) + pub profile: crate::auth::ProfileResponse, +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct ExpiringValue<T> { + /// Seconds since the UNIX epoch + pub expires_at: u64, + pub data: T, +} + +impl<T> ExpiringValue<T> { + pub fn is_expired(&self) -> bool { + self.expires_at + < SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + } + + /// Return the data if it's not expired, otherwise return `None` + pub fn get(&self) -> Option<&T> { + if self.is_expired() { + None + } else { + Some(&self.data) + } + } +} + +async fn get_entire_cache(cache_file: &Path) -> Result<Vec<CachedAccount>, CacheError> { + let mut cache: Vec<CachedAccount> = Vec::new(); + if cache_file.exists() { + let mut cache_file = File::open(cache_file).await.map_err(CacheError::Read)?; + // read the file into a string + let mut contents = String::new(); + cache_file + .read_to_string(&mut contents) + .await + .map_err(CacheError::Read)?; + cache = serde_json::from_str(&contents).map_err(CacheError::Parse)?; + } + Ok(cache) +} +async fn set_entire_cache(cache_file: &Path, cache: Vec<CachedAccount>) -> Result<(), CacheError> { + println!("saving cache: {:?}", cache); + + let mut cache_file = File::create(cache_file).await.map_err(CacheError::Write)?; + let cache = serde_json::to_string_pretty(&cache).map_err(CacheError::Parse)?; + cache_file + .write_all(cache.as_bytes()) + .await + .map_err(CacheError::Write)?; + + Ok(()) +} + +/// Gets cached data for the given email. +/// +/// Technically it doesn't actually have to be an email since it's only the +/// cache key. I considered using usernames or UUIDs as the cache key, but +/// usernames change and no one has their UUID memorized. +pub async fn get_account_in_cache(cache_file: &Path, email: &str) -> Option<CachedAccount> { + let cache = get_entire_cache(cache_file).await.unwrap_or_default(); + cache.into_iter().find(|account| account.email == email) +} + +pub async fn set_account_in_cache( + cache_file: &Path, + email: &str, + account: CachedAccount, +) -> Result<(), CacheError> { + let mut cache = get_entire_cache(cache_file).await.unwrap_or_default(); + cache.retain(|account| account.email != email); + cache.push(account); + set_entire_cache(cache_file, cache).await +} diff --git a/azalea-auth/src/lib.rs b/azalea-auth/src/lib.rs index 773ea1d9..03e15c71 100755 --- a/azalea-auth/src/lib.rs +++ b/azalea-auth/src/lib.rs @@ -1,3 +1,8 @@ -//! Handle Minecraft authentication. +#![feature(let_chains)] +mod auth; +mod cache; pub mod game_profile; +pub mod sessionserver; + +pub use auth::*; diff --git a/azalea-auth/src/sessionserver.rs b/azalea-auth/src/sessionserver.rs new file mode 100644 index 00000000..31857bc0 --- /dev/null +++ b/azalea-auth/src/sessionserver.rs @@ -0,0 +1,79 @@ +//! Tell Mojang you're joining a multiplayer server. +//! +use serde::Deserialize; +use serde_json::json; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Debug, Error)] +pub enum SessionServerError { + #[error("Error sending HTTP request to sessionserver: {0}")] + HttpError(#[from] reqwest::Error), + #[error("Multiplayer is not enabled for this account")] + MultiplayerDisabled, + #[error("This account has been banned from multiplayer")] + Banned, + #[error("Unknown sessionserver error: {0}")] + Unknown(String), + #[error("Unexpected response from sessionserver (status code {status_code}): {body}")] + UnexpectedResponse { status_code: u16, body: String }, +} + +#[derive(Deserialize)] +pub struct ForbiddenError { + pub error: String, + pub path: String, +} + +/// Tell Mojang's servers that you are going to join a multiplayer server, +/// which is required to join online-mode servers. The server ID is an empty +/// string. +pub async fn join( + access_token: &str, + public_key: &[u8], + private_key: &[u8], + uuid: &Uuid, + server_id: &str, +) -> Result<(), SessionServerError> { + let client = reqwest::Client::new(); + + let server_hash = azalea_crypto::hex_digest(&azalea_crypto::digest_data( + server_id.as_bytes(), + public_key, + private_key, + )); + + let mut encode_buffer = Uuid::encode_buffer(); + let undashed_uuid = uuid.simple().encode_lower(&mut encode_buffer); + + let data = json!({ + "accessToken": access_token, + "selectedProfile": undashed_uuid, + "serverId": server_hash + }); + println!("data: {:?}", data); + let res = client + .post("https://sessionserver.mojang.com/session/minecraft/join") + .json(&data) + .send() + .await?; + + match res.status() { + reqwest::StatusCode::NO_CONTENT => Ok(()), + reqwest::StatusCode::FORBIDDEN => { + let forbidden = res.json::<ForbiddenError>().await?; + match forbidden.error.as_str() { + "InsufficientPrivilegesException" => Err(SessionServerError::MultiplayerDisabled), + "UserBannedException" => Err(SessionServerError::Banned), + _ => Err(SessionServerError::Unknown(forbidden.error)), + } + } + status_code => { + let body = res.text().await?; + Err(SessionServerError::UnexpectedResponse { + status_code: status_code.as_u16(), + body, + }) + } + } +} |
