From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: Modrinth Enhanced Date: Mon, 14 Sep 2026 10:27:34 +0200 Subject: [PATCH] Add offline accounts Adds a second way to add a Minecraft account that never talks to Microsoft or Mojang, for playing singleplayer worlds and servers running in offline mode. An offline account is an ordinary row in `minecraft_users`, marked by a sentinel in the refresh token column, so no database migration is needed. `Credentials::refresh` and the online profile lookup both return early for such an account, which keeps every existing code path - launching, account switching, serialisation to the frontend - working without further changes. The player UUID is derived the way Minecraft itself derives it, as an MD5 name UUID over `OfflinePlayer:`. That is what vanilla servers in offline mode and other launchers use, so worlds keep the same player data when they are opened elsewhere. Usernames are validated the way Mojang validates them: 3 to 16 characters of letters, numbers and underscores. --- Cargo.toml | 1 + .../src/components/ui/AccountsCard.vue | 25 ++++ .../src/components/ui/OfflineAccountModal.vue | 136 ++++++++++++++++++ apps/app-frontend/src/helpers/auth.js | 13 ++ apps/app/src/api/auth.rs | 7 + packages/app-lib/Cargo.toml | 1 + packages/app-lib/src/api/minecraft_auth.rs | 39 +++++ packages/app-lib/src/state/minecraft_auth.rs | 68 +++++++++ 8 files changed, 290 insertions(+) create mode 100644 apps/app-frontend/src/components/ui/OfflineAccountModal.vue diff --git a/Cargo.toml b/Cargo.toml index a4a779c..a85f576 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -132,6 +132,7 @@ lz4_flex = { version = "0.11.5", default-features = false, features = [ "std", ] } maxminddb = "0.26.0" +md-5 = "0.10.6" modrinth-content-management = { path = "packages/modrinth-content-management" } modrinth-log = { path = "packages/modrinth-log" } modrinth-util = { path = "packages/modrinth-util" } diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue index 70cc46e..e695a6d 100644 --- a/apps/app-frontend/src/components/ui/AccountsCard.vue +++ b/apps/app-frontend/src/components/ui/AccountsCard.vue @@ -9,6 +9,10 @@ {{ formatMessage(messages.signInToMinecraft) }} + {{ formatMessage(messages.addAccount) }} + + diff --git a/apps/app-frontend/src/helpers/auth.js b/apps/app-frontend/src/helpers/auth.js index 94bd13e..cb7319a 100644 --- a/apps/app-frontend/src/helpers/auth.js +++ b/apps/app-frontend/src/helpers/auth.js @@ -33,6 +33,19 @@ export async function login() { return await invoke('plugin:auth|login') } +/** + * Adds an offline account with the given username and makes it the active one. + * + * Offline accounts never contact Microsoft or Mojang. They can play + * singleplayer worlds and join servers running in offline mode. + * + * @param {string} username + * @returns {Promise} + */ +export async function login_offline(username) { + return await invoke('plugin:auth|login_offline', { username }) +} + /** * Retrieves the default user * @return {Promise} diff --git a/apps/app/src/api/auth.rs b/apps/app/src/api/auth.rs index 8227d94..f4eded6 100644 --- a/apps/app/src/api/auth.rs +++ b/apps/app/src/api/auth.rs @@ -9,6 +9,7 @@ pub fn init() -> TauriPlugin { .invoke_handler(tauri::generate_handler![ check_reachable, login, + login_offline, remove_user, get_default_user, set_default_user, @@ -86,6 +87,12 @@ pub async fn login( Ok(None) } +/// Adds an offline account with the given username and makes it active. +#[tauri::command] +pub async fn login_offline(username: String) -> Result { + Ok(minecraft_auth::login_offline(&username).await?) +} + #[tauri::command] pub async fn remove_user(user: uuid::Uuid) -> Result<()> { Ok(minecraft_auth::remove_user(user).await?) diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index 69545c2..7da46c9 100644 --- a/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml @@ -48,6 +48,7 @@ image = { workspace = true, features = ["gif", "jpeg", "png", "webp"] } indicatif = { workspace = true, optional = true } itertools = { workspace = true } json5 = { workspace = true } +md-5 = { workspace = true } modrinth-content-management = { workspace = true } notify = { workspace = true } notify-debouncer-mini = { workspace = true } diff --git a/packages/app-lib/src/api/minecraft_auth.rs b/packages/app-lib/src/api/minecraft_auth.rs index e7195c6..a7fac4a 100644 --- a/packages/app-lib/src/api/minecraft_auth.rs +++ b/packages/app-lib/src/api/minecraft_auth.rs @@ -47,6 +47,45 @@ pub async fn finish_login( Ok(credentials) } +/// Creates an offline account for `username`, or reuses the existing one, and +/// makes it the active account. +/// +/// Offline accounts never contact Microsoft or Mojang. They are enough to play +/// singleplayer worlds and to join servers running in offline mode, and they +/// are refused by servers in online mode, exactly like offline accounts in +/// other launchers. +#[tracing::instrument] +pub async fn login_offline(username: &str) -> crate::Result { + let username = username.trim(); + + if !(3..=16).contains(&username.len()) + || !username + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { + return Err(crate::ErrorKind::InputError( + "An offline username must be 3 to 16 characters long and may only \ + contain letters, numbers and underscores" + .to_string(), + ) + .into()); + } + + let state = State::get().await?; + let credentials = Credentials::offline(username); + credentials.upsert(&state.pool).await?; + + if let Err(error) = + crate::onboarding_checklist::mark_logged_into_minecraft().await + { + tracing::warn!( + "Failed to mark Minecraft login in onboarding checklist: {error}" + ); + } + + Ok(credentials) +} + #[tracing::instrument] pub async fn get_default_user() -> crate::Result> { let state = State::get().await?; diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs index b835ad4..d97d233 100644 --- a/packages/app-lib/src/state/minecraft_auth.rs +++ b/packages/app-lib/src/state/minecraft_auth.rs @@ -6,6 +6,7 @@ use chrono::{DateTime, Duration, TimeZone, Utc}; use dashmap::DashMap; use futures::TryStreamExt; use heck::ToTitleCase; +use md5::Md5; use p256::ecdsa::signature::Signer; use p256::ecdsa::{Signature, SigningKey, VerifyingKey}; use p256::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding}; @@ -212,6 +213,34 @@ pub struct Credentials { pub active: bool, } +/// Access token handed to Minecraft for offline accounts. +/// +/// The game only uses this token to talk to Mojang's session server, which an +/// offline account never does, so the value just has to be non-empty. `0` is +/// what launchers have traditionally used for offline play. +const OFFLINE_ACCESS_TOKEN: &str = "0"; + +/// Marker stored in an offline account's refresh token column. +/// +/// Microsoft refresh tokens are opaque base64, so this value cannot collide +/// with a real one, and reusing an existing column means offline accounts need +/// no database migration. +const OFFLINE_REFRESH_TOKEN: &str = "modrinth-enhanced:offline-account"; + +/// Computes the player UUID Minecraft itself derives for an offline player. +/// +/// This mirrors Java's `UUID.nameUUIDFromBytes("OfflinePlayer:")`, which +/// is what vanilla servers in offline mode and other launchers use. Matching it +/// means a world played here keeps the same player data when it is opened from +/// somewhere else. +pub fn offline_uuid(username: &str) -> Uuid { + let mut bytes: [u8; 16] = + Md5::digest(format!("OfflinePlayer:{username}").as_bytes()).into(); + bytes[6] = (bytes[6] & 0x0f) | 0x30; // Version 3 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant + Uuid::from_bytes(bytes) +} + /// An entry in the player profile cache, keyed by player UUID. pub(super) enum ProfileCacheEntry { /// A cached profile that is valid, even though it may be stale. @@ -265,12 +294,45 @@ impl OnlineProfileCacheIntent { } impl Credentials { + /// Builds credentials for an offline account with the given username. + /// + /// Offline accounts hold no Microsoft tokens and have no Mojang profile + /// behind them; they exist so the launcher can start the game for + /// singleplayer worlds and offline-mode servers without signing in. + pub fn offline(username: &str) -> Self { + Self { + offline_profile: MinecraftProfile { + id: offline_uuid(username), + name: username.to_owned(), + ..MinecraftProfile::default() + }, + access_token: OFFLINE_ACCESS_TOKEN.to_owned(), + refresh_token: OFFLINE_REFRESH_TOKEN.to_owned(), + // There is nothing to expire. `refresh` returns early for offline + // accounts, and a far future date keeps every other expiry check + // from doing anything surprising. + expires: Utc::now() + Duration::days(365 * 100), + active: true, + } + } + + /// Whether these credentials belong to an offline account. + pub fn is_offline(&self) -> bool { + self.refresh_token == OFFLINE_REFRESH_TOKEN + } + /// Refreshes the authentication tokens for this user if they are expired, or /// very close to expiration. async fn refresh( &mut self, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result<()> { + // Offline accounts have no tokens to refresh and nothing to ask + // Microsoft about. + if self.is_offline() { + return Ok(()); + } + // Use a margin of 5 minutes to give e.g. Minecraft and potentially // other operations that depend on a fresh token 5 minutes to complete // from now, and deal with some classes of clock skew @@ -351,6 +413,12 @@ impl Credentials { &self, cache_intent: OnlineProfileCacheIntent, ) -> Option> { + // Offline accounts have no Mojang profile, so skip the request that + // would only ever fail and fall back to the offline profile. + if self.is_offline() { + return None; + } + let max_age = cache_intent.max_age(); let stale_profile = { let mut profile_cache = PROFILE_CACHE.lock().await;