aboutsummaryrefslogtreecommitdiff
path: root/azalea-auth/src
diff options
context:
space:
mode:
authormat <27899617+mat-1@users.noreply.github.com>2022-10-16 22:54:54 -0500
committerGitHub <noreply@github.com>2022-10-16 22:54:54 -0500
commit4cef62e8e4aa04e44048eb67e5091c12a73d2a09 (patch)
tree1c3b03bad262bdcab878cd42d445676290000bea /azalea-auth/src
parent993914d175609e5d291e7caafc1983379642e7fe (diff)
downloadazalea-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/src')
-rw-r--r--azalea-auth/src/auth.rs482
-rw-r--r--azalea-auth/src/cache.rs105
-rwxr-xr-xazalea-auth/src/lib.rs7
-rw-r--r--azalea-auth/src/sessionserver.rs79
4 files changed, 672 insertions, 1 deletions
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,
+ })
+ }
+ }
+}