Files
SCOPENET-MC/panel/server/src/yggdrasil/mod.rs
T
SCOPEDD a1f19e86c6 Complete private authentication, server integrations and activity reporting
Add Fabric/Forge version builds and Paper integration, preserve permanent player identities across renames, harden session authorization, and surface privacy-conscious launcher/server activity in the panel.
2026-09-28 13:31:17 -04:00

743 lines
29 KiB
Rust

//! A Yggdrasil-compatible authentication server, following the
//! authlib-injector specification:
//! <https://github.com/yushijinhun/authlib-injector/wiki/Yggdrasil-%E6%9C%8D%E5%8A%A1%E7%AB%AF%E6%8A%80%E6%9C%AF%E8%A7%84%E8%8C%83>
//!
//! Game clients and servers run authlib-injector pointed at
//! `{panel}/api/yggdrasil`. Sign-in, server joins, skins and capes then all
//! come from the panel — no Mojang or Microsoft account needed.
pub mod keys;
use crate::auth::{self, UserRow};
use crate::error::AppResult;
use crate::net::{self, ClientIp};
use crate::state::AppState;
use crate::store;
use crate::textures;
use axum::body::Body;
use axum::extract::{Multipart, Path, Query, State};
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post, put};
use axum::{Json, Router};
use base64::Engine;
use rand::RngCore;
use scopenet_shared::{CapeInfo, PlayerProfile};
use serde::Deserialize;
use serde_json::{json, Value};
pub const ROOT: &str = "/api/yggdrasil";
const TOKEN_DAYS: i64 = 30;
const MAX_TOKENS_PER_USER: i64 = 10;
const SESSION_SECS: i64 = 60;
// ---------------------------------------------------------------------------
// Errors (Yggdrasil has its own error shape)
// ---------------------------------------------------------------------------
pub struct YggError {
status: StatusCode,
error: &'static str,
message: String,
}
impl YggError {
fn forbidden(message: impl Into<String>) -> Self {
Self { status: StatusCode::FORBIDDEN, error: "ForbiddenOperationException", message: message.into() }
}
fn bad_request(message: impl Into<String>) -> Self {
Self { status: StatusCode::BAD_REQUEST, error: "IllegalArgumentException", message: message.into() }
}
fn invalid_token() -> Self {
Self::forbidden("Invalid token.")
}
}
impl IntoResponse for YggError {
fn into_response(self) -> Response {
(self.status, Json(json!({ "error": self.error, "errorMessage": self.message }))).into_response()
}
}
impl From<crate::error::AppError> for YggError {
fn from(e: crate::error::AppError) -> Self {
Self { status: e.status, error: "InternalServerError", message: e.message }
}
}
impl From<sqlx::Error> for YggError {
fn from(e: sqlx::Error) -> Self {
crate::error::AppError::from(e).into()
}
}
type YggResult<T> = Result<T, YggError>;
fn no_content() -> Response {
StatusCode::NO_CONTENT.into_response()
}
// ---------------------------------------------------------------------------
// Helpers shared with the launcher/admin APIs
// ---------------------------------------------------------------------------
pub fn undashed(uuid: &str) -> String {
uuid.replace('-', "").to_ascii_lowercase()
}
pub fn dashed(uuid: &str) -> Option<String> {
let u = undashed(uuid);
(u.len() == 32 && u.chars().all(|c| c.is_ascii_hexdigit()))
.then(|| format!("{}-{}-{}-{}-{}", &u[0..8], &u[8..12], &u[12..16], &u[16..20], &u[20..32]))
}
fn random_token() -> String {
let mut b = [0u8; 16];
rand::thread_rng().fill_bytes(&mut b);
hex::encode(b)
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct CapeRow {
pub id: i64,
pub name: String,
pub hash: String,
pub visibility: String,
pub allowed_groups: String,
pub created_at: String,
}
impl CapeRow {
pub fn info(&self, base: &str) -> CapeInfo {
CapeInfo { id: self.id, name: self.name.clone(), url: texture_url(base, &self.hash) }
}
}
pub fn texture_url(base: &str, hash: &str) -> String {
format!("{base}/textures/{hash}")
}
pub async fn user_by_uuid(state: &AppState, uuid: &str) -> AppResult<Option<UserRow>> {
let Some(d) = dashed(uuid) else { return Ok(None) };
Ok(sqlx::query_as("SELECT * FROM users WHERE uuid = ?").bind(d).fetch_optional(&state.db).await?)
}
pub async fn cape_of(state: &AppState, user: &UserRow) -> AppResult<Option<CapeRow>> {
let Some(id) = user.cape_id else { return Ok(None) };
Ok(sqlx::query_as("SELECT * FROM capes WHERE id = ?").bind(id).fetch_optional(&state.db).await?)
}
/// Capes the player may choose themselves.
pub async fn available_capes(state: &AppState, user: &UserRow) -> AppResult<Vec<CapeRow>> {
let groups = auth::user_groups(state, user.id).await?;
let all: Vec<CapeRow> = sqlx::query_as("SELECT * FROM capes ORDER BY name COLLATE NOCASE").fetch_all(&state.db).await?;
Ok(all
.into_iter()
.filter(|c| {
user.is_admin()
|| c.visibility == "public"
|| (c.visibility == "groups" && {
let allowed: Vec<String> = serde_json::from_str(&c.allowed_groups).unwrap_or_default();
allowed.iter().any(|g| groups.iter().any(|x| x.eq_ignore_ascii_case(g)))
})
})
.collect())
}
pub async fn player_profile(state: &AppState, base: &str, user: &UserRow) -> AppResult<PlayerProfile> {
Ok(PlayerProfile {
uuid: user.uuid.clone(),
name: user.username.clone(),
skin_url: user.skin_hash.as_deref().map(|h| texture_url(base, h)),
skin_model: user.skin_model.clone(),
cape: cape_of(state, user).await?.map(|c| c.info(base)),
available_capes: available_capes(state, user).await?.iter().map(|c| c.info(base)).collect(),
})
}
/// The base64 `textures` property value.
async fn textures_value(state: &AppState, base: &str, user: &UserRow) -> AppResult<String> {
let mut textures = serde_json::Map::new();
if let Some(hash) = &user.skin_hash {
let mut skin = json!({ "url": texture_url(base, hash) });
if user.skin_model == "slim" {
skin["metadata"] = json!({ "model": "slim" });
}
textures.insert("SKIN".into(), skin);
}
if let Some(cape) = cape_of(state, user).await? {
textures.insert("CAPE".into(), json!({ "url": texture_url(base, &cape.hash) }));
}
let value = json!({
"timestamp": chrono::Utc::now().timestamp_millis(),
"profileId": undashed(&user.uuid),
"profileName": user.username,
"textures": textures,
});
Ok(base64::engine::general_purpose::STANDARD.encode(value.to_string()))
}
/// A full game profile, optionally with signed properties.
pub async fn profile_json(state: &AppState, base: &str, user: &UserRow, signed: bool) -> AppResult<Value> {
let value = textures_value(state, base, user).await?;
let mut textures = json!({ "name": "textures", "value": value });
let mut uploadable = json!({ "name": "uploadableTextures", "value": "skin" });
if signed {
textures["signature"] = json!(state.ygg.sign_b64(value.as_bytes()));
uploadable["signature"] = json!(state.ygg.sign_b64(b"skin"));
}
Ok(json!({ "id": undashed(&user.uuid), "name": user.username, "properties": [textures, uploadable] }))
}
fn short_profile(user: &UserRow) -> Value {
json!({ "id": undashed(&user.uuid), "name": user.username })
}
/// Issue a game access token for `user_id`.
pub async fn issue_token(state: &AppState, user_id: i64, client_token: Option<String>) -> AppResult<(String, String)> {
let access = random_token();
let client = client_token.filter(|c| !c.is_empty() && c.len() <= 128).unwrap_or_else(random_token);
let now = chrono::Utc::now();
sqlx::query("DELETE FROM ygg_tokens WHERE expires_at < ?").bind(crate::db::now()).execute(&state.db).await?;
sqlx::query("INSERT INTO ygg_tokens (access_token, client_token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?)")
.bind(&access)
.bind(&client)
.bind(user_id)
.bind(crate::db::now())
.bind((now + chrono::Duration::days(TOKEN_DAYS)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.execute(&state.db)
.await?;
// Keep only the newest few sessions per account.
sqlx::query(
"DELETE FROM ygg_tokens WHERE user_id = ? AND access_token NOT IN
(SELECT access_token FROM ygg_tokens WHERE user_id = ? ORDER BY created_at DESC LIMIT ?)",
)
.bind(user_id)
.bind(user_id)
.bind(MAX_TOKENS_PER_USER)
.execute(&state.db)
.await?;
Ok((access, client))
}
/// The active account behind a valid token.
pub async fn token_user(state: &AppState, access: &str, client: Option<&str>) -> AppResult<Option<UserRow>> {
let row: Option<(i64, String)> =
sqlx::query_as("SELECT user_id, client_token FROM ygg_tokens WHERE access_token = ? AND expires_at > ?")
.bind(access)
.bind(crate::db::now())
.fetch_optional(&state.db)
.await?;
let Some((user_id, client_token)) = row else { return Ok(None) };
if client.is_some_and(|c| !c.is_empty() && c != client_token) {
return Ok(None);
}
let user: Option<UserRow> = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(user_id).fetch_optional(&state.db).await?;
Ok(user.filter(|u| u.status == "active"))
}
async fn check_password(state: &AppState, username: &str, password: &str) -> YggResult<UserRow> {
state.login_guard.check(username).map_err(|e| YggError::forbidden(e.message))?;
// Email or username (`feature.non_email_login`).
let user: Option<UserRow> = sqlx::query_as("SELECT * FROM users WHERE username = ? OR (email IS NOT NULL AND email = ?)")
.bind(username.trim())
.bind(username.trim())
.fetch_optional(&state.db)
.await?;
let Some(user) = user.filter(|u| auth::verify_password(password, &u.password_hash)) else {
state.login_guard.fail(username);
return Err(YggError::forbidden("Invalid credentials. Invalid username or password."));
};
state.login_guard.succeed(username);
match user.status.as_str() {
"active" => Ok(user),
"pending" => Err(YggError::forbidden("Your account is waiting for an admin to approve it.")),
_ => Err(YggError::forbidden(user.status_reason.clone().unwrap_or_else(|| "This account has been disabled.".into()))),
}
}
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
async fn metadata(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let base = net::public_base(&state, &headers).await;
let branding = store::branding(&state).await?;
Ok(Json(json!({
"meta": {
"serverName": branding.name,
"implementationName": "SCOPENET",
"implementationVersion": env!("CARGO_PKG_VERSION"),
"links": { "homepage": base, "register": base },
"feature.non_email_login": true,
"feature.enable_profile_key": true,
"feature.no_mojang_namespace": true,
},
"skinDomains": [net::host_of(&base)],
"signaturePublickey": state.ygg.public_pem,
})))
}
// ---------------------------------------------------------------------------
// authserver
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AuthenticateReq {
username: String,
password: String,
#[serde(default)]
client_token: Option<String>,
#[serde(default)]
request_user: bool,
}
fn user_json(user: &UserRow) -> Value {
json!({ "id": undashed(&user.uuid), "properties": [{ "name": "preferredLanguage", "value": "en" }] })
}
async fn authenticate(State(state): State<AppState>, Json(req): Json<AuthenticateReq>) -> YggResult<Json<Value>> {
let user = check_password(&state, &req.username, &req.password).await?;
let (access, client) = issue_token(&state, user.id, req.client_token).await?;
let mut body = json!({
"accessToken": access,
"clientToken": client,
"availableProfiles": [short_profile(&user)],
"selectedProfile": short_profile(&user),
});
if req.request_user {
body["user"] = user_json(&user);
}
Ok(Json(body))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct RefreshReq {
access_token: String,
#[serde(default)]
client_token: Option<String>,
#[serde(default)]
request_user: bool,
}
async fn refresh(State(state): State<AppState>, Json(req): Json<RefreshReq>) -> YggResult<Json<Value>> {
let client_token: Option<String> = sqlx::query_scalar("SELECT client_token FROM ygg_tokens WHERE access_token = ?")
.bind(&req.access_token)
.fetch_optional(&state.db)
.await?;
let user = token_user(&state, &req.access_token, req.client_token.as_deref()).await?.ok_or_else(YggError::invalid_token)?;
sqlx::query("DELETE FROM ygg_tokens WHERE access_token = ?").bind(&req.access_token).execute(&state.db).await?;
let (access, client) = issue_token(&state, user.id, client_token).await?;
let mut body = json!({ "accessToken": access, "clientToken": client, "selectedProfile": short_profile(&user) });
if req.request_user {
body["user"] = user_json(&user);
}
Ok(Json(body))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct TokenReq {
access_token: String,
#[serde(default)]
client_token: Option<String>,
}
async fn validate(State(state): State<AppState>, Json(req): Json<TokenReq>) -> YggResult<Response> {
token_user(&state, &req.access_token, req.client_token.as_deref()).await?.ok_or_else(YggError::invalid_token)?;
Ok(no_content())
}
async fn invalidate(State(state): State<AppState>, Json(req): Json<TokenReq>) -> YggResult<Response> {
sqlx::query("DELETE FROM ygg_tokens WHERE access_token = ?").bind(&req.access_token).execute(&state.db).await?;
Ok(no_content())
}
#[derive(Deserialize)]
struct SignoutReq {
username: String,
password: String,
}
async fn signout(State(state): State<AppState>, Json(req): Json<SignoutReq>) -> YggResult<Response> {
let user = check_password(&state, &req.username, &req.password).await?;
sqlx::query("DELETE FROM ygg_tokens WHERE user_id = ?").bind(user.id).execute(&state.db).await?;
Ok(no_content())
}
// ---------------------------------------------------------------------------
// sessionserver
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct JoinReq {
access_token: String,
selected_profile: String,
server_id: String,
}
async fn join(State(state): State<AppState>, ClientIp(ip): ClientIp, Json(req): Json<JoinReq>) -> YggResult<Response> {
let user = token_user(&state, &req.access_token, None).await?.ok_or_else(YggError::invalid_token)?;
if undashed(&req.selected_profile) != undashed(&user.uuid) {
return Err(YggError::forbidden("Invalid token."));
}
if req.server_id.is_empty() || req.server_id.len() > 64 {
return Err(YggError::bad_request("invalid serverId"));
}
let cutoff = (chrono::Utc::now() - chrono::Duration::seconds(SESSION_SECS)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
sqlx::query("DELETE FROM ygg_sessions WHERE created_at < ?").bind(cutoff).execute(&state.db).await?;
sqlx::query("INSERT OR REPLACE INTO ygg_sessions (server_id, user_id, ip, created_at) VALUES (?, ?, ?, ?)")
.bind(&req.server_id)
.bind(user.id)
.bind(ip)
.bind(crate::db::now())
.execute(&state.db)
.await?;
Ok(no_content())
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct HasJoinedQuery {
username: String,
server_id: String,
#[serde(default)]
ip: Option<String>,
}
async fn has_joined(State(state): State<AppState>, headers: HeaderMap, Query(q): Query<HasJoinedQuery>) -> YggResult<Response> {
let cutoff = (chrono::Utc::now() - chrono::Duration::seconds(SESSION_SECS)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let row: Option<(i64, Option<String>)> = sqlx::query_as("SELECT user_id, ip FROM ygg_sessions WHERE server_id = ? AND created_at >= ?")
.bind(&q.server_id)
.bind(cutoff)
.fetch_optional(&state.db)
.await?;
let Some((user_id, joined_ip)) = row else { return Ok(no_content()) };
let Some(user): Option<UserRow> =
sqlx::query_as("SELECT * FROM users WHERE id = ? AND status = 'active'").bind(user_id).fetch_optional(&state.db).await?
else {
return Ok(no_content());
};
if !user.username.eq_ignore_ascii_case(&q.username) {
return Ok(no_content());
}
if let Some(expected) = q.ip.as_deref().filter(|i| !i.is_empty()) {
if joined_ip.as_deref() != Some(expected) {
return Ok(no_content());
}
}
let base = net::public_base(&state, &headers).await;
Ok(Json(profile_json(&state, &base, &user, true).await?).into_response())
}
#[derive(Deserialize)]
struct ProfileQuery {
#[serde(default)]
unsigned: Option<String>,
}
async fn session_profile(
State(state): State<AppState>,
headers: HeaderMap,
Path(uuid): Path<String>,
Query(q): Query<ProfileQuery>,
) -> YggResult<Response> {
let Some(user) = user_by_uuid(&state, &uuid).await? else { return Ok(no_content()) };
let signed = q.unsigned.as_deref() == Some("false");
let base = net::public_base(&state, &headers).await;
Ok(Json(profile_json(&state, &base, &user, signed).await?).into_response())
}
// ---------------------------------------------------------------------------
// Mojang-style profile API
// ---------------------------------------------------------------------------
async fn profiles_by_names(State(state): State<AppState>, Json(names): Json<Vec<String>>) -> YggResult<Json<Vec<Value>>> {
let mut out = Vec::new();
for name in names.iter().take(100) {
if let Some(user) = auth::find_user_by_name(&state, name).await? {
if !out.iter().any(|v: &Value| v["id"] == undashed(&user.uuid)) {
out.push(short_profile(&user));
}
}
}
Ok(Json(out))
}
async fn profile_by_name(State(state): State<AppState>, Path(name): Path<String>) -> YggResult<Response> {
match auth::find_user_by_name(&state, &name).await? {
Some(user) => Ok(Json(short_profile(&user)).into_response()),
None => Ok(no_content()),
}
}
fn bearer(headers: &HeaderMap) -> Option<&str> {
headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")))
}
async fn bearer_user(state: &AppState, headers: &HeaderMap) -> YggResult<UserRow> {
let token = bearer(headers).ok_or_else(|| YggError {
status: StatusCode::UNAUTHORIZED,
error: "Unauthorized",
message: "Missing token.".into(),
})?;
token_user(state, token, None).await?.ok_or_else(|| YggError {
status: StatusCode::UNAUTHORIZED,
error: "Unauthorized",
message: "Invalid token.".into(),
})
}
/// `PUT /api/user/profile/{uuid}/skin` (multipart: `model`, `file`).
async fn upload_texture(
State(state): State<AppState>,
headers: HeaderMap,
Path((uuid, kind)): Path<(String, String)>,
mut form: Multipart,
) -> YggResult<Response> {
let user = bearer_user(&state, &headers).await?;
if undashed(&uuid) != undashed(&user.uuid) {
return Err(YggError::forbidden("You can only change your own skin."));
}
if kind != "skin" {
return Err(YggError::forbidden("Capes are assigned by the server admins."));
}
let mut model = String::from("classic");
let mut file = None;
while let Some(field) = form.next_field().await.map_err(|e| YggError::bad_request(e.to_string()))? {
match field.name().unwrap_or_default() {
"model" => model = field.text().await.map_err(|e| YggError::bad_request(e.to_string()))?,
"file" => file = Some(field.bytes().await.map_err(|e| YggError::bad_request(e.to_string()))?),
_ => {}
}
}
let bytes = file.ok_or_else(|| YggError::bad_request("no file"))?;
set_skin(&state, user.id, &bytes, &model).await?;
Ok(no_content())
}
async fn delete_texture(
State(state): State<AppState>,
headers: HeaderMap,
Path((uuid, kind)): Path<(String, String)>,
) -> YggResult<Response> {
let user = bearer_user(&state, &headers).await?;
if undashed(&uuid) != undashed(&user.uuid) || kind != "skin" {
return Err(YggError::forbidden("Not allowed."));
}
sqlx::query("UPDATE users SET skin_hash = NULL WHERE id = ?").bind(user.id).execute(&state.db).await?;
Ok(no_content())
}
/// Store a skin for a user (validated PNG, "classic" or "slim").
pub async fn set_skin(state: &AppState, user_id: i64, bytes: &[u8], model: &str) -> AppResult<()> {
let model = if model == "slim" { "slim" } else { "classic" };
let dir = state.cfg.textures_dir();
let data = bytes.to_vec();
let hash = tokio::task::spawn_blocking(move || textures::store(&dir, textures::Kind::Skin, &data))
.await
.map_err(|e| crate::error::AppError::bad_request(e.to_string()))??;
sqlx::query("UPDATE users SET skin_hash = ?, skin_model = ? WHERE id = ?")
.bind(hash)
.bind(model)
.bind(user_id)
.execute(&state.db)
.await?;
Ok(())
}
// ---------------------------------------------------------------------------
// minecraftservices (1.19+ chat signing, social features)
// ---------------------------------------------------------------------------
fn iso_millis(t: chrono::DateTime<chrono::Utc>) -> String {
t.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
}
/// PEM exactly as Minecraft's `Crypt.rsaPublicKeyToString` writes it (MIME
/// base64: 76-char lines, CRLF) — the V1 signature covers this text.
fn mojang_pem(label: &str, der: &[u8]) -> String {
let b64 = base64::engine::general_purpose::STANDARD.encode(der);
let lines: Vec<&str> = b64.as_bytes().chunks(76).map(|c| std::str::from_utf8(c).unwrap()).collect();
format!("-----BEGIN {label}-----\n{}\n-----END {label}-----\n", lines.join("\r\n"))
}
#[derive(sqlx::FromRow)]
struct PlayerKeyRow {
private_pem: String,
public_pem: String,
signature_v1: String,
signature_v2: String,
expires_at: String,
refreshed_after: String,
}
async fn player_certificates(State(state): State<AppState>, headers: HeaderMap) -> YggResult<Json<Value>> {
let user = bearer_user(&state, &headers).await?;
let existing: Option<PlayerKeyRow> =
sqlx::query_as("SELECT * FROM player_keys WHERE user_id = ?").bind(user.id).fetch_optional(&state.db).await?;
let row = match existing.filter(|k| k.refreshed_after > iso_millis(chrono::Utc::now())) {
Some(k) => k,
None => {
let row = generate_player_key(&state, &user).await?;
sqlx::query(
"INSERT OR REPLACE INTO player_keys (user_id, private_pem, public_pem, signature_v1, signature_v2, expires_at, refreshed_after)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(user.id)
.bind(&row.private_pem)
.bind(&row.public_pem)
.bind(&row.signature_v1)
.bind(&row.signature_v2)
.bind(&row.expires_at)
.bind(&row.refreshed_after)
.execute(&state.db)
.await?;
row
}
};
Ok(Json(json!({
"keyPair": { "privateKey": row.private_pem, "publicKey": row.public_pem },
"publicKeySignature": row.signature_v1,
"publicKeySignatureV2": row.signature_v2,
"expiresAt": row.expires_at,
"refreshedAfter": row.refreshed_after,
})))
}
async fn generate_player_key(state: &AppState, user: &UserRow) -> YggResult<PlayerKeyRow> {
use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey};
let key = tokio::task::spawn_blocking(|| rsa::RsaPrivateKey::new(&mut rand::thread_rng(), 2048))
.await
.map_err(|e| YggError::bad_request(e.to_string()))?
.map_err(|e| YggError::bad_request(e.to_string()))?;
let public_der = rsa::RsaPublicKey::from(&key).to_public_key_der().map_err(|e| YggError::bad_request(e.to_string()))?;
let private_der = key.to_pkcs8_der().map_err(|e| YggError::bad_request(e.to_string()))?;
let now = chrono::Utc::now();
let expires = now + chrono::Duration::hours(48);
let refreshed_after = now + chrono::Duration::hours(40);
let public_pem = mojang_pem("RSA PUBLIC KEY", public_der.as_bytes());
// V2 (1.19.1+): uuid msb, uuid lsb, expiry millis (big-endian), key DER.
let uuid = uuid::Uuid::parse_str(&user.uuid).map_err(|e| YggError::bad_request(e.to_string()))?;
let mut v2 = Vec::with_capacity(24 + public_der.as_bytes().len());
v2.extend_from_slice(uuid.as_bytes());
v2.extend_from_slice(&expires.timestamp_millis().to_be_bytes());
v2.extend_from_slice(public_der.as_bytes());
// V1 (1.19.0): expiry millis as text + the PEM text.
let v1 = format!("{}{}", expires.timestamp_millis(), public_pem);
Ok(PlayerKeyRow {
private_pem: mojang_pem("RSA PRIVATE KEY", private_der.as_bytes()),
signature_v1: state.ygg.sign_b64(v1.as_bytes()),
signature_v2: state.ygg.sign_b64(&v2),
public_pem,
expires_at: iso_millis(expires),
refreshed_after: iso_millis(refreshed_after),
})
}
async fn player_attributes(State(state): State<AppState>, headers: HeaderMap) -> YggResult<Json<Value>> {
bearer_user(&state, &headers).await?;
Ok(Json(json!({
"privileges": {
"onlineChat": { "enabled": true },
"multiplayerServer": { "enabled": true },
"multiplayerRealms": { "enabled": false },
"telemetry": { "enabled": false },
},
"profanityFilterPreferences": { "profanityFilterOn": false },
})))
}
async fn blocklist(State(state): State<AppState>, headers: HeaderMap) -> YggResult<Json<Value>> {
bearer_user(&state, &headers).await?;
Ok(Json(json!({ "blockedProfiles": [] })))
}
async fn public_keys(State(state): State<AppState>) -> Json<Value> {
let key = json!([{ "publicKey": state.ygg.public_der_b64 }]);
Json(json!({ "profilePropertyKeys": key, "playerCertificateKeys": key }))
}
async fn services_profile(State(state): State<AppState>, headers: HeaderMap) -> YggResult<Json<Value>> {
let user = bearer_user(&state, &headers).await?;
let base = net::public_base(&state, &headers).await;
let skins: Vec<Value> = user
.skin_hash
.iter()
.map(|h| json!({ "id": h, "state": "ACTIVE", "url": texture_url(&base, h), "variant": if user.skin_model == "slim" { "SLIM" } else { "CLASSIC" } }))
.collect();
let capes: Vec<Value> = cape_of(&state, &user)
.await?
.iter()
.map(|c| json!({ "id": c.id.to_string(), "state": "ACTIVE", "url": texture_url(&base, &c.hash), "alias": c.name }))
.collect();
Ok(Json(json!({ "id": undashed(&user.uuid), "name": user.username, "skins": skins, "capes": capes })))
}
// ---------------------------------------------------------------------------
// Texture files
// ---------------------------------------------------------------------------
pub async fn texture_file(State(state): State<AppState>, Path(hash): Path<String>) -> Response {
let Some(path) = textures::path(&state.cfg.textures_dir(), &hash) else { return StatusCode::NOT_FOUND.into_response() };
match tokio::fs::read(path).await {
Ok(bytes) => {
([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=31536000, immutable")], Body::from(bytes))
.into_response()
}
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
pub fn routes() -> Router<AppState> {
let p = |path: &str| format!("{ROOT}{path}");
Router::new()
.route(ROOT, get(metadata))
.route(&p("/"), get(metadata))
.route(&p("/authserver/authenticate"), post(authenticate))
.route(&p("/authserver/refresh"), post(refresh))
.route(&p("/authserver/validate"), post(validate))
.route(&p("/authserver/invalidate"), post(invalidate))
.route(&p("/authserver/signout"), post(signout))
.route(&p("/sessionserver/session/minecraft/join"), post(join))
.route(&p("/sessionserver/session/minecraft/hasJoined"), get(has_joined))
.route(&p("/sessionserver/session/minecraft/profile/{uuid}"), get(session_profile))
.route(&p("/api/profiles/minecraft"), post(profiles_by_names))
.route(&p("/api/users/profiles/minecraft/{name}"), get(profile_by_name))
.route(&p("/api/user/profile/{uuid}/{kind}"), put(upload_texture).delete(delete_texture))
.route(&p("/minecraftservices/player/certificates"), post(player_certificates))
.route(&p("/minecraftservices/player/attributes"), get(player_attributes))
.route(&p("/minecraftservices/privacy/blocklist"), get(blocklist))
.route(&p("/minecraftservices/publickeys"), get(public_keys))
.route(&p("/minecraftservices/minecraft/profile"), get(services_profile))
.route("/textures/{hash}", get(texture_file))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uuid_forms() {
assert_eq!(undashed("B50AD385-829D-3141-A216-7E7D7539BA7F"), "b50ad385829d3141a2167e7d7539ba7f");
assert_eq!(dashed("b50ad385829d3141a2167e7d7539ba7f").as_deref(), Some("b50ad385-829d-3141-a216-7e7d7539ba7f"));
assert!(dashed("nope").is_none());
}
#[test]
fn pem_matches_java_mime_layout() {
let pem = mojang_pem("RSA PUBLIC KEY", &[7u8; 100]);
assert!(pem.starts_with("-----BEGIN RSA PUBLIC KEY-----\n"));
assert!(pem.ends_with("\n-----END RSA PUBLIC KEY-----\n"));
assert!(pem.contains("\r\n"), "76-column MIME lines separated by CRLF");
}
}