Launcher: panel accounts via authlib-injector, skin & cape editor

Microsoft sign-in is gone. Server accounts now receive Yggdrasil tokens
from the panel and launch with authlib-injector pointed at the panel's
auth server, so online-mode servers verify players against it.

- Download authlib-injector from the panel mirror (fallback: official),
  SHA-256 verified and cached, with prefetched metadata
- New Skin & cape settings tab: upload, arm style, reset, cape picker
  with front/back previews
- Avatars render from the panel's head endpoint

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 08:58:27 +00:00
1 parent 99b45141fc
commit 77a15cab8b
18 files changed
+454 -208

No files matched your search

+103 -84
View File
@@ -1,16 +1,18 @@
//! Player accounts: panel (username/password), Microsoft, and local offline.
//! Player accounts: panel accounts (authenticated by the panel's Yggdrasil
//! server through authlib-injector) and local offline accounts.
use crate::secrets::Secret;
use crate::state::AppState;
use anyhow::{anyhow, bail, Context, Result};
use scopenet_shared::{offline_uuid, valid_username, AuthResponse, LoginRequest, RegisterRequest};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Account {
pub id: String,
/// "panel" | "microsoft" | "offline"
/// "panel" | "offline"
pub kind: String,
pub username: String,
pub uuid: String,
@@ -30,7 +32,13 @@ pub struct AccountsFile {
impl AccountsFile {
pub fn load(path: &Path) -> Self {
std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
let mut file: Self = std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
// Microsoft accounts are no longer supported.
file.accounts.retain(|a| a.kind == "panel" || a.kind == "offline");
if file.active.as_ref().is_some_and(|id| !file.accounts.iter().any(|a| &a.id == id)) {
file.active = file.accounts.first().map(|a| a.id.clone());
}
file
}
pub fn save(&self, path: &Path) -> Result<()> {
@@ -59,14 +67,17 @@ impl AccountsFile {
}
}
fn now() -> i64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64
}
async fn panel_error(resp: reqwest::Response) -> anyhow::Error {
let status = resp.status();
let body: serde_json::Value = resp.json().await.unwrap_or_default();
anyhow!("{}", body.get("error").and_then(|e| e.as_str()).map(String::from).unwrap_or_else(|| format!("panel returned {status}")))
anyhow!(
"{}",
body.get("error")
.and_then(|e| e.as_str())
.or_else(|| body.get("errorMessage").and_then(|e| e.as_str()))
.map(String::from)
.unwrap_or_else(|| format!("panel returned {status}"))
)
}
pub async fn login_panel(state: &AppState, username: &str, password: &str) -> Result<Account> {
@@ -114,7 +125,11 @@ fn save_panel_account(state: &AppState, panel: &str, auth: AuthResponse) -> Resu
role: Some(auth.user.role.clone()),
};
let account = state.accounts.write().unwrap().upsert(account);
state.secrets.set(&account.id, Secret { panel_token: Some(auth.token), ..Default::default() })?;
let ygg = auth.yggdrasil.ok_or_else(|| anyhow!("the server didn't start a game session — is the panel up to date?"))?;
state.secrets.set(
&account.id,
Secret { panel_token: Some(auth.token), ygg_access_token: Some(ygg.access_token), ygg_client_token: Some(ygg.client_token) },
)?;
state.save_accounts()?;
Ok(account)
}
@@ -141,93 +156,82 @@ pub fn add_offline(state: &AppState, username: &str) -> Result<Account> {
Ok(account)
}
pub fn ms_client_id(state: &AppState) -> Result<String> {
state
/// The panel's Yggdrasil API root for an account.
pub fn yggdrasil_url(state: &AppState, account: &Account) -> Option<String> {
let panel = account.panel_url.clone()?;
let from_manifest = state
.manifest
.read()
.unwrap()
.as_ref()
.and_then(|m| m.auth.microsoft.then(|| m.auth.microsoft_client_id.clone()).flatten())
.filter(|c| !c.is_empty())
.ok_or_else(|| anyhow!("Microsoft sign-in isn't enabled on this server"))
.filter(|_| state.panel_url().as_deref() == Some(panel.as_str()))
.and_then(|m| m.auth.yggdrasil_url.clone());
Some(from_manifest.unwrap_or_else(|| format!("{panel}/api/yggdrasil")))
}
pub fn save_ms_session(state: &AppState, session: scopenet_core::msa::MsaSession) -> Result<Account> {
let uuid = dashed(&session.profile.id);
let account = Account {
id: uuid::Uuid::new_v4().to_string(),
kind: "microsoft".into(),
username: session.profile.name.clone(),
uuid,
panel_url: None,
role: None,
/// Make sure the account's game session is valid, refreshing it if needed.
/// Returns the access token.
async fn ensure_session(state: &AppState, account: &Account, api: &str) -> Result<String> {
let secret = state.secrets.get(&account.id);
let (Some(access), Some(client)) = (secret.ygg_access_token.clone(), secret.ygg_client_token.clone()) else {
bail!("please sign in to {} again", account.username);
};
let account = state.accounts.write().unwrap().upsert(account);
state.secrets.set(
&account.id,
Secret {
ms_refresh_token: Some(session.refresh_token),
mc_access_token: Some(session.mc_access_token),
mc_expires_at: session.mc_expires_at,
xuid: session.xuid,
..Default::default()
},
)?;
state.save_accounts()?;
Ok(account)
}
fn dashed(id: &str) -> String {
let id = id.replace('-', "");
if id.len() != 32 {
return id;
let validate = state
.http
.post(format!("{api}/authserver/validate"))
.json(&json!({ "accessToken": access, "clientToken": client }))
.send()
.await
.context("couldn't reach the auth server")?;
if validate.status().is_success() {
return Ok(access);
}
format!("{}-{}-{}-{}-{}", &id[0..8], &id[8..12], &id[12..16], &id[16..20], &id[20..32])
let resp = state
.http
.post(format!("{api}/authserver/refresh"))
.json(&json!({ "accessToken": access, "clientToken": client }))
.send()
.await
.context("couldn't reach the auth server")?;
if !resp.status().is_success() {
bail!("your session for {} expired — please sign in again", account.username);
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Refreshed {
access_token: String,
client_token: String,
}
let r: Refreshed = resp.json().await?;
state
.secrets
.set(&account.id, Secret { ygg_access_token: Some(r.access_token.clone()), ygg_client_token: Some(r.client_token), ..secret })?;
Ok(r.access_token)
}
/// Credentials passed to the game.
pub async fn game_auth(state: &AppState, account: &Account) -> Result<scopenet_core::launch::Auth> {
/// Credentials passed to the game, plus the auth server URL for
/// authlib-injector (panel accounts only).
pub async fn game_auth(state: &AppState, account: &Account) -> Result<(scopenet_core::launch::Auth, Option<String>)> {
use scopenet_core::launch::Auth;
match account.kind.as_str() {
"microsoft" => {
let mut secret = state.secrets.get(&account.id);
if secret.mc_access_token.is_none() || secret.mc_expires_at < now() + 300 {
let refresh = secret.ms_refresh_token.clone().ok_or_else(|| anyhow!("please sign in to Microsoft again"))?;
let client_id = ms_client_id(state)?;
let session = scopenet_core::msa::refresh(&state.http, &client_id, &refresh).await?;
if session.profile.name != account.username {
let mut accounts = state.accounts.write().unwrap();
if let Some(a) = accounts.accounts.iter_mut().find(|a| a.id == account.id) {
a.username = session.profile.name.clone();
}
}
state.save_accounts().ok();
secret = Secret {
ms_refresh_token: Some(session.refresh_token),
mc_access_token: Some(session.mc_access_token),
mc_expires_at: session.mc_expires_at,
xuid: session.xuid,
..secret
};
state.secrets.set(&account.id, secret.clone())?;
}
let active_name = state.accounts.read().unwrap().accounts.iter().find(|a| a.id == account.id).map(|a| a.username.clone());
Ok(Auth {
username: active_name.unwrap_or_else(|| account.username.clone()),
uuid: account.uuid.clone(),
access_token: secret.mc_access_token.unwrap_or_default(),
user_type: "msa".into(),
xuid: secret.xuid,
})
"panel" => {
let api = yggdrasil_url(state, account).ok_or_else(|| anyhow!("account has no server"))?;
let access_token = ensure_session(state, account, &api).await?;
Ok((
Auth { username: account.username.clone(), uuid: account.uuid.clone(), access_token, user_type: "mojang".into() },
Some(api),
))
}
_ => Ok(Auth {
username: account.username.clone(),
uuid: account.uuid.clone(),
// Offline mode: any token works; servers in offline mode ignore it.
access_token: "0".into(),
user_type: "legacy".into(),
xuid: None,
}),
_ => Ok((
Auth {
username: account.username.clone(),
uuid: account.uuid.clone(),
// Offline mode: any token works; offline servers ignore it.
access_token: "0".into(),
user_type: "legacy".into(),
},
None,
)),
}
}
@@ -254,6 +258,21 @@ mod tests {
f.upsert(Account { id: "2".into(), ..a.clone() });
assert_eq!(f.accounts.len(), 1);
assert_eq!(f.active.as_deref(), Some("1"));
assert_eq!(dashed("b50ad385829d3141a2167e7d7539ba7f"), "b50ad385-829d-3141-a216-7e7d7539ba7f");
}
#[test]
fn drops_microsoft_accounts_on_load() {
let dir = std::env::temp_dir().join(format!("scopenet-acc-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("accounts.json");
std::fs::write(
&path,
r#"{"accounts":[{"id":"m","kind":"microsoft","username":"A","uuid":"x"},{"id":"o","kind":"offline","username":"B","uuid":"y"}],"active":"m"}"#,
)
.unwrap();
let f = AccountsFile::load(&path);
assert_eq!(f.accounts.len(), 1);
assert_eq!(f.active.as_deref(), Some("o"));
std::fs::remove_dir_all(dir).ok();
}
}