azalea_auth/
sessionserver.rs

1//! Tell Mojang you're joining a multiplayer server.
2
3use std::sync::LazyLock;
4
5use reqwest::StatusCode;
6use serde::Deserialize;
7use serde_json::json;
8use thiserror::Error;
9use tracing::debug;
10use uuid::Uuid;
11
12use crate::game_profile::{GameProfile, SerializableGameProfile};
13
14#[derive(Debug, Error)]
15pub enum ClientSessionServerError {
16    #[error("Error sending HTTP request to sessionserver: {0}")]
17    HttpError(#[from] reqwest::Error),
18    #[error("Multiplayer is not enabled for this account")]
19    MultiplayerDisabled,
20    #[error("This account has been banned from multiplayer")]
21    Banned,
22    #[error("The authentication servers are currently not reachable")]
23    AuthServersUnreachable,
24    #[error("Invalid or expired session")]
25    InvalidSession,
26    #[error("Unknown sessionserver error: {0}")]
27    Unknown(String),
28    #[error("Forbidden operation (expired session?)")]
29    ForbiddenOperation,
30    #[error("RateLimiter disallowed request")]
31    RateLimited,
32    #[error("Unexpected response from sessionserver (status code {status_code}): {body}")]
33    UnexpectedResponse { status_code: u16, body: String },
34}
35
36#[derive(Debug, Error)]
37pub enum ServerSessionServerError {
38    #[error("Error sending HTTP request to sessionserver: {0}")]
39    HttpError(#[from] reqwest::Error),
40    #[error("Invalid or expired session")]
41    InvalidSession,
42    #[error("Unexpected response from sessionserver (status code {status_code}): {body}")]
43    UnexpectedResponse { status_code: u16, body: String },
44    #[error("Unknown sessionserver error: {0}")]
45    Unknown(String),
46}
47
48#[derive(Deserialize)]
49pub struct ForbiddenError {
50    pub error: String,
51    pub path: String,
52}
53
54pub struct SessionServerJoinOpts<'a> {
55    pub access_token: &'a str,
56    /// Given to us by the
57    pub public_key: &'a [u8],
58    pub private_key: &'a [u8],
59    pub uuid: &'a Uuid,
60    /// This is given to us by the server, but it's typically an empty string.
61    pub server_id: &'a str,
62
63    pub proxy: Option<reqwest::Proxy>,
64}
65
66/// Tell Mojang's servers that you are going to join a multiplayer server,
67/// which is required to join online-mode servers.
68pub async fn join(opts: SessionServerJoinOpts<'_>) -> Result<(), ClientSessionServerError> {
69    let client = if let Some(proxy) = opts.proxy {
70        // reusing the client is too complicated if we're using proxies, so don't bother
71        reqwest::ClientBuilder::new().proxy(proxy).build()?
72    } else {
73        // no_proxy so we don't check reqwest's proxy env variables (because azalea
74        // doesn't handle them when connecting to servers anyways)
75        static REQWEST_CLIENT: LazyLock<reqwest::Client> =
76            LazyLock::new(|| reqwest::ClientBuilder::new().no_proxy().build().unwrap());
77
78        REQWEST_CLIENT.clone()
79    };
80
81    let server_hash = azalea_crypto::hex_digest(&azalea_crypto::digest_data(
82        opts.server_id.as_bytes(),
83        opts.public_key,
84        opts.private_key,
85    ));
86
87    join_with_server_id_hash(&client, opts.access_token, opts.uuid, &server_hash).await
88}
89
90pub async fn join_with_server_id_hash(
91    client: &reqwest::Client,
92    access_token: &str,
93    uuid: &Uuid,
94    server_hash: &str,
95) -> Result<(), ClientSessionServerError> {
96    let mut encode_buffer = Uuid::encode_buffer();
97    let undashed_uuid = uuid.simple().encode_lower(&mut encode_buffer);
98
99    let data = json!({
100        "accessToken": access_token,
101        "selectedProfile": undashed_uuid,
102        "serverId": server_hash
103    });
104    let res = client
105        .post("https://sessionserver.mojang.com/session/minecraft/join")
106        .json(&data)
107        .send()
108        .await?;
109
110    match res.status() {
111        StatusCode::NO_CONTENT => Ok(()),
112        StatusCode::FORBIDDEN => {
113            let forbidden = res.json::<ForbiddenError>().await?;
114            match forbidden.error.as_str() {
115                "InsufficientPrivilegesException" => {
116                    Err(ClientSessionServerError::MultiplayerDisabled)
117                }
118                "UserBannedException" => Err(ClientSessionServerError::Banned),
119                "AuthenticationUnavailableException" => {
120                    Err(ClientSessionServerError::AuthServersUnreachable)
121                }
122                "InvalidCredentialsException" => Err(ClientSessionServerError::InvalidSession),
123                "ForbiddenOperationException" => Err(ClientSessionServerError::ForbiddenOperation),
124                _ => Err(ClientSessionServerError::Unknown(forbidden.error)),
125            }
126        }
127        StatusCode::TOO_MANY_REQUESTS => Err(ClientSessionServerError::RateLimited),
128        status_code => {
129            // log the headers
130            debug!("Error headers: {:#?}", res.headers());
131            let body = res.text().await?;
132            Err(ClientSessionServerError::UnexpectedResponse {
133                status_code: status_code.as_u16(),
134                body,
135            })
136        }
137    }
138}
139
140/// Ask Mojang's servers if the player joining is authenticated.
141/// Included in the reply is the player's skin and cape.
142/// The IP field is optional and equivalent to enabling
143/// 'prevent-proxy-connections' in server.properties
144pub async fn serverside_auth(
145    username: &str,
146    public_key: &[u8],
147    private_key: &[u8; 16],
148    ip: Option<&str>,
149) -> Result<GameProfile, ServerSessionServerError> {
150    let hash = azalea_crypto::hex_digest(&azalea_crypto::digest_data(
151        "".as_bytes(),
152        public_key,
153        private_key,
154    ));
155
156    let url = reqwest::Url::parse_with_params(
157        "https://sessionserver.mojang.com/session/minecraft/hasJoined",
158        if let Some(ip) = ip {
159            vec![("username", username), ("serverId", &hash), ("ip", ip)]
160        } else {
161            vec![("username", username), ("serverId", &hash)]
162        },
163    )
164    .expect("URL should always be valid");
165
166    let res = reqwest::get(url).await?;
167
168    match res.status() {
169        StatusCode::OK => {}
170        StatusCode::NO_CONTENT => {
171            return Err(ServerSessionServerError::InvalidSession);
172        }
173        StatusCode::FORBIDDEN => {
174            return Err(ServerSessionServerError::Unknown(
175                res.json::<ForbiddenError>().await?.error,
176            ));
177        }
178        status_code => {
179            // log the headers
180            debug!("Error headers: {:#?}", res.headers());
181            let body = res.text().await?;
182            return Err(ServerSessionServerError::UnexpectedResponse {
183                status_code: status_code.as_u16(),
184                body,
185            });
186        }
187    };
188
189    Ok(res.json::<SerializableGameProfile>().await?.into())
190}