Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
314 changes: 278 additions & 36 deletions Cargo.lock

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,22 @@ license = "AGPL-3.0-only"
anyhow = { version = "1.0.100" }
async-trait = "0.1.89"
dirs = { version = "7" }
erased-serde = "0.4.11"
futures = "0.3.32"
hex = "0.4.3"
jiff = { version = "0.2.0", features = ["serde"] }
reqwest = { version = "0.13.1", features = ["json", "stream", "default-tls"] }
nidhogg = { git = "https://github.com/ElectronicWave/nidhogg", rev = "68eaf3c7445ce34bfa6f3f5874f6fe28d1f7f2b8" }
reqwest = { version = "0.13.1", features = [
"json",
"multipart",
"stream",
"default-tls",
] }
scc = { version = "3.4.8", features = ["serde"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.148"
sha1 = "0.11.0"
sha2 = "0.10.9"
thiserror = "2.0.21"
tokio = { version = "1.48.0", features = [
"rt",
Expand Down
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,10 @@ Prepare and launch a vanilla instance:
```rust
use anyhow::Result;
use elemental::{
core::auth::{authorizer::Authorizer, authorizers::offline::OfflineAuthorizer},
core::auth::{
accounts::Account,
authorizers::{Authorizer, offline::OfflineAuthorizer},
},
launcher::{DriverSpec, LaunchOptions, Launcher, RuntimeOptions},
};

Expand All @@ -117,11 +120,12 @@ async fn main() -> Result<()> {
)
.await?;

let credential = OfflineAuthorizer {
let account = OfflineAuthorizer {
username: "Player".to_owned(),
}
.authorize()
.await?;
let credential = account.credential().await?;

let command = launcher
.build_launch_command(
Expand All @@ -141,9 +145,10 @@ async fn main() -> Result<()> {

Offline authentication keeps local development simple. Use a production
`Authorizer` for real player accounts. Authentication and launch are separate:
obtain a `UserCredential`, build a `LaunchCommand`, optionally customize its
arguments, then spawn it. Use `prepare_with_session()` to supply a configured
session while the launcher finishes and validates it.
obtain an `Account`, derive a `Credential`, build a `LaunchCommand`,
optionally customize its arguments, then spawn it. Use
`prepare_with_session()` to supply a configured session while the launcher
finishes and validates it.

Inspect and reopen an existing instance without preparing it again:

Expand Down Expand Up @@ -222,6 +227,9 @@ Forwarded features:
| `shared-toml` | Enables `elemental-shared/toml` |
| `infra-serde` | Enables `elemental-infra/serde` |
| `object-notify` | Enables `elemental-object/notify` |
| `yggdrasil-auth` | Enables Yggdrasil authentication support, including authlib-injector download and launch injection |
| `authlib-injector-embed` | Embeds an authlib-injector snapshot as a fallback when metadata retrieval, download, or verification fails |
| `offline-server` | Enables the local offline Yggdrasil server and authlib-injector support in `elemental-launcher` |

> [!IMPORTANT]
> Forwarded features do not enable the facade module itself. With
Expand Down
6 changes: 5 additions & 1 deletion crates/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,18 @@ license.workspace = true

[features]
default = []
yggdrasil-auth = ["dep:nidhogg"]
offline-server = ["yggdrasil-auth", "nidhogg/server"]

[dependencies]
async-trait = { workspace = true }
dirs = { workspace = true }
elemental-schema = { path = "../schema", version = "0.2.0" }
erased-serde = { workspace = true }
futures = { workspace = true }
md-5 = "0.11.0"
minecraft-msa-auth = "0.5.0"
nidhogg = { workspace = true, optional = true }
oauth2 = { version = "5.0.0", default-features = false }
oauth2-reqwest = { version = "0.1.0-alpha.3" }
reqwest = { workspace = true }
Expand All @@ -29,7 +33,7 @@ tokio = { workspace = true }
toml = { workspace = true }
tracing = "0.1.44"
tracing-subscriber = "0.3"
uuid = "1.19.0"
uuid = { version = "1.19.0", features = ["serde", "v4", "v5"] }

[target.'cfg(windows)'.dependencies]
winreg = "0.56.0"
44 changes: 37 additions & 7 deletions crates/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ spawning launch commands. Minecraft-specific installation logic lives in
toolchain directories, Minecraft's bundled runtimes, and `JAVA_HOME`/`JRE_HOME`.
- Explicit runtime selection with strict or disabled Java version validation.
- Offline and Microsoft device-code authorizers.
- Opt-in Yggdrasil authorizer through the `yggdrasil-auth` feature.
- Account-level skin and cape queries and mutations for Microsoft and Yggdrasil.
- `LaunchCommand` construction, process spawning, and streamed process logs.
- SHA-1 helpers for files and in-memory data.

Expand All @@ -36,18 +38,22 @@ Create an offline credential and list the locally discovered Java runtimes:
```rust
use anyhow::Result;
use elemental_core::{
auth::{authorizer::Authorizer, authorizers::offline::OfflineAuthorizer},
auth::{
accounts::Account,
authorizers::{Authorizer, offline::OfflineAuthorizer},
},
minecraft::MinecraftVersionId,
runtime::RuntimeResolver,
};

#[tokio::main]
async fn main() -> Result<()> {
let credential = OfflineAuthorizer {
let account = OfflineAuthorizer {
username: "Player".to_owned(),
}
.authorize()
.await?;
let credential = account.credential().await?;

println!("uuid: {}", credential.uuid);

Expand All @@ -70,7 +76,7 @@ implements `Authorizer` for real accounts.

| Module | Responsibility |
| --- | --- |
| `auth` | `Authorizer`, `UserCredential`, offline auth, and Microsoft device-code auth |
| `auth` | `Account`, `Authorizer`, `Credential`, offline, Microsoft, and Yggdrasil auth |
| `consts` | Platform-dependent constants shared by launcher code |
| `hash` | Synchronous and asynchronous SHA-1 helpers |
| `launcher` | `LaunchCommand` plus process spawning and log streaming |
Expand All @@ -79,6 +85,13 @@ implements `Authorizer` for real accounts.
| `storage` | Generic `Storage` and `Layout` resource resolution |
| `version` | The tagged `Version<Tag>` identifier wrapper |

### Feature Flags

| Feature | Description |
| --- | --- |
| `yggdrasil-auth` | Enables the Yggdrasil account and authorizer, and `Credential::auth_server` |
| `offline-server` | Enables `yggdrasil-auth` and re-exports nidhogg's offline server |

## Core Concepts

### Typed versions
Expand Down Expand Up @@ -195,10 +208,27 @@ providers without sharing state.

### Authentication and launch processes

`OfflineAuthorizer` derives a deterministic offline UUID and empty access
token. `MicrosoftAuthorizer` runs the Microsoft device-code flow and accepts a
verification callback so the host application can present the user code with
its own UI.
Authentication is split into two module trees. `auth::accounts` owns the
`Account` trait, stable account identities, and the offline, Microsoft, and
Yggdrasil account types. The Yggdrasil types require the
`yggdrasil-auth` feature. `auth::authorizers` owns the `Authorizer` trait and
the corresponding login flows. An `Authorizer` is a login entry point with a
stable `id()` used for persistence and a human-readable `name()`.
`OfflineAuthorizer` derives a deterministic offline UUID,
`MicrosoftAuthorizer` runs the Microsoft device-code flow, and
`YggdrasilAuthorizer` authenticates against an authlib-compatible API. An
`Account` is the persisted result of authorization and can be refreshed; a
`Credential` is the per-launch projection passed to the game and is never
persisted.

`Account::textures()` returns the current skin, available capes, and the
texture kinds the account can upload. Microsoft accounts expose the owned cape
inventory for selection; Yggdrasil accounts report the active cape and their
`uploadableTextures` capabilities. Mutations are explicit:
`upload_skin()`, `delete_skin()`, `upload_cape()`, `delete_cape()`, and
`select_cape()`. Providers that do not support an operation return
`auth::Error::TextureUnsupported`. Offline accounts have no remote textures;
use nidhogg's offline server for local skin and cape files.

A `LaunchCommand` contains the runtime, structured JVM and game arguments, the
main class, environment variables, and optional working directory. It remains
Expand Down
133 changes: 133 additions & 0 deletions crates/core/src/auth/accounts/microsoft.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
use std::time::{SystemTime, UNIX_EPOCH};

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

use crate::auth::accounts::{Account, AccountId};
use crate::auth::authorizers::microsoft::MICROSOFT_AUTHORIZER_ID;
use crate::auth::credential::{Credential, CredentialKind};
use crate::auth::error::Result;
use crate::auth::texture::{Cape, ProfileTextures, Skin, SkinModel, TextureType};

const REFRESH_SKEW_SECONDS: u64 = 300;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MicrosoftAccount {
pub account_id: AccountId,
pub username: String,
pub uuid: String,
pub access_token: String,
pub refresh_token: String,
pub client_id: String,
pub xuid: Option<String>,
pub expires_at: u64,
}

#[async_trait]
impl Account for MicrosoftAccount {
fn account_id(&self) -> AccountId {
self.account_id
}

fn authorizer_id(&self) -> &'static str {
MICROSOFT_AUTHORIZER_ID
}

fn display_name(&self) -> &str {
&self.username
}

fn needs_refresh(&self) -> bool {
unix_now().saturating_add(REFRESH_SKEW_SECONDS) >= self.expires_at
}

async fn credential(&self) -> Result<Credential> {
Ok(Credential {
username: self.username.clone(),
uuid: self.uuid.clone(),
access_token: self.access_token.clone(),
kind: CredentialKind::Msa,
xuid: self.xuid.clone(),
client_id: Some(self.client_id.clone()),
#[cfg(feature = "yggdrasil-auth")]
auth_server: None,
})
}

async fn refresh(&self) -> Result<Box<dyn Account>> {
Ok(Box::new(
crate::auth::authorizers::microsoft::refresh(self).await?,
))
}

async fn textures(&self) -> Result<ProfileTextures> {
profile_textures(
crate::auth::authorizers::microsoft::minecraft_profile(&self.access_token).await?,
)
}

async fn upload_skin(&self, image: Vec<u8>, model: SkinModel) -> Result<()> {
crate::auth::authorizers::microsoft::upload_skin(&self.access_token, image, model).await
}

async fn delete_skin(&self) -> Result<()> {
crate::auth::authorizers::microsoft::delete_skin(&self.access_token).await
}

async fn delete_cape(&self) -> Result<()> {
crate::auth::authorizers::microsoft::select_cape(&self.access_token, None).await
}

async fn select_cape(&self, cape_id: Option<String>) -> Result<()> {
crate::auth::authorizers::microsoft::select_cape(&self.access_token, cape_id).await
}
}

pub(crate) fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default()
}

fn profile_textures(
profile: crate::auth::authorizers::microsoft::MinecraftProfileResponse,
) -> Result<ProfileTextures> {
Ok(ProfileTextures {
skin: active_skin(profile.skins)?,
capes: profile.capes.into_iter().map(cape_from).collect(),
uploadable: vec![TextureType::Skin],
})
}

fn active_skin(
skins: Vec<crate::auth::authorizers::microsoft::MinecraftTexture>,
) -> Result<Option<Skin>> {
skins
.into_iter()
.find(|texture| texture.state.eq_ignore_ascii_case("ACTIVE"))
.map(skin_from)
.transpose()
}

fn skin_from(texture: crate::auth::authorizers::microsoft::MinecraftTexture) -> Result<Skin> {
let variant = texture.variant.unwrap_or_default();
let model = match variant.to_ascii_lowercase().as_str() {
"" | "classic" => SkinModel::Classic,
"slim" => SkinModel::Slim,
_ => return Err(crate::auth::error::Error::TextureModelUnknown { model: variant }),
};
Ok(Skin {
url: texture.url,
model,
})
}

fn cape_from(texture: crate::auth::authorizers::microsoft::MinecraftTexture) -> Cape {
Cape {
id: texture.id,
alias: texture.alias,
url: texture.url,
active: texture.state.eq_ignore_ascii_case("ACTIVE"),
}
}
Loading
Loading