Panel: Yggdrasil auth server (authlib-injector), skins and capes

- Yggdrasil API per the authlib-injector spec: metadata with signing key
  and skin domains, authenticate/refresh/validate/invalidate/signout,
  join/hasJoined, profile lookup, texture upload, and the minecraftservices
  endpoints (chat certificates, publickeys, attributes, blocklist)
- 4096-bit signing key generated once into the data volume; textures and
  chat certificates signed SHA1withRSA (verified with Java's own crypto)
- Skins/capes stored content-addressed after validation and re-encoding;
  cape library with public/group/private visibility; head avatars API
- Launcher login returns a game session; launcher sessions recorded for
  launcher-only servers; authlib-injector download mirror
- Schema v2: player UUIDs (offline UUID backfilled), skins, capes, tokens,
  sessions, chat keys, game server tables
- Remove Microsoft sign-in from the engine and panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
This commit is contained in:
Claude committed 2026-09-28 06:57:07 +00:00
1 parent 1b1bb984bc
commit 99b45141fc
29 files changed
+2436 -400

No files matched your search

+2
View File
@@ -13,6 +13,8 @@ tokio.workspace = true
reqwest.workspace = true
futures.workspace = true
sha1.workspace = true
sha2.workspace = true
base64.workspace = true
hex.workspace = true
zip.workspace = true
tracing.workspace = true
+130
View File
@@ -0,0 +1,130 @@
//! authlib-injector: a small Java agent that points the game's
//! authentication (sessions, skins, profile signatures) at the panel's
//! Yggdrasil server instead of Mojang's.
//!
//! The launcher fetches it from the panel's mirror first and falls back to
//! the official download, verifying the SHA-256 either way.
use crate::http::{self, Download};
use crate::paths::Layout;
use anyhow::{anyhow, bail, Context, Result};
use base64::Engine;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
pub const OFFICIAL_LATEST: &str = "https://authlib-injector.yushi.moe/artifact/latest.json";
/// Shape of `latest.json` (the panel mirror uses the same format).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Artifact {
pub build_number: u64,
pub version: String,
pub download_url: String,
pub checksums: Checksums,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Checksums {
pub sha256: String,
}
pub fn sha256_file(path: &Path) -> Result<String> {
let bytes = std::fs::read(path)?;
Ok(hex::encode(Sha256::digest(&bytes)))
}
fn cache_dir(layout: &Layout) -> PathBuf {
layout.cache().join("authlib-injector")
}
/// Newest jar already on disk (used when offline).
fn newest_cached(layout: &Layout) -> Option<PathBuf> {
std::fs::read_dir(cache_dir(layout))
.ok()?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "jar"))
.max_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok())
}
async fn from_source(client: &reqwest::Client, layout: &Layout, index_url: &str, base: Option<&str>) -> Result<PathBuf> {
let artifact: Artifact = http::get_json(client, index_url).await?;
let url = match base {
Some(b) => crate::sync::resolve_url(b, &artifact.download_url),
None => artifact.download_url.clone(),
};
let dest = cache_dir(layout).join(format!("authlib-injector-{}.jar", artifact.version));
let expected = artifact.checksums.sha256.to_ascii_lowercase();
if dest.exists() && sha256_file(&dest)? == expected {
return Ok(dest);
}
http::download_one(client, &Download::new(url, dest.clone(), None, None), &|_| {}).await?;
let got = sha256_file(&dest)?;
if got != expected {
std::fs::remove_file(&dest).ok();
bail!("authlib-injector checksum mismatch (expected {expected}, got {got})");
}
Ok(dest)
}
/// Make sure authlib-injector is available locally and return its path.
pub async fn ensure(client: &reqwest::Client, layout: &Layout, panel_base: Option<&str>) -> Result<PathBuf> {
let mut errors = Vec::new();
if let Some(base) = panel_base.filter(|b| !b.is_empty()) {
let index = format!("{}/api/v1/launcher/authlib-injector.json", base.trim_end_matches('/'));
match from_source(client, layout, &index, Some(base)).await {
Ok(p) => return Ok(p),
Err(e) => errors.push(format!("panel mirror: {e:#}")),
}
}
match from_source(client, layout, OFFICIAL_LATEST, None).await {
Ok(p) => return Ok(p),
Err(e) => errors.push(format!("official download: {e:#}")),
}
if let Some(cached) = newest_cached(layout) {
tracing::warn!("using cached authlib-injector ({})", errors.join("; "));
return Ok(cached);
}
Err(anyhow!("couldn't download authlib-injector: {}", errors.join("; ")))
}
/// Fetch the Yggdrasil metadata so it can be handed to the agent up front
/// (saves the game a network round-trip at startup).
pub async fn prefetch_metadata(client: &reqwest::Client, api_url: &str) -> Result<String> {
let resp = client.get(api_url).send().await?.error_for_status().context("fetching auth server metadata")?;
let bytes = resp.bytes().await?;
// Must be valid JSON, or the agent would refuse to start.
serde_json::from_slice::<serde_json::Value>(&bytes).context("auth server metadata isn't JSON")?;
Ok(base64::engine::general_purpose::STANDARD.encode(&bytes))
}
/// JVM arguments that load the agent. They must come before the main class.
pub fn jvm_args(jar: &Path, api_url: &str, prefetched: Option<&str>) -> Vec<String> {
let mut args = vec![format!("-javaagent:{}={}", jar.display(), api_url)];
if let Some(p) = prefetched {
args.push(format!("-Dauthlibinjector.yggdrasil.prefetched={p}"));
}
args
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_agent_args() {
let args = jvm_args(Path::new("/c/ai.jar"), "https://panel.example/api/yggdrasil", Some("e30="));
assert_eq!(args[0], "-javaagent:/c/ai.jar=https://panel.example/api/yggdrasil");
assert_eq!(args[1], "-Dauthlibinjector.yggdrasil.prefetched=e30=");
}
#[test]
fn parses_latest_json() {
let a: Artifact = serde_json::from_str(
r#"{"build_number":53,"version":"1.2.5","release_time":"x","download_url":"https://x/a.jar","checksums":{"sha256":"ab"}}"#,
)
.unwrap();
assert_eq!(a.version, "1.2.5");
}
}
+7 -6
View File
@@ -27,14 +27,15 @@ pub struct Auth {
/// Dashed or undashed UUID.
pub uuid: String,
pub access_token: String,
/// "msa" for Microsoft accounts, "legacy" for offline.
/// "mojang" for panel (Yggdrasil) accounts, "legacy" for offline.
pub user_type: String,
pub xuid: Option<String>,
}
#[derive(Debug, Clone)]
pub struct LaunchOptions {
pub auth: Auth,
/// JVM arguments that must come first (the authlib-injector agent).
pub agent_args: Vec<String>,
pub game_dir: PathBuf,
pub memory_min_mb: u32,
pub memory_max_mb: u32,
@@ -187,7 +188,7 @@ pub fn build(installed: &Installed, layout: &Layout, opts: &LaunchOptions) -> Co
vars.insert("auth_access_token", opts.auth.access_token.clone());
vars.insert("auth_session", format!("token:{}:{}", opts.auth.access_token, uuid));
vars.insert("clientid", String::new());
vars.insert("auth_xuid", opts.auth.xuid.clone().unwrap_or_default());
vars.insert("auth_xuid", String::new());
vars.insert("user_type", opts.auth.user_type.clone());
vars.insert("user_properties", "{}".into());
vars.insert("version_type", opts.version_label.clone());
@@ -203,7 +204,7 @@ pub fn build(installed: &Installed, layout: &Layout, opts: &LaunchOptions) -> Co
vars.insert("quickPlayMultiplayer", format!("{host}:{port}"));
}
let mut args = Vec::new();
let mut args = opts.agent_args.clone();
args.push(format!("-Xms{}M", opts.memory_min_mb.min(opts.memory_max_mb)));
args.push(format!("-Xmx{}M", opts.memory_max_mb));
args.extend(gc_args(opts.gc, installed.java_major));
@@ -336,8 +337,8 @@ mod tests {
uuid: "b50ad385-829d-3141-a216-7e7d7539ba7f".into(),
access_token: "0".into(),
user_type: "legacy".into(),
xuid: None,
},
agent_args: vec!["-javaagent:/a.jar=https://p/api/yggdrasil".into()],
game_dir: "/g".into(),
memory_min_mb: 1024,
memory_max_mb: 4096,
@@ -362,7 +363,7 @@ mod tests {
let layout = Layout::new("/root");
let cmd = build(&installed(json), &layout, &opts(true));
let a = cmd.args.join(" ");
assert!(a.starts_with("-Xms1024M -Xmx4096M"));
assert!(a.starts_with("-javaagent:/a.jar=https://p/api/yggdrasil -Xms1024M -Xmx4096M"), "agent must come first");
assert!(a.contains("-Dcustom=1"));
assert!(a.contains("--uuid b50ad385829d3141a2167e7d7539ba7f"));
assert!(a.contains("--width 1280 --height 720"));
+1 -1
View File
@@ -5,6 +5,7 @@
//! tested without a window.
pub mod assets;
pub mod authlib;
pub mod http;
pub mod install;
pub mod java;
@@ -13,7 +14,6 @@ pub mod libraries;
pub mod loaders;
pub mod maven;
pub mod meta;
pub mod msa;
pub mod options;
pub mod paths;
pub mod ping;
-212
View File
@@ -1,212 +0,0 @@
//! Microsoft account sign-in (device-code flow → Xbox Live → Minecraft).
//!
//! Requires an Azure app registration ("client id") that Mojang has approved
//! for the Minecraft API. The admin configures it in the panel.
use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::time::Duration;
const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode";
const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token";
const SCOPE: &str = "XboxLive.signin offline_access";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceCode {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub expires_in: u64,
#[serde(default = "default_interval")]
pub interval: u64,
#[serde(default)]
pub message: String,
}
fn default_interval() -> u64 {
5
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: Option<String>,
refresh_token: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MsaSession {
pub refresh_token: String,
pub mc_access_token: String,
/// Unix seconds.
pub mc_expires_at: i64,
pub profile: McProfile,
pub xuid: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McProfile {
pub id: String,
pub name: String,
#[serde(default)]
pub skins: Vec<McSkin>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McSkin {
pub url: String,
#[serde(default)]
pub state: String,
#[serde(default)]
pub variant: Option<String>,
}
pub async fn start_device_code(client: &reqwest::Client, client_id: &str) -> Result<DeviceCode> {
let resp = client.post(DEVICE_CODE_URL).form(&[("client_id", client_id), ("scope", SCOPE)]).send().await?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
bail!("Microsoft rejected the sign-in request: {body}");
}
Ok(resp.json().await?)
}
/// Poll until the user finishes signing in, then complete the full chain.
pub async fn finish_device_code(client: &reqwest::Client, client_id: &str, code: &DeviceCode) -> Result<MsaSession> {
let mut interval = code.interval.max(1);
let deadline = std::time::Instant::now() + Duration::from_secs(code.expires_in);
loop {
if std::time::Instant::now() > deadline {
bail!("the sign-in code expired, please try again");
}
tokio::time::sleep(Duration::from_secs(interval)).await;
let resp: TokenResponse = client
.post(TOKEN_URL)
.form(&[
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("client_id", client_id),
("device_code", code.device_code.as_str()),
])
.send()
.await?
.json()
.await?;
match resp.error.as_deref() {
None => {
let access = resp.access_token.ok_or_else(|| anyhow!("no access token"))?;
let refresh = resp.refresh_token.ok_or_else(|| anyhow!("no refresh token"))?;
return complete(client, &access, refresh).await;
}
Some("authorization_pending") => continue,
Some("slow_down") => interval += 5,
Some("authorization_declined") => bail!("sign-in was cancelled"),
Some("expired_token") => bail!("the sign-in code expired, please try again"),
Some(other) => bail!("Microsoft sign-in failed: {other} {}", resp.error_description.unwrap_or_default()),
}
}
}
/// Refresh a saved session (used before each launch when the Minecraft
/// token is close to expiring).
pub async fn refresh(client: &reqwest::Client, client_id: &str, refresh_token: &str) -> Result<MsaSession> {
let resp: TokenResponse = client
.post(TOKEN_URL)
.form(&[("grant_type", "refresh_token"), ("client_id", client_id), ("refresh_token", refresh_token), ("scope", SCOPE)])
.send()
.await?
.json()
.await?;
if let Some(err) = resp.error {
bail!("your Microsoft session expired ({err}); please sign in again");
}
let access = resp.access_token.ok_or_else(|| anyhow!("no access token"))?;
let refresh = resp.refresh_token.unwrap_or_else(|| refresh_token.to_string());
complete(client, &access, refresh).await
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct XboxResponse {
token: String,
display_claims: DisplayClaims,
}
#[derive(Deserialize)]
struct DisplayClaims {
xui: Vec<Xui>,
}
#[derive(Deserialize)]
struct Xui {
uhs: String,
#[serde(default)]
xid: Option<String>,
}
#[derive(Deserialize)]
struct McLogin {
access_token: String,
expires_in: i64,
}
async fn complete(client: &reqwest::Client, ms_access: &str, refresh_token: String) -> Result<MsaSession> {
// Xbox Live
let xbl: XboxResponse = client
.post("https://user.auth.xboxlive.com/user/authenticate")
.json(&json!({
"Properties": {"AuthMethod": "RPS", "SiteName": "user.auth.xboxlive.com", "RpsTicket": format!("d={ms_access}")},
"RelyingParty": "http://auth.xboxlive.com",
"TokenType": "JWT"
}))
.send()
.await?
.error_for_status()
.context("Xbox Live authentication failed")?
.json()
.await?;
// XSTS
let resp = client
.post("https://xsts.auth.xboxlive.com/xsts/authorize")
.json(&json!({
"Properties": {"SandboxId": "RETAIL", "UserTokens": [xbl.token]},
"RelyingParty": "rp://api.minecraftservices.com/",
"TokenType": "JWT"
}))
.send()
.await?;
if resp.status().as_u16() == 401 {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
let msg = match body.get("XErr").and_then(|v| v.as_u64()) {
Some(2148916233) => "this Microsoft account has no Xbox profile yet — sign in once at xbox.com, then try again",
Some(2148916235) => "Xbox Live isn't available in your country",
Some(2148916236) | Some(2148916237) => "this account needs adult verification on xbox.com",
Some(2148916238) => "this is a child account — an adult must add it to a Microsoft family first",
_ => "Xbox Live refused the sign-in",
};
bail!("{msg}");
}
let xsts: XboxResponse = resp.error_for_status()?.json().await?;
let claims = xsts.display_claims.xui.first().ok_or_else(|| anyhow!("missing Xbox user hash"))?;
let uhs = claims.uhs.clone();
let xuid = claims.xid.clone();
// Minecraft
let mc: McLogin = client
.post("https://api.minecraftservices.com/authentication/login_with_xbox")
.json(&json!({ "identityToken": format!("XBL3.0 x={uhs};{}", xsts.token) }))
.send()
.await?
.error_for_status()
.context("Minecraft services rejected the Xbox token (is the Azure app approved for Minecraft?)")?
.json()
.await?;
let resp = client.get("https://api.minecraftservices.com/minecraft/profile").bearer_auth(&mc.access_token).send().await?;
if resp.status().as_u16() == 404 {
bail!("this Microsoft account doesn't own Minecraft: Java Edition");
}
let profile: McProfile = resp.error_for_status()?.json().await?;
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
Ok(MsaSession { refresh_token, mc_access_token: mc.access_token, mc_expires_at: now + mc.expires_in, profile, xuid })
}
+12 -1
View File
@@ -34,8 +34,8 @@ async fn run(mc: &str, loader: Loader) {
uuid: scopenet_shared::offline_uuid("CiBot"),
access_token: "0".into(),
user_type: "legacy".into(),
xuid: None,
},
agent_args: vec![],
game_dir,
memory_min_mb: 512,
memory_max_mb: 2048,
@@ -94,3 +94,14 @@ async fn forge_1_20_1() {
async fn neoforge_1_21_1() {
run("1.21.1", Loader::NeoForge).await;
}
#[tokio::test]
#[ignore]
async fn authlib_injector_official_download() {
let dir = tempfile::tempdir().unwrap();
let layout = Layout::new(dir.path());
let jar = scopenet_core::authlib::ensure(&scopenet_core::http::client(), &layout, None).await.unwrap();
let zip = zip::ZipArchive::new(std::fs::File::open(&jar).unwrap()).unwrap();
assert!(zip.file_names().any(|n| n == "META-INF/MANIFEST.MF"), "not a jar: {}", jar.display());
println!("authlib-injector: {}", jar.display());
}