From c9abf5df77e275cc40891015998d68cc332931b5 Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 25 Sep 2026 15:22:18 +0200 Subject: [PATCH] Authenticate through Mojang's session server; add version manifest Replace `auth` (which received the player's Minecraft access token) with the vanilla online-mode flow, so the token never reaches this server: - auth_challenge: validate the username and return a random one-time server id - the client calls Mojang's session `join` with its token and that id - auth_verify: confirm the join with Mojang's `hasJoined` and log the player in Add GET /versions returning {"supported": [...], "deprecated": [...]} from the SUPPORTED_VERSIONS / DEPRECATED_VERSIONS env vars (defaults: 0.1.0-beta3 supported), exposed in compose.yaml. Refs saturnclientmc/saturnclient#7 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 + Cargo.toml | 3 +- compose.yaml | 4 ++ src/main.rs | 62 +++++++++++++++++++++++++++--- src/methods/auth.rs | 92 +++++++++++++++++++++++++++++++++------------ src/methods/mod.rs | 20 ++++++++-- 6 files changed, 150 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e6a4a6d..597b98b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1463,6 +1463,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -1746,6 +1747,7 @@ name = "server" version = "0.1.0" dependencies = [ "axum", + "rand 0.9.2", "reqwest", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index e503641..a1eb3ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,8 @@ edition = "2024" [dependencies] axum = { version = "0.8.9", features = ["ws"] } -reqwest = { version = "0.13.2", features = ["json"] } +rand = "0.9" +reqwest = { version = "0.13.2", features = ["json", "query"] } serde = { version = "1.0.228", features = ["serde_derive"] } serde_json = "1.0.149" session-rs = { version = "0.2.1", default-features = false, features = ["axum"] } diff --git a/compose.yaml b/compose.yaml index 6ec591f..479ef7f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,6 +6,10 @@ services: # Coolify's proxy; set the domain in Coolify as https://:8080. ports: - "${HOST_PORT:-8080}:8080" + environment: + # Comma-separated client versions served at GET /versions. + SUPPORTED_VERSIONS: ${SUPPORTED_VERSIONS:-0.1.0-beta3} + DEPRECATED_VERSIONS: ${DEPRECATED_VERSIONS:-} volumes: - server-data:/data diff --git a/src/main.rs b/src/main.rs index 236a333..6b7a65e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,12 +6,13 @@ mod user; use std::{collections::HashMap, sync::Arc, time::Duration}; use axum::{ - Router, + Json, Router, extract::{State, WebSocketUpgrade}, http::StatusCode, response::Response, routing::get, }; +use serde::Serialize; use session_rs::Session; use sqlx::SqlitePool; use tokio::sync::Mutex; @@ -25,6 +26,35 @@ const MAX_MESSAGE_SIZE: usize = 1 << 20; struct AppState { pool: Arc, sessions: SessionMap, + versions: Arc, +} + +/// Client versions the server accepts. Clients on a `deprecated` version are +/// warned but still connect; clients on neither list refuse to connect. +#[derive(Debug, Serialize)] +struct VersionManifest { + supported: Vec, + deprecated: Vec, +} + +impl VersionManifest { + /// Reads comma-separated `SUPPORTED_VERSIONS` and `DEPRECATED_VERSIONS`. + fn from_env() -> Self { + let list = |key: &str, default: &str| -> Vec { + std::env::var(key) + .unwrap_or_else(|_| default.to_string()) + .split(',') + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(String::from) + .collect() + }; + + Self { + supported: list("SUPPORTED_VERSIONS", "0.1.0-beta3"), + deprecated: list("DEPRECATED_VERSIONS", ""), + } + } } #[tokio::main] @@ -36,14 +66,19 @@ async fn main() -> std::io::Result<()> { std::process::exit(healthcheck(&bind_addr).await); } + let versions = VersionManifest::from_env(); + println!("Client versions: {versions:?}"); + let state = AppState { pool: Arc::new(user::init_db().await), sessions: Arc::new(Mutex::new(HashMap::new())), + versions: Arc::new(versions), }; let app = Router::new() .route("/", get(ws)) .route("/health", get(health)) + .route("/versions", get(versions_manifest)) .with_state(state); let listener = tokio::net::TcpListener::bind(&bind_addr).await?; @@ -76,10 +111,15 @@ async fn health(State(state): State) -> (StatusCode, &'static str) { } } +async fn versions_manifest(State(state): State) -> Json> { + Json(state.versions) +} + async fn register_handlers(session: &Session, state: AppState) { - let AppState { pool, sessions } = state; + let AppState { pool, sessions, .. } = state; let uuid = Arc::new(Mutex::new(String::new())); let name = Arc::new(Mutex::new(String::new())); + let pending: methods::auth::PendingChallenge = Arc::new(Mutex::new(None)); session .on_close({ @@ -106,20 +146,30 @@ async fn register_handlers(session: &Session, state: AppState) { .await; session - .on_request::({ + .on_request::({ + let uuid = Arc::clone(&uuid); + let pending = Arc::clone(&pending); + + move |_, username| methods::auth::challenge(uuid.clone(), pending.clone(), username) + }) + .await; + + session + .on_request::({ let pool = Arc::clone(&pool); let uuid = Arc::clone(&uuid); let sessions = Arc::clone(&sessions); let name = Arc::clone(&name); + let pending = Arc::clone(&pending); let session = session.clone(); - move |_, token| { - methods::auth::authenticate( + move |_, ()| { + methods::auth::verify( sessions.clone(), session.clone(), name.clone(), uuid.clone(), - token, + pending.clone(), pool.clone(), ) } diff --git a/src/methods/auth.rs b/src/methods/auth.rs index 265684c..46afb7a 100644 --- a/src/methods/auth.rs +++ b/src/methods/auth.rs @@ -2,63 +2,109 @@ use std::sync::Arc; use session_rs::session::Session; use sqlx::SqlitePool; +use tokio::sync::Mutex; use crate::{ - types::{SessionMap, UUID}, + types::{MinecraftAuthResponse, SessionMap, UUID}, user::User, }; -pub async fn authenticate( +const HAS_JOINED_URL: &str = "https://sessionserver.mojang.com/session/minecraft/hasJoined"; + +/// A challenge issued to a connection that hasn't authenticated yet. +#[derive(Debug, Clone)] +pub struct Challenge { + username: String, + server_id: String, +} + +pub type PendingChallenge = Arc>>; + +/// Minecraft usernames: 1–16 characters of letters, digits and underscores. +fn is_valid_username(name: &str) -> bool { + (1..=16).contains(&name.len()) && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') +} + +/// Issues a random server id for the client to `join` with at Mojang. +pub async fn challenge(uuid: UUID, pending: PendingChallenge, username: String) -> Result { + if !uuid.lock().await.is_empty() { + return Err("Already authenticated".to_string()); + } + + if !is_valid_username(&username) { + return Err("Invalid username".to_string()); + } + + let server_id: String = rand::random::<[u8; 20]>() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + + *pending.lock().await = Some(Challenge { + username, + server_id: server_id.clone(), + }); + + Ok(server_id) +} + +/// Confirms with Mojang that the challenged player joined with our server id. +pub async fn verify( sessions: SessionMap, session: Session, name: UUID, uuid: UUID, - session_token: String, + pending: PendingChallenge, pool: Arc, ) -> Result { if !uuid.lock().await.is_empty() { - return Err(format!("Already authenticated")); + return Err("Already authenticated".to_string()); } - let client = reqwest::Client::new(); + // Single use: a failed verification needs a fresh challenge. + let Some(challenge) = pending.lock().await.take() else { + return Err("No pending challenge, call auth_challenge first".to_string()); + }; - let response = client - .get("https://api.minecraftservices.com/minecraft/profile") - .bearer_auth(&session_token) + let response = reqwest::Client::new() + .get(HAS_JOINED_URL) + .query(&[ + ("username", challenge.username.as_str()), + ("serverId", challenge.server_id.as_str()), + ]) .send() .await - .map_err(|_| "Failed to validate session".to_string())?; + .map_err(|_| "Failed to reach the Mojang session server".to_string())?; + + // 204 No Content: the player didn't join with this server id. + if response.status() == reqwest::StatusCode::NO_CONTENT { + return Err("Session not verified by Mojang".to_string()); + } if !response.status().is_success() { return Err(format!( - "Authentication failed with code {}", + "Mojang session server returned {}", response.status() )); } let auth = response - .json::() + .json::() .await - .map_err(|_| "Unable to parse auth response".to_string()) - .and_then(|auth| { - let id = crate::types::format_uuid(&auth.id)?; - Ok(crate::types::MinecraftAuthResponse { - name: auth.name, - id, - }) - })?; + .map_err(|_| "Unable to parse Mojang response".to_string())?; + let id = crate::types::format_uuid(&auth.id)?; - *uuid.lock().await = auth.id.clone(); + *uuid.lock().await = id.clone(); *name.lock().await = auth.name.clone(); sessions .lock() .await - .entry(auth.id.clone()) + .entry(id.clone()) .or_default() .insert(session); - println!("{:?}", auth); + println!("Authenticated {} ({id})", auth.name); - crate::user::get_put(&auth.id, &pool).await + crate::user::get_put(&id, &pool).await } diff --git a/src/methods/mod.rs b/src/methods/mod.rs index 51629d9..48847e3 100644 --- a/src/methods/mod.rs +++ b/src/methods/mod.rs @@ -10,12 +10,26 @@ use crate::{ user::User, }; +/// Step 1 of authentication: the client sends its username and gets a random +/// server id to pass to Mojang's session server `join` endpoint. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Auth; +pub struct AuthChallenge; -impl Method for Auth { - const NAME: &'static str = "auth"; +impl Method for AuthChallenge { + const NAME: &'static str = "auth_challenge"; type Request = String; + type Response = String; + type Error = String; +} + +/// Step 2 of authentication: after joining, the server confirms the player +/// with Mojang's `hasJoined` endpoint. The access token never reaches us. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthVerify; + +impl Method for AuthVerify { + const NAME: &'static str = "auth_verify"; + type Request = (); type Response = User; type Error = String; }