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 <[email protected]>
This commit is contained in:
2026-09-25 15:22:18 +02:00
co-authored by claude
parent 222567b735
commit c9abf5df77
6 changed files with 150 additions and 33 deletions
Generated
+2
View File
@@ -1463,6 +1463,7 @@ dependencies = [
"rustls-platform-verifier", "rustls-platform-verifier",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
@@ -1746,6 +1747,7 @@ name = "server"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"rand 0.9.2",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
+2 -1
View File
@@ -5,7 +5,8 @@ edition = "2024"
[dependencies] [dependencies]
axum = { version = "0.8.9", features = ["ws"] } 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 = { version = "1.0.228", features = ["serde_derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
session-rs = { version = "0.2.1", default-features = false, features = ["axum"] } session-rs = { version = "0.2.1", default-features = false, features = ["axum"] }
+4
View File
@@ -6,6 +6,10 @@ services:
# Coolify's proxy; set the domain in Coolify as https://<domain>:8080. # Coolify's proxy; set the domain in Coolify as https://<domain>:8080.
ports: ports:
- "${HOST_PORT:-8080}:8080" - "${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: volumes:
- server-data:/data - server-data:/data
+56 -6
View File
@@ -6,12 +6,13 @@ mod user;
use std::{collections::HashMap, sync::Arc, time::Duration}; use std::{collections::HashMap, sync::Arc, time::Duration};
use axum::{ use axum::{
Router, Json, Router,
extract::{State, WebSocketUpgrade}, extract::{State, WebSocketUpgrade},
http::StatusCode, http::StatusCode,
response::Response, response::Response,
routing::get, routing::get,
}; };
use serde::Serialize;
use session_rs::Session; use session_rs::Session;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -25,6 +26,35 @@ const MAX_MESSAGE_SIZE: usize = 1 << 20;
struct AppState { struct AppState {
pool: Arc<SqlitePool>, pool: Arc<SqlitePool>,
sessions: SessionMap, sessions: SessionMap,
versions: Arc<VersionManifest>,
}
/// 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<String>,
deprecated: Vec<String>,
}
impl VersionManifest {
/// Reads comma-separated `SUPPORTED_VERSIONS` and `DEPRECATED_VERSIONS`.
fn from_env() -> Self {
let list = |key: &str, default: &str| -> Vec<String> {
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] #[tokio::main]
@@ -36,14 +66,19 @@ async fn main() -> std::io::Result<()> {
std::process::exit(healthcheck(&bind_addr).await); std::process::exit(healthcheck(&bind_addr).await);
} }
let versions = VersionManifest::from_env();
println!("Client versions: {versions:?}");
let state = AppState { let state = AppState {
pool: Arc::new(user::init_db().await), pool: Arc::new(user::init_db().await),
sessions: Arc::new(Mutex::new(HashMap::new())), sessions: Arc::new(Mutex::new(HashMap::new())),
versions: Arc::new(versions),
}; };
let app = Router::new() let app = Router::new()
.route("/", get(ws)) .route("/", get(ws))
.route("/health", get(health)) .route("/health", get(health))
.route("/versions", get(versions_manifest))
.with_state(state); .with_state(state);
let listener = tokio::net::TcpListener::bind(&bind_addr).await?; let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
@@ -76,10 +111,15 @@ async fn health(State(state): State<AppState>) -> (StatusCode, &'static str) {
} }
} }
async fn versions_manifest(State(state): State<AppState>) -> Json<Arc<VersionManifest>> {
Json(state.versions)
}
async fn register_handlers(session: &Session, state: AppState) { 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 uuid = Arc::new(Mutex::new(String::new()));
let name = 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 session
.on_close({ .on_close({
@@ -106,20 +146,30 @@ async fn register_handlers(session: &Session, state: AppState) {
.await; .await;
session session
.on_request::<methods::Auth, _>({ .on_request::<methods::AuthChallenge, _>({
let uuid = Arc::clone(&uuid);
let pending = Arc::clone(&pending);
move |_, username| methods::auth::challenge(uuid.clone(), pending.clone(), username)
})
.await;
session
.on_request::<methods::AuthVerify, _>({
let pool = Arc::clone(&pool); let pool = Arc::clone(&pool);
let uuid = Arc::clone(&uuid); let uuid = Arc::clone(&uuid);
let sessions = Arc::clone(&sessions); let sessions = Arc::clone(&sessions);
let name = Arc::clone(&name); let name = Arc::clone(&name);
let pending = Arc::clone(&pending);
let session = session.clone(); let session = session.clone();
move |_, token| { move |_, ()| {
methods::auth::authenticate( methods::auth::verify(
sessions.clone(), sessions.clone(),
session.clone(), session.clone(),
name.clone(), name.clone(),
uuid.clone(), uuid.clone(),
token, pending.clone(),
pool.clone(), pool.clone(),
) )
} }
+68 -22
View File
@@ -2,63 +2,109 @@ use std::sync::Arc;
use session_rs::session::Session; use session_rs::session::Session;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use tokio::sync::Mutex;
use crate::{ use crate::{
types::{SessionMap, UUID}, types::{MinecraftAuthResponse, SessionMap, UUID},
user::User, 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<Mutex<Option<Challenge>>>;
/// 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<String, String> {
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, sessions: SessionMap,
session: Session, session: Session,
name: UUID, name: UUID,
uuid: UUID, uuid: UUID,
session_token: String, pending: PendingChallenge,
pool: Arc<SqlitePool>, pool: Arc<SqlitePool>,
) -> Result<User, String> { ) -> Result<User, String> {
if !uuid.lock().await.is_empty() { 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 let response = reqwest::Client::new()
.get("https://api.minecraftservices.com/minecraft/profile") .get(HAS_JOINED_URL)
.bearer_auth(&session_token) .query(&[
("username", challenge.username.as_str()),
("serverId", challenge.server_id.as_str()),
])
.send() .send()
.await .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() { if !response.status().is_success() {
return Err(format!( return Err(format!(
"Authentication failed with code {}", "Mojang session server returned {}",
response.status() response.status()
)); ));
} }
let auth = response let auth = response
.json::<crate::types::MinecraftAuthResponse>() .json::<MinecraftAuthResponse>()
.await .await
.map_err(|_| "Unable to parse auth response".to_string()) .map_err(|_| "Unable to parse Mojang response".to_string())?;
.and_then(|auth| {
let id = crate::types::format_uuid(&auth.id)?; let id = crate::types::format_uuid(&auth.id)?;
Ok(crate::types::MinecraftAuthResponse {
name: auth.name,
id,
})
})?;
*uuid.lock().await = auth.id.clone(); *uuid.lock().await = id.clone();
*name.lock().await = auth.name.clone(); *name.lock().await = auth.name.clone();
sessions sessions
.lock() .lock()
.await .await
.entry(auth.id.clone()) .entry(id.clone())
.or_default() .or_default()
.insert(session); .insert(session);
println!("{:?}", auth); println!("Authenticated {} ({id})", auth.name);
crate::user::get_put(&auth.id, &pool).await crate::user::get_put(&id, &pool).await
} }
+17 -3
View File
@@ -10,12 +10,26 @@ use crate::{
user::User, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Auth; pub struct AuthChallenge;
impl Method for Auth { impl Method for AuthChallenge {
const NAME: &'static str = "auth"; const NAME: &'static str = "auth_challenge";
type Request = String; 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 Response = User;
type Error = String; type Error = String;
} }