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
54static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(reqwest::Client::new);
55
56/// Tell Mojang's servers that you are going to join a multiplayer server,
57/// which is required to join online-mode servers.
58///
59/// The server ID should typically be an empty string.
60pub async fn join(
61    access_token: &str,
62    public_key: &[u8],
63    private_key: &[u8],
64    uuid: &Uuid,
65    server_id: &str,
66) -> Result<(), ClientSessionServerError> {
67    let client = REQWEST_CLIENT.clone();
68
69    let server_hash = azalea_crypto::hex_digest(&azalea_crypto::digest_data(
70        server_id.as_bytes(),
71        public_key,
72        private_key,
73    ));
74
75    join_with_server_id_hash(&client, access_token, uuid, &server_hash).await
76}
77
78pub async fn join_with_server_id_hash(
79    client: &reqwest::Client,
80    access_token: &str,
81    uuid: &Uuid,
82    server_hash: &str,
83) -> Result<(), ClientSessionServerError> {
84    let mut encode_buffer = Uuid::encode_buffer();
85    let undashed_uuid = uuid.simple().encode_lower(&mut encode_buffer);
86
87    let data = json!({
88        "accessToken": access_token,
89        "selectedProfile": undashed_uuid,
90        "serverId": server_hash
91    });
92    let res = client
93        .post("https://sessionserver.mojang.com/session/minecraft/join")
94        .json(&data)
95        .send()
96        .await?;
97
98    match res.status() {
99        StatusCode::NO_CONTENT => Ok(()),
100        StatusCode::FORBIDDEN => {
101            let forbidden = res.json::<ForbiddenError>().await?;
102            match forbidden.error.as_str() {
103                "InsufficientPrivilegesException" => {
104                    Err(ClientSessionServerError::MultiplayerDisabled)
105                }
106                "UserBannedException" => Err(ClientSessionServerError::Banned),
107                "AuthenticationUnavailableException" => {
108                    Err(ClientSessionServerError::AuthServersUnreachable)
109                }
110                "InvalidCredentialsException" => Err(ClientSessionServerError::InvalidSession),
111                "ForbiddenOperationException" => Err(ClientSessionServerError::ForbiddenOperation),
112                _ => Err(ClientSessionServerError::Unknown(forbidden.error)),
113            }
114        }
115        StatusCode::TOO_MANY_REQUESTS => Err(ClientSessionServerError::RateLimited),
116        status_code => {
117            // log the headers
118            debug!("Error headers: {:#?}", res.headers());
119            let body = res.text().await?;
120            Err(ClientSessionServerError::UnexpectedResponse {
121                status_code: status_code.as_u16(),
122                body,
123            })
124        }
125    }
126}
127
128/// Ask Mojang's servers if the player joining is authenticated.
129/// Included in the reply is the player's skin and cape.
130/// The IP field is optional and equivalent to enabling
131/// 'prevent-proxy-connections' in server.properties
132pub async fn serverside_auth(
133    username: &str,
134    public_key: &[u8],
135    private_key: &[u8; 16],
136    ip: Option<&str>,
137) -> Result<GameProfile, ServerSessionServerError> {
138    let hash = azalea_crypto::hex_digest(&azalea_crypto::digest_data(
139        "".as_bytes(),
140        public_key,
141        private_key,
142    ));
143
144    let url = reqwest::Url::parse_with_params(
145        "https://sessionserver.mojang.com/session/minecraft/hasJoined",
146        if let Some(ip) = ip {
147            vec![("username", username), ("serverId", &hash), ("ip", ip)]
148        } else {
149            vec![("username", username), ("serverId", &hash)]
150        },
151    )
152    .expect("URL should always be valid");
153
154    let res = reqwest::get(url).await?;
155
156    match res.status() {
157        StatusCode::OK => {}
158        StatusCode::NO_CONTENT => {
159            return Err(ServerSessionServerError::InvalidSession);
160        }
161        StatusCode::FORBIDDEN => {
162            return Err(ServerSessionServerError::Unknown(
163                res.json::<ForbiddenError>().await?.error,
164            ));
165        }
166        status_code => {
167            // log the headers
168            debug!("Error headers: {:#?}", res.headers());
169            let body = res.text().await?;
170            return Err(ServerSessionServerError::UnexpectedResponse {
171                status_code: status_code.as_u16(),
172                body,
173            });
174        }
175    };
176
177    Ok(res.json::<SerializableGameProfile>().await?.into())
178}